···11-/**
22- * @typedef {object} TimeoutSignal
33- * @property {AbortSignal} signal the ticking signal
44- * @property {VoidFunction} cancel a cleanup function, to cancel the timer
55- */
66-77-import * as common_types from '#common/types.js'
88-99-/**
1010- * returns a new abort signal which will abort after some timeout, unless cancelled.
1111- * better than AbortSignal.timeout because it consistently aborts with a timeout error
1212- *
1313- * @param {number} ms - timeout in milliseconds
1414- * @returns {TimeoutSignal} a cancellable timeout abort signal.
1515- */
1616-export function timeoutSignal(ms) {
1717- const controller = new AbortController()
1818-1919- const timeout = setTimeout(() => {
2020- controller.abort(
2121- new DOMException('Operation timed out', 'TimeoutError'),
2222- )
2323- }, ms)
2424-2525- const cancel = () => {
2626- clearTimeout(timeout)
2727- controller.signal.removeEventListener('abort', cancel)
2828- }
2929-3030- controller.signal.addEventListener('abort', cancel)
3131- return { signal: controller.signal, cancel }
3232-}
3333-3434-/**
3535- * @param {Array<AbortSignal | undefined>} signals the list of signals to combine
3636- * @returns {AbortSignal} a combined signal, which will abort when any given signal does
3737- */
3838-export function combineSignals(...signals) {
3939- /** @type {Array<common_types.VoidCallback>} */
4040- const cleanups = []
4141- const controller = new AbortController()
4242-4343- for (const signal of signals) {
4444- if (signal == undefined) continue
4545-4646- if (signal.aborted) {
4747- controller.abort(signal.reason)
4848- return controller.signal
4949- }
5050-5151- const handler = () => {
5252- if (!controller.signal.aborted) {
5353- controller.abort(signal.reason)
5454- }
5555- }
5656-5757- signal.addEventListener('abort', handler)
5858- cleanups.push(() => signal.removeEventListener('abort', handler))
5959- }
6060-6161- controller.signal.addEventListener('abort', () => {
6262- cleanups.forEach(cb => cb())
6363- })
6464-6565- return controller.signal
6666-}
+57
src/common/async/aborts.ts
···11+export interface TimeoutSignal {
22+ signal: AbortSignal
33+ cancel: () => void
44+}
55+66+/**
77+ * returns a new abort signal which will abort after some timeout, unless cancelled.
88+ * better than AbortSignal.timeout because it consistently aborts with a timeout error
99+ */
1010+export function timeoutSignal(ms: number): TimeoutSignal {
1111+ const controller = new AbortController()
1212+1313+ const timeout = setTimeout(() => {
1414+ controller.abort(new DOMException('Operation timed out', 'TimeoutError'))
1515+ }, ms)
1616+1717+ const cancel = () => {
1818+ clearTimeout(timeout)
1919+ controller.signal.removeEventListener('abort', cancel)
2020+ }
2121+2222+ controller.signal.addEventListener('abort', cancel)
2323+ return { signal: controller.signal, cancel }
2424+}
2525+2626+/**
2727+ * @param signals the list of signals to combine
2828+ * @returns a combined signal, which will abort when any given signal does
2929+ */
3030+export function combineSignals(...signals: Array<AbortSignal | undefined>): AbortSignal {
3131+ const cleanups: Array<() => void> = []
3232+ const controller = new AbortController()
3333+3434+ for (const signal of signals) {
3535+ if (signal == undefined) continue
3636+3737+ if (signal.aborted) {
3838+ controller.abort(signal.reason)
3939+ return controller.signal
4040+ }
4141+4242+ const handler = () => {
4343+ if (!controller.signal.aborted) {
4444+ controller.abort(signal.reason)
4545+ }
4646+ }
4747+4848+ signal.addEventListener('abort', handler)
4949+ cleanups.push(() => signal.removeEventListener('abort', handler))
5050+ }
5151+5252+ controller.signal.addEventListener('abort', () => {
5353+ cleanups.forEach(cb => cb())
5454+ })
5555+5656+ return controller.signal
5757+}
-52
src/common/async/blocking-atom.js
···11-/** @module common/async */
22-33-import { Semaphore } from './semaphore.js'
44-55-/**
66- * simple blocking atom, for waiting for a value.
77- * cribbed mostly from {@link https://github.com/ComFreek/async-playground}
88- *
99- * @template T - the type we're holding
1010- */
1111-export class BlockingAtom {
1212-1313- /** @type {T | undefined} */
1414- #item
1515-1616- /** @type {Semaphore} */
1717- #sema
1818-1919- constructor() {
2020- this.#sema = new Semaphore()
2121- this.#item = undefined
2222- }
2323-2424- /**
2525- * puts an item into the atom and unblocks an awaiter.
2626- *
2727- * @param {T} item the item to put into the atom
2828- */
2929- set(item) {
3030- this.#item = item
3131- this.#sema.free()
3232- }
3333-3434- /**
3535- * tries to get the item from the atom, and blocks until available.
3636- *
3737- * @example
3838- * if (await atom.take())
3939- * console.log('got it!')
4040- *
4141- * @param {AbortSignal | undefined} signal - an abort signal to cancel the await
4242- * @returns {Promise<T | undefined>} a promise for the item, or undefined if something aborted.
4343- */
4444- async get(signal) {
4545- if (await this.#sema.take(signal)) {
4646- return this.#item
4747- }
4848-4949- signal?.throwIfAborted()
5050- }
5151-5252-}
+45
src/common/async/blocking-atom.ts
···11+/** @module common/async */
22+33+import { Semaphore } from './semaphore.js'
44+55+/**
66+ * simple blocking atom, for waiting for a value.
77+ * cribbed mostly from {@link https://github.com/ComFreek/async-playground}
88+ *
99+ * @template T - the type we're holding
1010+ */
1111+export class BlockingAtom<T> {
1212+1313+ #item: T | undefined
1414+ #sema: Semaphore
1515+1616+ constructor() {
1717+ this.#sema = new Semaphore()
1818+ this.#item = undefined
1919+ }
2020+2121+ /** puts an item into the atom and unblocks an awaiter. */
2222+ set(item: T) {
2323+ this.#item = item
2424+ this.#sema.free()
2525+ }
2626+2727+ /**
2828+ * tries to get the item from the atom, and blocks until available.
2929+ *
3030+ * @example
3131+ * if (await atom.take())
3232+ * console.log('got it!')
3333+ *
3434+ * @param signal - an abort signal to cancel the await
3535+ * @returns a promise for the item, or undefined if something aborted.
3636+ */
3737+ async get(signal: AbortSignal | undefined): Promise<T | undefined> {
3838+ if (await this.#sema.take(signal)) {
3939+ return this.#item
4040+ }
4141+4242+ signal?.throwIfAborted()
4343+ }
4444+4545+}
···55/**
66 * simple blocking queue, for turning streams into async pulls.
77 * cribbed mostly from {@link https://github.com/ComFreek/async-playground}
88- *
99- * @template T
108 */
1111-export class BlockingQueue {
99+export class BlockingQueue<T> {
12101313- /** @type {Semaphore} */
1414- #sema
1515-1616- /** @type {T[]} */
1717- #items
1818-1919- /** @type {number | undefined} */
2020- #maxsize
1111+ #sema: Semaphore
1212+ #maxsize?: number
1313+ #items: T[]
21142215 constructor(maxsize = 1000) {
2316 this.#sema = new Semaphore()
2424- this.#items = []
2517 this.#maxsize = maxsize ? maxsize : undefined
1818+ this.#items = []
2619 }
27202821 /** @returns {number} how deep is the queue? */
2929- get depth() {
2222+ get depth(): number {
3023 return this.#items.length
3124 }
32253333- /**
3434- * place one or more items on the queue, to be picked up by awaiters.
3535- *
3636- * @param {...T} elements the items to place on the queue.
3737- */
3838- prequeue(...elements) {
2626+ /** place one or more items on the queue, to be picked up by awaiters. */
2727+ prequeue(...elements: T[]) {
3928 for (const el of elements.reverse()) {
4029 if (this.#maxsize && this.#items.length >= this.#maxsize) {
4130 throw Error('out of room')
···4635 }
4736 }
48374949- /**
5050- * place one or more items on the queue, to be picked up by awaiters.
5151- *
5252- * @param {...T} elements the items to place on the queue.
5353- */
5454- enqueue(...elements) {
3838+ /** place one or more items on the queue, to be picked up by awaiters. */
3939+ enqueue(...elements: T[]) {
5540 for (const el of elements) {
5641 if (this.#maxsize && this.#items.length >= this.#maxsize) {
5742 throw Error('out of room')
···6550 /**
6651 * block while waiting for an item off the queue.
6752 *
6868- * @param {AbortSignal} [signal] a signal to use for aborting the block.
6969- * @returns {Promise<T>} the item off the queue; rejects if aborted.
5353+ * @param [signal] a signal to use for aborting the block.
5454+ * @returns the item off the queue; rejects if aborted.
7055 */
7171- async dequeue(signal) {
5656+ async dequeue(signal?: AbortSignal): Promise<T> {
7257 if (await this.#sema.take(signal)) {
7358 return this.#poll()
7459 }
···66 */
77export class Semaphore {
8899- /** @type { number } */
1010- #counter = 0
1111-1212- /** @type {Array<function(boolean): void>} */
1313- #resolvers = []
99+ #counter: number = 0
1010+ #resolvers: Array<((taken: boolean) => void)> = []
14111512 constructor(count = 0) {
1613 this.#counter = count
···2017 * try to take from the semaphore, reducing it's count
2118 * if the semaphore is empty, blocks until available, or the given signal aborts.
2219 *
2323- * @param {AbortSignal | undefined} signal a signal to use to abort the block
2424- * @returns {Promise<boolean>} true if the semaphore was successfully taken, false if aborted.
2020+ * @param signal a signal to use to abort the block
2121+ * @returns true if the semaphore was successfully taken, false if aborted.
2522 */
2626- take(signal) {
2323+ take(signal?: AbortSignal): Promise<boolean> {
2724 return new Promise((resolve) => {
2825 if (signal?.aborted) return resolve(false)
2926···4845 })
4946 }
50475151- /**
5252- * try to take from the semaphore, reducing it's count, *without blocking*.
5353- *
5454- * @returns {boolean} true if the semaphore was taken, false otherwise.
5555- */
5656- poll() {
4848+ /** try to take from the semaphore, reducing it's count, *without blocking*. */
4949+ poll(): boolean {
5750 if (this.#counter <= 0) return false
58515952 this.#counter--
-28
src/common/async/sleep.js
···11-/** @module common/async */
22-33-/**
44- * @param {number} ms the number of ms to sleep
55- * @param {AbortSignal} [signal] an aptional abort signal, to cancel the sleep
66- * @returns {Promise<void>}
77- * a promise that resolves after given amount of time, and is interruptable with an abort signal.
88- */
99-export function sleep(ms, signal) {
1010- signal?.throwIfAborted()
1111-1212- const { resolve, reject, promise } = Promise.withResolvers()
1313- const timeout = setTimeout(resolve, ms)
1414-1515- if (signal) {
1616- const abortHandler = () => {
1717- clearTimeout(timeout)
1818- reject(signal.reason)
1919- }
2020-2121- signal.addEventListener('abort', abortHandler)
2222- promise.finally(() => {
2323- signal.removeEventListener('abort', abortHandler)
2424- })
2525- }
2626-2727- return promise
2828-}
+27
src/common/async/sleep.ts
···11+/** @module common/async */
22+33+/**
44+ * @param ms the number of ms to sleep
55+ * @param [signal] an aptional abort signal, to cancel the sleep
66+ * @returns a promise that resolves after given amount of time, and is interruptable with an abort signal.
77+ */
88+export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
99+ signal?.throwIfAborted()
1010+1111+ const { resolve, reject, promise } = Promise.withResolvers<void>()
1212+ const timeout = setTimeout(resolve, ms)
1313+1414+ if (signal) {
1515+ const abortHandler = () => {
1616+ clearTimeout(timeout)
1717+ reject(signal.reason)
1818+ }
1919+2020+ signal.addEventListener('abort', abortHandler)
2121+ promise.finally(() => {
2222+ signal.removeEventListener('abort', abortHandler)
2323+ })
2424+ }
2525+2626+ return promise
2727+}
+15-18
src/common/breaker.js
src/common/breaker.ts
···11/** @module common/async */
2233-import * as common_types from '#common/types.js'
33+import { Callback } from "#common/types"
4455/**
66 * A Breaker, which allows creating wrapped functions which will only be executed before
···2626 */
2727export class Breaker {
28282929- /** @type {undefined | common_types.VoidCallback} */
3030- #onTripped
3131-3232- /** @type {boolean} */
3333- #tripped
2929+ #tripped: boolean
3030+ #onTripped?: () => void
34313532 /**
3636- * @param {common_types.VoidCallback} [onTripped]
3333+ * @param [onTripped]
3734 * an optional callback, called when the breaker is tripped, /before/ any wrapped functions.
3835 */
3939- constructor(onTripped) {
3636+ constructor(onTripped?: () => void) {
4037 this.#tripped = false
4138 this.#onTripped = onTripped
4239 }
43404444- /** @returns {boolean} true if the breaker has already tripped */
4545- tripped() {
4141+ /** @returns true if the breaker has already tripped */
4242+ tripped(): boolean {
4643 return this.#tripped
4744 }
4845···5047 * wrap the given callback in a function that will trip the breaker before it's called.
5148 * any subsequent calls to the wrapped function will be no-ops.
5249 *
5353- * @param {common_types.Callback} fn the function to be wrapped in the breaker
5454- * @returns {common_types.Callback} a wrapped function, controlled by the breaker
5050+ * @param fn the function to be wrapped in the breaker
5151+ * @returns a wrapped function, controlled by the breaker
5552 */
5656- tripThen(fn) {
5757- return (...args) => {
5353+ tripThen<CB extends Callback>(fn: CB): CB {
5454+ return ((...args: Parameters<CB>): void => {
5855 if (!this.#tripped) {
5956 this.#tripped = true
6057···6259 this.#onTripped?.()
6360 fn(...args)
6461 }
6565- }
6262+ }) as CB
6663 }
67646865 /**
···7269 * @param {common_types.Callback} fn the function to be wrapped in the breaker
7370 * @returns {common_types.Callback} a wrapped function, controlled by the breaker
7471 */
7575- untilTripped(fn) {
7676- return (...args) => {
7272+ untilTripped<CB extends Callback>(fn: CB): CB {
7373+ return ((...args: Parameters<CB>): void => {
7774 if (!this.#tripped) {
7875 // TODO: if these throw, what to do?
7976 fn(...args)
8077 }
8181- }
7878+ }) as CB
8279 }
83808481}
···66const encrAlgo = { name: 'AES-GCM', length: 256 }
77const deriveAlgo = { name: 'PBKDF2', hash: 'SHA-256' }
8899-/**
1010- * @private
1111- * @param {string} [s] a possibly undefined or null input string
1212- * @returns {string} either the input or a medium-resistent random string
1313- */
1414-function orRandom(s) {
99+function orRandom(s: string): string {
1510 return s ?? nanoid(10)
1611}
17121818-/**
1919- * @private
2020- * @param {string|Uint8Array} s a string or uint8array
2121- * @returns {Uint8Array} either the input or a medium-resistent random string
2222- */
2323-function asUint8Array(s) {
1313+function asUint8Array(s: string | Uint8Array): Uint8Array {
2414 if (s instanceof Uint8Array) {
2515 return s
2616 }
2727-2828- if (typeof s === 'string') {
1717+ else if (typeof s === 'string') {
2918 return new TextEncoder().encode(s)
3019 }
3120···3827 *
3928 * @private
4029 *
4141- * @param {string} passwordStr a password for derivation
4242- * @param {string} saltStr a salt for derivation
4343- * @param {string} nonceStr a nonce for derivation
4444- * @param {number} [iterations] number of iterations for pbkdf
4545- * @returns {Promise<CryptoKey>} the derived crypto key
3030+ * @param passwordStr a password for derivation
3131+ * @param saltStr a salt for derivation
3232+ * @param nonceStr a nonce for derivation
3333+ * @param [iterations] number of iterations for pbkdf
3434+ * @returns the derived crypto key
4635 */
4747-async function deriveKey(passwordStr, saltStr, nonceStr, iterations = 100000) {
3636+async function deriveKey(passwordStr: string, saltStr: string, nonceStr: string, iterations: number = 100000): Promise<CryptoKey> {
4837 const encoder = new TextEncoder()
49385039 const password = encoder.encode(`${passwordStr}-pass-cryptosystem`)
···7968 * any missing parameter (password/salt/nonce) is replaced with a random value,
8069 * but if a stable password/salt/nonce is given, the derived keys will be stable.
8170 *
8282- * @param {string} [passwordStr] a password for derivation
8383- * @param {string} [saltStr] a salt for derivation
8484- * @param {string} [nonceStr] a nonce for derivation
8585- * @returns {Promise<Cipher>} the derived {@link Cipher}
7171+ * @param [passwordStr] a password for derivation
7272+ * @param [saltStr] a salt for derivation
7373+ * @param [nonceStr] a nonce for derivation
7474+ * @returns the derived {@link Cipher}
8675 */
8787- static async derive(passwordStr, saltStr, nonceStr) {
7676+ static async derive(passwordStr: string, saltStr: string, nonceStr: string): Promise<Cipher> {
8877 const cryptokey = await deriveKey(orRandom(passwordStr), orRandom(saltStr), orRandom(nonceStr))
8978 return new this(cryptokey)
9079 }
91809292- /** @type {CryptoKey} */
9393- #cryptokey
8181+ #cryptokey: CryptoKey
94829583 /**
9684 * import a cipher from an aleady existing {@link CryptoKey}.
9785 * does _not_ ensure that the imported key will work with our preferred encryption
9886 *
9999- * @param {CryptoKey} cryptokey the key to import into a Cipher
8787+ * @param cryptokey the key to import into a Cipher
10088 */
101101- constructor(cryptokey) {
8989+ constructor(cryptokey: CryptoKey) {
10290 this.#cryptokey = cryptokey
10391 }
1049210593 /**
106106- * @param {(string | Uint8Array)} data the data to encrypte
107107- * @returns {Promise<string>} a url-safe base64 encoded encrypted string.
9494+ * @param data the data to encrypte
9595+ * @returns a url-safe base64 encoded encrypted string.
10896 */
109109- async encrypt(data) {
9797+ async encrypt(data: (string | Uint8Array)): Promise<string> {
11098 const iv = crypto.getRandomValues(new Uint8Array(12))
11199 const encoded = asUint8Array(data)
112100 const encrypted = await crypto.subtle.encrypt({ ...encrAlgo, iv }, this.#cryptokey, encoded)
···121109 }
122110123111 /**
124124- * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher.
125125- * @returns {Promise<string>} the decrypted output, decoded into utf-8 text.
112112+ * @param encryptedData a base64 encoded string, previously encrypted with this cipher.
113113+ * @returns the decrypted output, decoded into utf-8 text.
126114 */
127127- async decryptText(encryptedData) {
115115+ async decryptText(encryptedData: string): Promise<string> {
128116 const plainbytes = await this.decryptBytes(encryptedData)
129117 return new TextDecoder().decode(plainbytes)
130118 }
131119132120 /**
133133- * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher.
134134- * @returns {Promise<ArrayBuffer>} the decrypted output, as an array buffer of bytes.
121121+ * @param encryptedData a base64 encoded string, previously encrypted with this cipher.
122122+ * @returns the decrypted output, as an array buffer of bytes.
135123 */
136136- async decryptBytes(encryptedData) {
124124+ async decryptBytes(encryptedData: string): Promise<ArrayBuffer> {
137125 const combined = base64url.decode(encryptedData)
138126139127 // Extract IV and encrypted data (which includes auth tag)
-18
src/common/crypto/errors.js
···11-import { BaseError } from '#common/errors.js'
22-import * as errors_types from '#common/errors.js'
33-44-/** Common base class for errors in the crypto module */
55-export class CryptoError extends BaseError {
66-}
77-88-/** Thrown when failing to verify a JWT signature */
99-export class JWTBadSignatureError extends CryptoError {
1010-1111- /**
1212- * @param {errors_types.BaseErrorOpts} [options] options to pass upstream
1313- */
1414- constructor(options) {
1515- super('could not verify token signature', options)
1616- }
1717-1818-}
+13
src/common/crypto/errors.ts
···11+import { BaseError, BaseErrorOpts } from '#common/errors.js'
22+33+/** Common base class for errors in the crypto module */
44+export class CryptoError extends BaseError {}
55+66+/** Thrown when failing to verify a JWT signature */
77+export class JWTBadSignatureError extends CryptoError {
88+99+ constructor(options?: BaseErrorOpts) {
1010+ super('could not verify token signature', options)
1111+ }
1212+1313+}
···2727 *
2828 * @see https://www.rfc-editor.org/rfc/rfc7517
2929 * @see https://github.com/panva/jose/blob/main/src/types.d.ts#L2
3030- * @type {z.ZodType<JWK>}
3130 */
3232-export const jwkSchema = z.union([
3131+export const jwkSchema: z.ZodType<jose.JWK> = z.union([
3332 jwkEcPublicSchema,
3433 jwkEcPrivateSchema,
3534])
36353736/**
3837 * zod transform from JWK to CryptoKey
3939- *
4040- * @type {z.ZodTransform<CryptoKey, JWK>}
4138 */
4242-export const jwkImport = z.transform(async (val, ctx) => {
3939+export const jwkImport: z.ZodTransform<CryptoKey, jose.JWK> = z.transform(async (val, ctx) => {
4340 try {
4441 if (typeof val === 'object' && val !== null) {
4542 const key = await jose.importJWK(val, joseSignAlgo.name)
···7269 return z.NEVER
7370})
74717575-/**
7676- * zod transform from exportable CryptoKey to JWK
7777- *
7878- * @type {z.ZodTransform<JWK, CryptoKey>}
7979- */
8080-export const jwkExport = z.transform(async (val, ctx) => {
7272+/** zod transform from exportable CryptoKey to JWK */
7373+export const jwkExport: z.ZodTransform<jose.JWK, CryptoKey> = z.transform(async (val, ctx) => {
8174 try {
8275 if (val.extractable) {
8376 return await jose.exportJWK(val)
···10194})
1029510396/**
104104- * @returns {Promise<CryptoKeyPair>} a newly generated, signing compatible keypair
9797+ * @returns a newly generated, signing compatible keypair
10598 */
106106-export async function generateSigningJwkPair() {
9999+export async function generateSigningJwkPair(): Promise<CryptoKeyPair> {
107100 const pair = await crypto.subtle.generateKey(subtleSignAlgo, true, ['sign', 'verify'])
108101 if (!('publicKey' in pair))
109102 throw new CryptoError('keypair returned a single key!?')
···112105}
113106114107/**
115115- * @param {jose.JWTPayload} payload the payload to sign
116116- * @returns {jose.SignJWT} a properly configured jwt signer, with the payload provided
108108+ * @param payload the payload to sign
109109+ * @returns a properly configured jwt signer, with the payload provided
117110 */
118118-export function generateSignableJwt(payload) {
111111+export function generateSignableJwt(payload: jose.JWTPayload): jose.SignJWT {
119112 return new jose.SignJWT(payload)
120113 .setProtectedHeader({ alg: joseSignAlgo.name })
121114}
-102
src/common/crypto/jwts.js
···11-/** @module common/crypto */
22-33-import * as jose from 'jose'
44-import { z } from 'zod/v4'
55-import { JWTBadSignatureError } from '#common/crypto/errors.js'
66-import { normalizeError } from '#common/errors.js'
77-88-const signAlgo = { name: 'ES256' }
99-1010-/**
1111- * @typedef {object} JWTToken
1212- * @property {string} token the still-encoded JWT, for later verification
1313- * @property {jose.JWTPayload} claims the decoded JWT payload, for later verification
1414- */
1515-1616-/**
1717- * @template T
1818- * @typedef {object} JWTTokenPayload
1919- * @property {string} token the still-encoded JWT, for later verification
2020- * @property {jose.JWTPayload} claims the decoded JWT payload, for later verification
2121- * @property {T} payload the validate payload type, extracted from a payloadkey
2222- */
2323-2424-/**
2525- * schema describing a decoded JWT.
2626- * **important** - this does no claims validation, only decoding from string to JWT!
2727- *
2828- * @type {z.ZodType<JWTToken, string>}
2929- */
3030-export const jwtSchema = z.jwt({ abort: true }).transform((token, ctx) => {
3131- try {
3232- const claims = jose.decodeJwt(token)
3333- return { claims, token }
3434- }
3535- catch (e) {
3636- ctx.issues.push({
3737- code: 'custom',
3838- message: `error while decoding token: ${e}`,
3939- input: token,
4040- })
4141-4242- return z.NEVER
4343- }
4444-})
4545-4646-/**
4747- * schema describing a verified payload in a JWT.
4848- * **important** - this does no claims validation, only decoding from string to JWT!
4949- *
5050- * @template T
5151- * @param {z.ZodType<T>} schema the schema to extract from the payload
5252- * @returns {z.ZodType<JWTTokenPayload<T>>} transformer
5353- */
5454-export const jwtPayload = (schema) => {
5555- const parser = z.looseObject({ payload: schema })
5656-5757- return jwtSchema.transform(async (payload, ctx) => {
5858- const result = await parser.safeParseAsync(payload.claims)
5959- if (result.success)
6060- return { ...payload, payload: result.data.payload }
6161-6262- result.error.issues.forEach((iss) => {
6363- ctx.issues.push(iss)
6464- })
6565-6666- return z.NEVER
6767- })
6868-}
6969-7070-/** @typedef {Partial<Omit<jose.JWTVerifyOptions, 'algorithms'>>} VerifyOpts */
7171-7272-/**
7373- * @param {string} jwt the (still encoded) token to verify
7474- * @param {CryptoKey} pubkey the key with which to verify the token
7575- * @param {VerifyOpts} [options] the key with which to verify the token
7676- * @returns {Promise<jose.JWTPayload>} a verified payload
7777- * @throws {JWTBadSignatureError} if the signature is not valid
7878- */
7979-export async function verifyJwtToken(jwt, pubkey, options = {}) {
8080- try {
8181- const result = await jose.jwtVerify(jwt, pubkey, {
8282- algorithms: [signAlgo.name],
8383- ...options,
8484- })
8585-8686- return result.payload
8787- }
8888- catch (exc) {
8989- const err = normalizeError(exc)
9090- throw new JWTBadSignatureError({ cause: err })
9191- }
9292-}
9393-9494-/**
9595- * generate a fingerprint for the given crypto key
9696- *
9797- * @param {CryptoKey} key the key to fingerprint
9898- * @returns {Promise<string>} the sha256 fingerprint of the key
9999- */
100100-export async function fingerprintKey(key) {
101101- return await jose.calculateJwkThumbprint(key, 'sha256')
102102-}
+93
src/common/crypto/jwts.ts
···11+/** @module common/crypto */
22+33+import * as jose from 'jose'
44+import { z } from 'zod/v4'
55+import { JWTBadSignatureError } from '#common/crypto/errors'
66+import { normalizeError } from '#common/errors'
77+88+const signAlgo = { name: 'ES256' }
99+1010+interface JWTToken {
1111+ token: string
1212+ claims: jose.JWTPayload
1313+}
1414+1515+interface JWTTokenPayload<T> {
1616+ token: string
1717+ claims: jose.JWTPayload
1818+ payload: T
1919+}
2020+2121+/**
2222+ * schema describing a decoded JWT.
2323+ * **important** - this does no claims validation, only decoding from string to JWT!
2424+ */
2525+export const jwtSchema: z.ZodType<JWTToken, string> = z.jwt({ abort: true }).transform((token, ctx) => {
2626+ try {
2727+ const claims = jose.decodeJwt(token)
2828+ return { claims, token }
2929+ }
3030+ catch (e) {
3131+ ctx.issues.push({
3232+ code: 'custom',
3333+ message: `error while decoding token: ${e}`,
3434+ input: token,
3535+ })
3636+3737+ return z.NEVER
3838+ }
3939+})
4040+4141+/**
4242+ * schema describing a verified payload in a JWT.
4343+ * **important** - this does no claims validation, only decoding from string to JWT!
4444+ */
4545+export const jwtPayload = <T>(schema: z.ZodType<T>): z.ZodType<JWTTokenPayload<T>> => {
4646+ const parser = z.looseObject({ payload: schema })
4747+4848+ return jwtSchema.transform(async (payload, ctx) => {
4949+ const result = await parser.safeParseAsync(payload.claims)
5050+ if (result.success)
5151+ return { ...payload, payload: result.data.payload }
5252+5353+ result.error.issues.forEach((iss) => {
5454+ ctx.issues.push(iss)
5555+ })
5656+5757+ return z.NEVER
5858+ })
5959+}
6060+6161+type VerifyOpts = Partial<Omit<jose.JWTVerifyOptions, 'algorithms'>>
6262+6363+/**
6464+ * @param jwt the (still encoded) token to verify
6565+ * @param pubkey the key with which to verify the token
6666+ * @param [options] the key with which to verify the token
6767+ * @returns a verified payload
6868+ * @throws if the signature is not valid
6969+ */
7070+export async function verifyJwtToken(jwt: string, pubkey: CryptoKey, options: VerifyOpts = {}): Promise<jose.JWTPayload> {
7171+ try {
7272+ const result = await jose.jwtVerify(jwt, pubkey, {
7373+ algorithms: [signAlgo.name],
7474+ ...options,
7575+ })
7676+7777+ return result.payload
7878+ }
7979+ catch (exc) {
8080+ const err = normalizeError(exc)
8181+ throw new JWTBadSignatureError({ cause: err })
8282+ }
8383+}
8484+8585+/**
8686+ * generate a fingerprint for the given crypto key
8787+ *
8888+ * @param key the key to fingerprint
8989+ * @returns the sha256 fingerprint of the key
9090+ */
9191+export async function fingerprintKey(key: CryptoKey): Promise<string> {
9292+ return await jose.calculateJwkThumbprint(key, 'sha256')
9393+}
-127
src/common/errors.js
···11-/** @module common */
22-33-import { prettifyError, ZodError } from 'zod/v4'
44-55-/**
66- * @private
77- * @type {Record<number, string>}
88- */
99-const StatusCodes = {
1010- 400: 'Bad Request',
1111- 401: 'Unauthorized',
1212- 403: 'Forbidden',
1313- 404: 'Not Found',
1414- 408: 'Request Timeout',
1515- 409: 'Conflict',
1616- 429: 'Too Many Requests',
1717- 499: 'Client Closed Request',
1818- 500: 'Internal Server Error',
1919-}
2020-2121-/**
2222- * @typedef {object} BaseErrorOpts
2323- * @property {Error | undefined} cause the cause of the error.
2424- */
2525-2626-/**
2727- * Common base class for Skypod Errors
2828- * only difference is that we explicitly type cause to be Error
2929- *
3030- * @augments Error
3131- * @property {Error | undefined} cause the cause of the error.
3232- */
3333-// cause is called out in order to get a known type
3434-export class BaseError extends Error {
3535-3636- /**
3737- * @param {string} message a "details" message representing this error
3838- * @param {BaseErrorOpts} [options] a previous error we're wrapping
3939- */
4040- constructor(message, options) {
4141- super(message, options)
4242-4343- if (options?.cause)
4444- this.cause = normalizeError(options.cause)
4545- }
4646-4747-}
4848-4949-/**
5050- * Common base class for Websocket Errors
5151- *
5252- * @augments BaseError
5353- */
5454-export class ProtocolError extends BaseError {
5555-5656- /**
5757- * @param {string} message a "details" message representing this error
5858- * @param {number} status the HTTP status code representing this error
5959- * @param {BaseErrorOpts} [options] a previous error we're wrapping
6060- */
6161- constructor(message, status, options) {
6262- const statusText = StatusCodes[status] || 'Unknown'
6363- super(`${status} ${statusText}: ${message}`, options)
6464-6565- this.name = this.constructor.name
6666- this.status = status
6767- }
6868-6969-}
7070-7171-/**
7272- * @param {Error} e the error to check
7373- * @param {number} [status] the protocol status to check, or any protocol error if undefined.
7474- * @returns {boolean} true if the given error is a protocol error with the given status.
7575- */
7676-export function isProtocolError(e, status) {
7777- return (e instanceof ProtocolError && (status === undefined || e.status == status))
7878-}
7979-8080-/**
8181- * Normalizes the given error into a protocol error; passes through input that is already
8282- * protocol errors.
8383- *
8484- * @param {unknown} cause some caught error
8585- * @returns {ProtocolError} an apporpriate protocol error
8686- */
8787-export function normalizeProtocolError(cause) {
8888- if (cause instanceof ProtocolError)
8989- return cause
9090-9191- if (cause instanceof ZodError)
9292- return new ProtocolError(prettifyError(cause), 400, { cause })
9393-9494- if (cause instanceof Error || cause instanceof DOMException) {
9595- if (cause.name === 'TimeoutError')
9696- return new ProtocolError('operation timed out', 408, { cause })
9797-9898- if (cause.name === 'AbortError')
9999- return new ProtocolError('operation was aborted', 499, { cause })
100100-101101- return new ProtocolError(cause.message, 500, { cause })
102102- }
103103-104104- // fallback, unknown
105105- const options = cause == undefined ? undefined : { cause: normalizeError(cause) }
106106- return new ProtocolError(`Error! ${cause}`, 500, options)
107107-}
108108-109109-/**
110110- * Error wrapper for unknown errors (not an Error?)
111111- *
112112- * @augments Error
113113- */
114114-export class NormalizedError extends Error {}
115115-116116-/**
117117- * wrap the given failure error into an error; passes through input that is already an Error object.
118118- *
119119- * @param {unknown} failure some caught error
120120- * @returns {Error} an apporpriate error
121121- */
122122-export function normalizeError(failure) {
123123- if (failure instanceof Error)
124124- return failure
125125-126126- return new NormalizedError(`unnormalized failure ${failure}`)
127127-}
···11+/** @module common */
22+33+import { prettifyError, ZodError } from 'zod/v4'
44+55+const StatusCodes: Record<number, string> = {
66+ 400: 'Bad Request',
77+ 401: 'Unauthorized',
88+ 403: 'Forbidden',
99+ 404: 'Not Found',
1010+ 408: 'Request Timeout',
1111+ 409: 'Conflict',
1212+ 429: 'Too Many Requests',
1313+ 499: 'Client Closed Request',
1414+ 500: 'Internal Server Error',
1515+}
1616+1717+/** Base error options interface */
1818+export interface BaseErrorOpts {
1919+ /** the cause of the error */
2020+ cause?: Error
2121+}
2222+2323+/**
2424+ * Common base class for Skypod Errors
2525+ * only difference is that we explicitly type cause to be Error
2626+ */
2727+export class BaseError extends Error {
2828+2929+ /** the cause of the error */
3030+ declare cause?: Error
3131+3232+ constructor(message: string, options?: BaseErrorOpts) {
3333+ super(message, options)
3434+ if (options?.cause)
3535+ this.cause = normalizeError(options.cause)
3636+ }
3737+3838+}
3939+4040+/** Common base class for Websocket Errors */
4141+export class ProtocolError extends BaseError {
4242+4343+ /** the HTTP status code representing this error */
4444+ status: number
4545+4646+ constructor(message: string, status: number, options?: BaseErrorOpts) {
4747+ const statusText = StatusCodes[status] || 'Unknown'
4848+ super(`${status} ${statusText}: ${message}`, options)
4949+5050+ this.name = this.constructor.name
5151+ this.status = status
5252+ }
5353+5454+}
5555+5656+/** Check if an error is a protocol error with optional status check */
5757+export function isProtocolError(e: Error, status?: number): e is ProtocolError {
5858+ return (e instanceof ProtocolError && (status === undefined || e.status == status))
5959+}
6060+6161+/**
6262+ * Normalizes the given error into a protocol error
6363+ * passes through input that is already protocol errors.
6464+ */
6565+export function normalizeProtocolError(cause: unknown): ProtocolError {
6666+ if (cause instanceof ProtocolError)
6767+ return cause
6868+6969+ if (cause instanceof ZodError)
7070+ return new ProtocolError(prettifyError(cause), 400, { cause })
7171+7272+ if (cause instanceof Error || cause instanceof DOMException) {
7373+ if (cause.name === 'TimeoutError')
7474+ return new ProtocolError('operation timed out', 408, { cause })
7575+7676+ if (cause.name === 'AbortError')
7777+ return new ProtocolError('operation was aborted', 499, { cause })
7878+7979+ return new ProtocolError(cause.message, 500, { cause })
8080+ }
8181+8282+ // fallback, unknown
8383+ const options = cause == undefined ? undefined : { cause: normalizeError(cause) }
8484+ return new ProtocolError(`Error! ${cause}`, 500, options)
8585+}
8686+8787+/** Error wrapper for unknown errors (not an Error?) */
8888+export class NormalizedError extends Error {}
8989+9090+/**
9191+ * wrap the given failure error into an error
9292+ * passes through input that is already an Error object.
9393+ */
9494+export function normalizeError(failure: unknown): Error {
9595+ if (failure instanceof Error)
9696+ return failure
9797+9898+ return new NormalizedError(`unnormalized failure ${failure}`)
9999+}
-28
src/common/protocol.js
···11-/** @module common/protocol */
22-33-export * from './protocol/brands.js'
44-export * from './protocol/messages.js'
55-export * from './protocol/messages-preauth.js'
66-export * from './protocol/messages-realm.js'
77-88-import { z } from 'zod/v4'
99-1010-/**
1111- * A zod transformer for parsing Json
1212- *
1313- * @type {z.ZodTransform<object, string>}
1414- */
1515-export const parseJson = z.transform((input, ctx) => {
1616- try {
1717- return JSON.parse(input)
1818- }
1919- catch {
2020- ctx.issues.push({
2121- code: 'custom',
2222- input,
2323- message: 'input could not be parsed as JSON',
2424- })
2525-2626- return z.NEVER
2727- }
2828-})
+26
src/common/protocol.ts
···11+/** @module common/protocol */
22+33+export * from './protocol/brands'
44+export * from './protocol/messages'
55+export * from './protocol/messages-preauth'
66+export * from './protocol/messages-realm'
77+88+import { z } from 'zod/v4'
99+1010+/** A zod transformer for parsing json */
1111+export const parseJson: z.ZodTransform<unknown, string> = z.transform(
1212+ (input, ctx) => {
1313+ try {
1414+ return JSON.parse(input)
1515+ }
1616+ catch {
1717+ ctx.issues.push({
1818+ code: 'custom',
1919+ input,
2020+ message: 'input could not be parsed as JSON',
2121+ })
2222+2323+ return z.NEVER
2424+ }
2525+ }
2626+)
-8
src/common/protocol/brands.js
···11-import { z as z_types } from 'zod/v4'
22-import { Brand } from '#common/schema/brand.js'
33-44-export const IdentBrand = new Brand('ident')
55-/** @typedef {z_types.infer<typeof IdentBrand.schema>} IdentID */
66-77-export const RealmBrand = new Brand('realm')
88-/** @typedef {z_types.infer<typeof RealmBrand.schema>} RealmID */
+9
src/common/protocol/brands.ts
···11+import { Brand } from '#common/schema/brand'
22+33+const ident = Symbol('ident')
44+export const IdentBrand = new Brand<typeof ident>(ident, 'ident')
55+export type IdentID = ReturnType<typeof IdentBrand.generate>
66+77+const realm = Symbol('realm')
88+export const RealmBrand = new Brand<typeof realm>(realm, 'realm')
99+export type RealmID = ReturnType<typeof RealmBrand.generate>
···11+import { z } from 'zod/v4'
22+33+/** zod schema for `ok` message */
44+export const responseOkSchema = z.object({
55+ ok: z.literal(true),
66+})
77+export type ResponseOk = z.infer<typeof responseOkSchema>
88+99+/** zod schema for `error` message */
1010+export const responseErrorSchema = z.object({
1111+ ok: z.literal(false),
1212+ status: z.number(),
1313+ message: z.string(),
1414+})
1515+export type ErrorResponse = z.infer<typeof responseErrorSchema>
-69
src/common/schema/brand.js
···11-/** @module common/schema */
22-33-import { nanoid } from 'nanoid'
44-import { z } from 'zod/v4'
55-66-/**
77- * A brand creates identifiers that are typesafe by construction,
88- * and shouldn't be able to be passed to the wrong resource type.
99- */
1010-export class Brand {
1111-1212- /**
1313- * @private
1414- * @typedef {z.core.$ZodBranded<z.ZodString, symbol>} BrandSchema
1515- * @typedef {z.infer<BrandSchema>} BrandId
1616- */
1717-1818- /** @type {string} */
1919- #prefix
2020-2121- /** @type {number} */
2222- #length
2323-2424- /** @type {BrandSchema} */
2525- #schema
2626-2727- /**
2828- * @param {string} prefix string prefix for the identifier, eg. "ident", "acct")
2929- * @param {number} [length] the length of the random identifier part
3030- */
3131- constructor(prefix, length = 16) {
3232- const brand = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex chars
3333- const pattern = new RegExp(`^${brand}-[A-Za-z0-9_-]{${length.toString()}}$`)
3434-3535- this.#prefix = brand
3636- this.#length = length
3737- this.#schema = z.string().regex(pattern).brand(Symbol(brand))
3838- }
3939-4040- get schema() {
4141- return this.#schema
4242- }
4343-4444- /** @returns {BrandId} a generated identifier */
4545- generate() {
4646- const id = nanoid(this.#length)
4747- return this.#schema.parse(`${this.#prefix}-${id}`)
4848- }
4949-5050- /**
5151- * @param {unknown} input the input to validate
5252- * @returns {BrandId} a valid identifier
5353- * @throws {z.ZodError} if the input could not be parsed
5454- */
5555- parse(input) {
5656- return this.#schema.parse(input)
5757- }
5858-5959- /**
6060- * @param {unknown} input the input to validate
6161- * @returns {boolean} true if a valid identifier, false otherwise
6262- */
6363- validate(input) {
6464- return input != null
6565- && typeof input === 'string'
6666- && this.#schema.safeParse(input).success
6767- }
6868-6969-}
+45
src/common/schema/brand.ts
···11+/** @module common/schema */
22+33+import { nanoid } from 'nanoid'
44+import { z } from 'zod/v4'
55+66+export type Branded<T, B> = T & { __brand: B }
77+88+/**
99+ * A brand creates identifiers that are typesafe by construction,
1010+ * and shouldn't be able to be passed to the wrong resource type.
1111+ */
1212+export class Brand<B extends symbol> {
1313+1414+ #prefix: string
1515+ #length: number
1616+ #schema
1717+1818+ constructor(brand: B, prefix: string, length = 16) {
1919+ prefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex chars
2020+ const pattern = new RegExp(`^${prefix}-[A-Za-z0-9_-]{${length.toString()}}$`)
2121+2222+ this.#prefix = prefix
2323+ this.#length = length
2424+ this.#schema = z.string().regex(pattern).brand(brand)
2525+ }
2626+2727+ get schema(): z.ZodType<Branded<string, B>> {
2828+ return this.#schema as unknown as z.ZodType<Branded<string, B>>
2929+ }
3030+3131+ generate() {
3232+ const id = nanoid(this.#length)
3333+ return this.#schema.parse(`${this.#prefix}-${id}`) as Branded<string, B>
3434+ }
3535+3636+ parse(input: unknown): Branded<string, B> {
3737+ return this.#schema.parse(input) as Branded<string, B>
3838+ }
3939+4040+ /** @return a boolean if the string is valid */
4141+ validate(input: string): input is Branded<string, B> {
4242+ return input != null && typeof input === 'string' && this.#schema.safeParse(input).success
4343+ }
4444+4545+}
+42-71
src/common/socket.js
src/common/socket.ts
···11/** @module common/socket */
2233-import * as z_types from 'zod/v4'
44-55-import { combineSignals } from '#common/async/aborts.js'
66-import { BlockingAtom } from '#common/async/blocking-atom.js'
77-import { BlockingQueue } from '#common/async/blocking-queue.js'
88-import { Breaker } from '#common/breaker.js'
99-import { ProtocolError } from '#common/errors.js'
33+import { combineSignals } from '#common/async/aborts'
44+import { BlockingAtom } from '#common/async/blocking-atom'
55+import { BlockingQueue } from '#common/async/blocking-queue'
66+import { Breaker } from '#common/breaker'
77+import { normalizeError, ProtocolError } from '#common/errors'
88+import { z } from 'zod/v4'
1091110import { parseJson } from './protocol.js'
12111313-/**
1414- * Send some data in JSON format down the wire.
1515- *
1616- * @param {WebSocket} ws the socket to send on
1717- * @param {unknown} data the data to send
1818- */
1919-export function sendSocket(ws, data) {
1212+/** Send some data in JSON format down the wire. */
1313+export function sendSocket(ws: WebSocket, data: unknown): void {
2014 ws.send(JSON.stringify(data))
2115}
2216···3529 * if (ws.readyState !== ws.CLOSED)
3630 * ws.close();
3731 * }
3838- *
3939- * @param {WebSocket} ws the socket to read
4040- * @param {AbortSignal} [signal] an abort signal to cancel the block
4141- * @returns {Promise<unknown>} the message off the socket
4232 */
4343-export async function takeSocket(ws, signal) {
3333+export async function takeSocket(ws: WebSocket, signal?: AbortSignal): Promise<unknown> {
4434 signal?.throwIfAborted()
45354636 const atom = new BlockingAtom()
···7565/**
7666 * exactly take socket, but will additionally apply a json decoding
7767 *
7878- * @template T the schema's type
7979- * @param {WebSocket} ws the socket to read
8080- * @param {z_types.ZodSchema<T>} schema an a schema to execute
8181- * @param {AbortSignal} [signal] an abort signal to cancel the block
8282- * @returns {Promise<T>} the message off the socket
6868+ * @param ws the socket to read
6969+ * @param schema an a schema to execute
7070+ * @param [signal] an abort signal to cancel the block
7171+ * @returns the message off the socket
8372 */
8484-export async function takeSocketJson(ws, schema, signal) {
7373+export async function takeSocketJson<T>(ws: WebSocket, schema: z.ZodSchema<T>, signal?: AbortSignal): Promise<T> {
8574 const data = await takeSocket(ws, signal)
8675 return parseJson.pipe(schema).parseAsync(data)
8776}
88778989-/**
9090- * stream configuration options
9191- *
9292- * @typedef ConfigProps
9393- * @property {number} maxDepth max depth of the queue, after which messages are dropped
9494- * @property {AbortSignal} [signal] an abort signal to cancel the block
9595- */
7878+/** stream configuration options */
96799797-/** @type {ConfigProps} */
9898-export const STREAM_CONFIG_DEFAULT = { maxDepth: 1000 }
8080+interface ConfigProps {
8181+ maxDepth: number
8282+ signal?: AbortSignal
8383+}
8484+8585+export const STREAM_CONFIG_DEFAULT: ConfigProps = {
8686+ maxDepth: 1000
8787+}
998810089// symbols for iteration protocol
9090+10191const yield$ = Symbol('yield$')
10292const error$ = Symbol('error$')
103103-const end$ = Symbol('end$')
9393+const end$ = Symbol('end$')
9494+9595+type StreamYield =
9696+ | [typeof yield$, unknown]
9797+ | [typeof error$, Error]
9898+ | [typeof end$]
10499105100/**
106101 * Given a websocket, stream messages off the socket as an async generator.
···119114 * if (ws.readyState !== ws.CLOSED)
120115 * ws.close();
121116 * }
122122- *
123123- *
124124- *
125125- * @param {WebSocket} ws the socket to read
126126- * @param {Partial<ConfigProps>} [config_] stream configuration to merge into defaults
127127- * @yields {unknown} messages from the socket
128117 */
129129-export async function* streamSocket(ws, config_) {
130130- const { signal, ...config } = { ...STREAM_CONFIG_DEFAULT, ...config_ }
118118+export async function* streamSocket(ws: WebSocket, config_?: Partial<ConfigProps>) {
119119+ const { signal, ...config } = { ...STREAM_CONFIG_DEFAULT, ...(config_ || {}) }
131120 signal?.throwIfAborted()
132121133122 // await incoming messages without blocking
134134- /** @type {BlockingQueue<Array<*>>} */
135135- const queue = new BlockingQueue(config.maxDepth)
123123+ const queue = new BlockingQueue<StreamYield>(config.maxDepth)
136124137125 // if true, we're ignoring incoming messages until we drop the queue
138126 let inBackoffMode = false
···143131144132 // define callback functions (need to be able to reference for removing them)
145133146146- /** @type {function(MessageEvent): void} */
147147- const onMessage = breaker.untilTripped((m) => {
134134+ const onMessage = breaker.untilTripped((m: MessageEvent) => {
148135 if (inBackoffMode) {
149136 console.warn('ignoring incoming message due to backpressure protection!')
150137 return
···157144 }
158145 })
159146160160- /** @type {function(Event): void} */
161147 // todo: why are we getting this on client shutdown instead of onClose?
162162- const onError = breaker.tripThen((e) => {
163163- if (e.message === 'Unexpected EOF') {
164164- queue.enqueue([end$])
165165- }
166166- else {
167167- queue.enqueue([error$, e])
168168- }
148148+ const onError = breaker.tripThen((e: Event) => {
149149+ queue.enqueue([error$, normalizeError(e)])
169150 })
170151171171- /** @type {function(): void} */
172152 const onClose = breaker.tripThen(() => {
173153 queue.enqueue([end$])
174154 })
···209189/**
210190 * exactly stream socket, but will additionally apply a json decoding
211191 * messages not validating will end the stream with an error
212212- *
213213- * @param {WebSocket} ws the socket to read
214214- * @param {Partial<ConfigProps>} [config] stream configuration to merge into defaults
215215- * @returns {AsyncGenerator<unknown>} an async generator
216216- * @yields the message off the socket
217192 */
218218-export async function* streamSocketJson(ws, config) {
193193+export async function* streamSocketJson(ws: WebSocket, config?: Partial<ConfigProps>): AsyncGenerator<unknown> {
219194 for await (const message of streamSocket(ws, config)) {
220195 yield parseJson.parseAsync(message)
221196 }
···224199/**
225200 * exactly stream socket, but will additionally apply a json decoding
226201 * messages not validating will end the stream with an error
227227- *
228228- * @template T the schema's type
229229- * @param {WebSocket} ws the socket to read
230230- * @param {z_types.ZodSchema<T>} schema an a schema to execute
231231- * @param {Partial<ConfigProps>} [config] stream configuration to merge into defaults
232232- * @returns {AsyncGenerator<T>} an async generator
233233- * @yields the message off the socket
234202 */
235235-export async function* streamSocketSchema(ws, schema, config) {
203203+export async function* streamSocketSchema<T>(
204204+ ws: WebSocket,
205205+ schema: z.ZodSchema<T>,
206206+ config?: Partial<ConfigProps>,
207207+): AsyncGenerator<T> {
236208 const parser = parseJson.pipe(schema)
237237-238209 for await (const message of streamSocket(ws, config)) {
239210 yield await parser.parseAsync(message)
240211 }
-54
src/common/strict-map.js
···11-/** @module common */
22-33-/**
44- * A map with methods to ensure key presence and safe update.
55- *
66- * @template K, V
77- * @augments {Map<K, V>}
88- */
99-export class StrictMap extends Map {
1010-1111- /**
1212- * @param {K} key to lookup in the map, throwing is missing
1313- * @returns {V} the value from the map
1414- * @throws {Error} if the key is not present in the map
1515- */
1616- require(key) {
1717- if (!this.has(key)) throw Error(`key is required but not in the map`)
1818-1919- const value = /** @type {V} */ (this.get(key))
2020- return value
2121- }
2222-2323- /**
2424- * @param {K} key to lookup in the map
2525- * @param {function(): V} maker a callback which will create the value in the map if not present.
2626- * @returns {V} the value from the map, possibly newly created by {maker}
2727- */
2828- ensure(key, maker) {
2929- if (!this.has(key)) {
3030- this.set(key, maker())
3131- }
3232-3333- return /** @type {V} */ (this.get(key))
3434- }
3535-3636- /**
3737- * @param {K} key to update in the map
3838- * @param {function(V=): V | undefined} update
3939- * function which returns the new value for the map
4040- * if the return value is REMOVE_KEY, the whole entry in the map will be removed.
4141- */
4242- update(key, update) {
4343- const prev = this.get(key)
4444- const next = update(prev)
4545-4646- if (next === undefined) {
4747- this.delete(key)
4848- }
4949- else {
5050- this.set(key, next)
5151- }
5252- }
5353-5454-}
+39
src/common/strict-map.ts
···11+/** @module common */
22+33+/** A map with methods to ensure key presence and safe update. */
44+export class StrictMap<K, V> extends Map<K, V> {
55+66+ /**
77+ * Get a value from the map, throwing if missing
88+ * @throws {Error} if the key is not present in the map
99+ */
1010+ require(key: K): V {
1111+ if (!this.has(key)) throw Error(`key is required but not in the map`)
1212+1313+ const value = this.get(key)!
1414+ return value
1515+ }
1616+1717+ /** Get a value from the map, creating it if not present */
1818+ ensure(key: K, maker: () => V): V {
1919+ if (!this.has(key)) {
2020+ this.set(key, maker())
2121+ }
2222+2323+ return this.get(key)!
2424+ }
2525+2626+ /** Update a value in the map, removing if undefined is returned */
2727+ update(key: K, update: (prev?: V) => V | undefined): void {
2828+ const prev = this.get(key)
2929+ const next = update(prev)
3030+3131+ if (next === undefined) {
3232+ this.delete(key)
3333+ }
3434+ else {
3535+ this.set(key, next)
3636+ }
3737+ }
3838+3939+}
+2-4
src/common/types.js
src/common/types.ts
···4455/**
66 * A callback function, with arbitrary arguments; use {Parameters} to extract them.
77- *
88- * @typedef {function(...*): void} Callback
97 */
88+export type Callback = (...args: any[]) => void
1091110/**
1211 * A callback function with no arguments.
1313- *
1414- * @typedef {function(): void} VoidCallback
1512 */
1313+export type VoidCallback = () => void
16141715const output = NEVER
1816export default output
+7-9
src/server/index.js
src/server/index.ts
···22import * as http from 'http'
33import { WebSocketServer } from 'ws'
4455-import { apiRouter } from './routes-api/middleware.js'
66-import { socketHandler } from './routes-socket/handler.js'
77-import { makeStaticMiddleware, makeSpaMiddleware } from './routes-static.js'
88-import { notFoundHandler } from './routes-error.js'
99-1010-/** @typedef {typeof http.IncomingMessage} Input */
55+import { apiRouter } from './routes-api/middleware'
66+import { socketHandler } from './routes-socket/handler'
77+import { makeStaticMiddleware, makeSpaMiddleware } from './routes-static'
88+import { notFoundHandler } from './routes-error'
1191210/**
1311 * configures an http server which hosts the SPA and websocket endpoint
1412 *
1515- * @param {string} root the path to the root public/ directory
1616- * @returns {http.Server<Input>} a configured server
1313+ * @param root the path to the root public/ directory
1414+ * @returns a configured server
1715 */
1818-export function buildServer(root) {
1616+export function buildServer(root: string): http.Server<typeof http.IncomingMessage> {
1917 const app = express()
2018 const server = http.createServer(app)
2119
···11+import * as express from 'express'
22+33+export const notFoundHandler: express.RequestHandler = (req, res) => {
44+ console.log(req.url)
55+ res.status(404).send('wut')
66+}
-66
src/server/routes-socket/handler-preauth.js
···11-import { combineSignals, timeoutSignal } from '#common/async/aborts.js'
22-import { jwkImport } from '#common/crypto/jwks.js'
33-import { jwtPayload, verifyJwtToken } from '#common/crypto/jwts.js'
44-import { normalizeError, ProtocolError } from '#common/errors.js'
55-import { IdentBrand, preauthMessageSchema, RealmBrand } from '#common/protocol.js'
66-import { takeSocket } from '#common/socket.js'
77-88-import * as protocol_types from '#common/protocol.js'
99-import * as realms from './state.js'
1010-1111-/**
1212- * immediately after the socket connects, we up to 3 seconds for a valid authentication message.
1313- * - if the realm does not exist (by realm id), we create a new one, and add the identity (success).
1414- * - if the realm /does/ exist, we verify the message is a signed JWT our already registered pubkey.
1515- *
1616- * @param {WebSocket} ws the websocket we're controlling
1717- * @param {AbortSignal} [signal] a signal to abort blocking on the socket
1818- * @returns {Promise<realms.AuthenticatedConnection>} the now authenticated identity
1919- */
2020-export async function preauthHandler(ws, signal) {
2121- const timeout = timeoutSignal(3000)
2222- const combinedSignal = combineSignals(signal, timeout.signal)
2323-2424- try {
2525- const data = await takeSocket(ws, combinedSignal)
2626-2727- // if any of the parsing fails, it'll throw a zod error
2828- const jwt = await jwtPayload(preauthMessageSchema).parseAsync(data)
2929- const identid = IdentBrand.parse(jwt.claims.iss)
3030- const realmid = RealmBrand.parse(jwt.claims.aud)
3131-3232- // if we're registering, make sure the realm exists
3333- if (jwt.payload.msg === 'preauth.register') {
3434- const registrantkey = await jwkImport.parseAsync(jwt.payload.pubkey)
3535- realms.ensureRegisteredRealm(realmid, identid, registrantkey)
3636- }
3737-3838- return authenticatePreauth(realmid, identid, jwt.token)
3939- }
4040- finally {
4141- timeout.cancel()
4242- }
4343-}
4444-4545-/**
4646- * @param {protocol_types.RealmID} realmid the realm id to lookup
4747- * @param {protocol_types.IdentID} identid the identity id to authenticate against
4848- * @param {string} token the (still encoded) JWT to verify
4949- * @returns {Promise<realms.AuthenticatedConnection>} an authenticated connection from this token
5050- * @throws {ProtocolError} when the token isn't validly signed or the identity is unrecognized
5151- */
5252-async function authenticatePreauth(realmid, identid, token) {
5353- try {
5454- const realm = realms.realmMap.require(realmid)
5555- const pubkey = realm.identities.require(identid)
5656-5757- // at this point we no langer care about the payload
5858- // but this throws as a side-effect if the token is invalid
5959- await verifyJwtToken(token, pubkey)
6060- return { realmid, realm, identid, pubkey }
6161- }
6262- catch (exc) {
6363- const err = normalizeError(exc)
6464- throw new ProtocolError('jwt verification failed', 401, { cause: err })
6565- }
6666-}
+54
src/server/routes-socket/handler-preauth.ts
···11+import { combineSignals, timeoutSignal } from '#common/async/aborts'
22+import { jwkImport } from '#common/crypto/jwks'
33+import { jwtPayload, verifyJwtToken } from '#common/crypto/jwts'
44+import { normalizeError, ProtocolError } from '#common/errors'
55+import { IdentBrand, IdentID, preauthMessageSchema, RealmBrand, RealmID } from '#common/protocol'
66+import { takeSocket } from '#common/socket'
77+88+import * as realms from './state'
99+1010+/**
1111+ * immediately after the socket connects, we up to 3 seconds for a valid authentication message.
1212+ * - if the realm does not exist (by realm id), we create a new one, and add the identity (success).
1313+ * - if the realm /does/ exist, we verify the message is a signed JWT our already registered pubkey.
1414+ */
1515+export async function preauthHandler(ws: WebSocket, signal?: AbortSignal): Promise<realms.AuthenticatedIdentity> {
1616+ const timeout = timeoutSignal(3000)
1717+ const combinedSignal = combineSignals(signal, timeout.signal)
1818+1919+ try {
2020+ const data = await takeSocket(ws, combinedSignal)
2121+2222+ // if any of the parsing fails, it'll throw a zod error
2323+ const jwt = await jwtPayload(preauthMessageSchema).parseAsync(data)
2424+ const identid = IdentBrand.parse(jwt.claims.iss)
2525+ const realmid = RealmBrand.parse(jwt.claims.aud)
2626+2727+ // if we're registering, make sure the realm exists
2828+ if (jwt.payload.msg === 'preauth.register') {
2929+ const registrantkey = await jwkImport.parseAsync(jwt.payload.pubkey)
3030+ realms.ensureRegisteredRealm(realmid, identid, registrantkey)
3131+ }
3232+3333+ return authenticatePreauth(realmid, identid, jwt.token)
3434+ }
3535+ finally {
3636+ timeout.cancel()
3737+ }
3838+}
3939+4040+async function authenticatePreauth(realmid: RealmID, identid: IdentID, token: string): Promise<realms.AuthenticatedIdentity> {
4141+ try {
4242+ const realm = realms.realmMap.require(realmid)
4343+ const pubkey = realm.identities.require(identid)
4444+4545+ // at this point we no langer care about the payload
4646+ // but this throws as a side-effect if the token is invalid
4747+ await verifyJwtToken(token, pubkey)
4848+ return { realmid, realm, identid, pubkey }
4949+ }
5050+ catch (exc) {
5151+ const err = normalizeError(exc)
5252+ throw new ProtocolError('jwt verification failed', 401, { cause: err })
5353+ }
5454+}
···11import { format } from 'node:util'
2233-import { normalizeError, normalizeProtocolError } from '#common/errors.js'
33+import { normalizeError, normalizeProtocolError } from '#common/errors'
4455-import { preauthHandler } from './handler-preauth.js'
66-import { realmHandler } from './handler-realm.js'
77-import { attachSocket, detachSocket } from './state.js'
55+import { preauthHandler } from './handler-preauth'
66+import { realmHandler } from './handler-realm'
77+import { attachSocket, detachSocket } from './state'
8899-/**
1010- * when the socket connects, we drive our protocol through handlers.
1111- *
1212- * @param {WebSocket} ws the websocket to control
1313- */
1414-export async function socketHandler(ws) {
99+/** when the socket connects, we drive our protocol through handlers. */
1010+export async function socketHandler(ws: WebSocket) {
1511 console.log('WebSocket connection established')
16121713 try {
-67
src/server/routes-socket/state.js
···11-import { StrictMap } from '#common/strict-map.js'
22-import * as protocol_types from '#common/protocol.js'
33-44-/**
55- * An authenticated identity; only handed out in response to successful authentication.
66- *
77- * @typedef {object} AuthenticatedConnection
88- * @property {protocol_types.RealmID} realmid the realm this connection is associated with
99- * @property {protocol_types.IdentID} identid the identity this connection is associated with
1010- * @property {Realm} realm the actual realm from the realm map
1111- * @property {CryptoKey} pubkey the connected identity's public key
1212- */
1313-1414-/**
1515- * @typedef {object} Realm
1616- * @property {protocol_types.RealmID} realmid the realm's id
1717- * @property {StrictMap<protocol_types.IdentID, Array<WebSocket>>} sockets
1818- * a map of identity id to open sockets.
1919- * @property {StrictMap<protocol_types.IdentID, CryptoKey>} identities
2020- * a map of identity id to the realm's known public key for that identity.
2121- */
2222-2323-/** @type {StrictMap<protocol_types.RealmID, Realm>} */
2424-export const realmMap = new StrictMap()
2525-2626-/**
2727- * gets or creates a registered realm, with the given identity and key
2828- * as initial registrants in a newly created realm. If the realm already
2929- * exists, it's not changed.
3030- *
3131- * @param {protocol_types.RealmID} realmid the realm id to ensure exists
3232- * @param {protocol_types.IdentID} registrantid the identity id of the registrant
3333- * @param {CryptoKey} registrantkey the public key of the registrant
3434- * @returns {Realm} a registered realm, possibly newly created with the registrant
3535- */
3636-export function ensureRegisteredRealm(realmid, registrantid, registrantkey) {
3737- const realm = realmMap.ensure(realmid, () => ({
3838- realmid,
3939- sockets: new StrictMap(),
4040- identities: new StrictMap([[registrantid, registrantkey]]),
4141- }))
4242-4343- // hack for now, allow any registration to work
4444- realm.identities.ensure(registrantid, () => registrantkey)
4545- return realm
4646-}
4747-4848-/**
4949- * @param {Realm} realm the realm on which to attach the socket
5050- * @param {protocol_types.IdentID} ident the identity which owns the socket
5151- * @param {WebSocket} socket the socket to attach
5252- */
5353-export function attachSocket(realm, ident, socket) {
5454- realm.sockets.update(ident, ss => (ss ? [...ss, socket] : [socket]))
5555-}
5656-5757-/**
5858- * @param {Realm} realm the realm from which to detach the socket
5959- * @param {protocol_types.IdentID} ident the identity which owns the socket
6060- * @param {WebSocket} socket the socket to dettach
6161- */
6262-export function detachSocket(realm, ident, socket) {
6363- realm.sockets.update(ident, (sockets) => {
6464- const next = sockets?.filter(s => s !== socket)
6565- return next?.length ? next : undefined
6666- })
6767-}
+51
src/server/routes-socket/state.ts
···11+import { IdentID, RealmID } from '#common/protocol.js'
22+import { StrictMap } from '#common/strict-map'
33+44+/** An authenticated identity; only handed out in response to successful authentication. */
55+export interface AuthenticatedIdentity {
66+ realm: Realm
77+ realmid: RealmID
88+ identid: IdentID
99+ pubkey: CryptoKey
1010+}
1111+1212+export interface Realm {
1313+ realmid: RealmID
1414+ sockets: StrictMap<IdentID, WebSocket[]>
1515+ identities: StrictMap<IdentID, CryptoKey>
1616+}
1717+1818+export const realmMap = new StrictMap<RealmID, Realm>()
1919+2020+/**
2121+ * gets or creates a registered realm, with the given identity and key
2222+ * as initial registrants in a newly created realm. If the realm already
2323+ * exists, it's not changed.
2424+ *
2525+ * @param realmid the realm id to ensure exists
2626+ * @param registrantid the identity id of the registrant
2727+ * @param registrantkey the public key of the registrant
2828+ * @returns a registered realm, possibly newly created with the registrant
2929+ */
3030+export function ensureRegisteredRealm(realmid: RealmID, registrantid: IdentID, registrantkey: CryptoKey): Realm {
3131+ const realm = realmMap.ensure(realmid, () => ({
3232+ realmid,
3333+ sockets: new StrictMap(),
3434+ identities: new StrictMap([[registrantid, registrantkey]]),
3535+ }))
3636+3737+ // hack for now, allow any registration to work
3838+ realm.identities.ensure(registrantid, () => registrantkey)
3939+ return realm
4040+}
4141+4242+export function attachSocket(realm: Realm, ident: IdentID, socket: WebSocket) {
4343+ realm.sockets.update(ident, ss => (ss ? [...ss, socket] : [socket]))
4444+}
4545+4646+export function detachSocket(realm: Realm, ident: IdentID, socket: WebSocket) {
4747+ realm.sockets.update(ident, (sockets) => {
4848+ const next = sockets?.filter(s => s !== socket)
4949+ return next?.length ? next : undefined
5050+ })
5151+}
-35
src/server/routes-static.js
···11-import * as express_types from 'express'
22-import express from 'express'
33-import { join } from 'path'
44-55-/**
66- * @typedef {object} StaticOpts
77- * @property {string} root the root directory to staticly server
88- * @property {string} index the index file to use if given a directory or spa path
99- */
1010-1111-/**
1212- * returns a configured static middleware
1313- *
1414- * @param {StaticOpts} opts options for corfiguring the middleware
1515- * @returns {express_types.RequestHandler} a new middleware
1616- */
1717-export function makeStaticMiddleware(opts) {
1818- return express.static(opts.root, { index: opts.index })
1919-}
2020-2121-/**
2222- * returns the index file for any GET request for text/html it matches
2323- *
2424- * @param {StaticOpts} opts options for configuring the middleware
2525- * @returns {express_types.RequestHandler} a new middleware
2626- */
2727-export function makeSpaMiddleware(opts) {
2828- return (req, res, next) => {
2929- if (req.method === 'GET' && req.accepts('text/html')) {
3030- return res.sendFile(join(opts.root, opts.index))
3131- }
3232-3333- next() // otherwise
3434- }
3535-}
+33
src/server/routes-static.ts
···11+import express from 'express'
22+import { join } from 'path'
33+44+interface StaticOpts {
55+ root: string
66+ index: string
77+}
88+99+/**
1010+ * returns a configured static middleware
1111+ *
1212+ * @param opts options for corfiguring the middleware
1313+ * @returns a new middleware
1414+ */
1515+export function makeStaticMiddleware(opts: StaticOpts): express.RequestHandler {
1616+ return express.static(opts.root, { index: opts.index })
1717+}
1818+1919+/**
2020+ * returns the index file for any GET request for text/html it matches
2121+ *
2222+ * @param opts options for configuring the middleware
2323+ * @returns a new middleware
2424+ */
2525+export function makeSpaMiddleware(opts: StaticOpts): express.RequestHandler {
2626+ return (req, res, next) => {
2727+ if (req.method === 'GET' && req.accepts('text/html')) {
2828+ return res.sendFile(join(opts.root, opts.index))
2929+ }
3030+3131+ next() // otherwise
3232+ }
3333+}