@robota-sdk/agent-transport 3.0.0-beta.82 → 3.0.0-beta.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +752 -0
- package/dist/browser/client.d.ts +2 -2
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +31 -11
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/observer-messages-Bx-xReUQ.js +2 -0
- package/dist/browser/observer-messages-Bx-xReUQ.js.map +1 -0
- package/dist/browser/{message-decoders-BB8n-H2X.d.ts → observer-messages-DC_8VHk9.d.ts} +104 -7
- package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +131 -14
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +131 -14
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.cjs +1 -1
- package/dist/node/node.d.cts +69 -1
- package/dist/node/node.d.cts.map +1 -1
- package/dist/node/node.d.ts +70 -2
- package/dist/node/node.d.ts.map +1 -1
- package/dist/node/node.js +1 -1
- package/dist/node/node.js.map +1 -1
- package/package.json +13 -11
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +201 -5
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +192 -0
- package/src/__tests__/session-history-wire.test.ts +342 -0
- package/src/__tests__/session-message-handler-observe.test.ts +193 -0
- package/src/__tests__/session-message-handler.test.ts +82 -1
- package/src/__tests__/ws-broadcast-events.test.ts +10 -0
- package/src/client.ts +9 -1
- package/src/index.ts +14 -2
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +82 -4
- package/src/node/bearer-resource-gate.ts +291 -0
- package/src/node/index.ts +13 -0
- package/src/observer-messages.ts +36 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +11 -2
- package/src/protocol-session.ts +7 -1
- package/src/session-directory-messages.ts +85 -0
- package/src/session-events.ts +93 -18
- package/src/session-message-handler.ts +53 -8
- package/src/session-query-messages.ts +105 -4
- package/src/wire-messages.ts +105 -7
- package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
- package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
- package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
package/dist/node/node.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node.js","names":[],"sources":["../../src/node/admission.ts","../../src/node/handoff-manifest.ts","../../src/node/access-token-verifier.ts","../../src/node/file-transfer.ts"],"sourcesContent":["/**\n * SEC-008 — the machinery behind the admission decision.\n *\n * The SHAPE of the decision is a contract and lives in `@robota-sdk/agent-interface-transport`; an\n * interface package is inert by rule, and minting and comparing a credential need `node:crypto`. So\n * the types are declared there and produced here, in the package every transport already shares.\n *\n * Splitting them is not bookkeeping. Putting the crypto in the contract package would have given\n * every consumer of those types a runtime dependency edge on a Node builtin — the same defect\n * CORE-028 removed from another package, reintroduced one package over.\n */\n\nimport { randomBytes, timingSafeEqual } from 'node:crypto';\n\nimport type {\n ITransportAdmission,\n ITransportAdmissionConfig,\n} from '@robota-sdk/agent-interface-transport';\n\n/** Bytes of entropy per minted token. 32 bytes = 256 bits, hex-encoded to 64 characters. */\nconst TOKEN_BYTES = 32;\n\n/**\n * Mint a per-launch credential.\n *\n * Throws rather than returning a short or empty string if entropy is unavailable, so a transport\n * built on it fails to construct instead of binding with a guessable credential.\n */\nexport function mintTransportToken(): string {\n const token = randomBytes(TOKEN_BYTES).toString('hex');\n if (token.length !== TOKEN_BYTES * 2) {\n throw new Error('transport admission: could not mint a credential of the required length');\n }\n return token;\n}\n\n/**\n * Resolve the admission decision for one transport.\n *\n * IDEMPOTENT: it accepts an already-resolved `ITransportAdmission` and returns it unchanged, so\n * `resolve(resolve(x))` is `resolve(x)`. That is not a convenience — it is what lets a caller\n * holding either shape just call this, instead of deciding which shape it holds first.\n *\n * The HTTP route used to make that decision with `'token' in options.admission`, and review showed\n * it cannot work: BOTH interfaces declare a `token`, so a config is only distinguishable from a\n * resolution by the VALUE. `{ token: '' }` — documented as \"mint a fresh one\" — was read as\n * pre-resolved and installed the empty string as the required credential, which a peer sending an\n * empty bearer would then match. Making this function idempotent deletes that discriminator\n * instead of repairing it.\n *\n * @throws if `open` is set without a reason — an unexplained open transport is the state this\n * module exists to remove, so it is refused at construction rather than recorded and shipped.\n * @throws if a token and `open: true` are asked for together. The two are contradictory, and the\n * silent precedence this used to give (token wins) is the safe DIRECTION but the wrong ANSWER: a\n * caller that wrote both does not know what it is asking for, and one of the two things it believes\n * is false. `WebRtcTransport` already refused the analogous `secret` + `open` pair in its own\n * constructor; a seam whose whole claim is \"one place to read and one place to change\" cannot leave\n * that protection to whichever transport happens to have implemented it.\n */\nexport function resolveAdmission(\n config: ITransportAdmissionConfig | ITransportAdmission = {},\n): ITransportAdmission {\n const requestedOpen = 'open' in config && config.open === true;\n const hasToken = config.token !== undefined && config.token !== null && config.token !== '';\n\n if (hasToken && requestedOpen) {\n throw new Error(\n 'transport admission: a token and `open: true` are contradictory — one requires a credential ' +\n 'and the other requires none. Asking for both means one of the two beliefs behind the call ' +\n 'is wrong; say which is intended.',\n );\n }\n\n if (hasToken) {\n return { token: config.token as string };\n }\n\n // `token: null` is the RESOLVED open state, not an absent token — the one value that tells an\n // already-resolved admission apart from a config. It still has to carry its reason: an\n // admission reaching this function without one was either built by hand or corrupted, and both\n // are the unexplained-open state the reason exists to prevent.\n if (config.token === null || requestedOpen) {\n const reason = config.openReason?.trim();\n if (!reason) {\n throw new Error(\n 'transport admission: an open transport requires `openReason`. An open transport is a ' +\n 'decision, and a decision nobody wrote down is indistinguishable from an oversight.',\n );\n }\n return { token: null, openReason: reason };\n }\n\n return { token: mintTransportToken() };\n}\n\n/**\n * Whether a presented credential matches the required one.\n *\n * Compared with `timingSafeEqual` over the raw bytes, so the answer takes the same time whatever the\n * first differing character is. The length check before it is unavoidable — `timingSafeEqual` throws\n * on unequal lengths — and it does leak one bit: whether the presented credential is the right\n * LENGTH.\n *\n * For a minted token that costs nothing: the length is fixed at 64 hex characters and published in\n * this file. A host that supplies its own token of some other length leaks that length instead, and\n * review was right to point out that the earlier wording (\"fixed and public\") quietly assumed the\n * minted case for every case. Length is not the secret in either — but the claim now says which\n * situation it is describing.\n *\n * A missing credential is a mismatch, not a special case: a peer that sends nothing and a peer that\n * sends the wrong thing are equally unadmitted, and giving them different answers would tell an\n * attacker which half they got right.\n */\nexport function credentialMatches(expected: string, presented: string | undefined): boolean {\n if (presented === undefined) return false;\n const a = Buffer.from(expected, 'utf8');\n const b = Buffer.from(presented, 'utf8');\n if (a.length !== b.length) return false;\n return timingSafeEqual(a, b);\n}\n\n/**\n * Extract a bearer credential from an `Authorization` header value.\n *\n * Returns `undefined` for anything that is not a bearer token, including a header that merely\n * starts with the right word — a peer sending `Bearerness xyz` is sending something else.\n *\n * The SCHEME is matched case-insensitively, per RFC 7235: `auth-scheme` is a token and tokens are\n * compared without regard to case, so `bearer …` and `BEARER …` are the same request. Review found\n * the exact-case match; it failed safe, refusing a conformant client with a 401 it could not act on,\n * which is the kind of refusal that gets read as a server bug and worked around rather than fixed.\n * The credential itself is untouched — it is not a token in that sense and its case is significant.\n *\n * Separator: one or more spaces or tabs, not exactly one space. That is the same clause's `BWS`.\n *\n * PARSED, not matched. `/^Bearer[ \\t]+(.+)$/i` said the same thing and CodeQL was right about it: two\n * adjacent variable-length runs over attacker-controlled input is a polynomial backtrack, and this\n * input arrives on an unauthenticated request — the one place a slow path is worth reaching for.\n * Reading the prefix, then trimming, has no backtracking to exploit and says the same thing more\n * plainly.\n */\nconst BEARER_PREFIX = 'bearer';\n\nexport function bearerCredential(header: string | undefined | null): string | undefined {\n if (!header) return undefined;\n if (header.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX) return undefined;\n const rest = header.slice(BEARER_PREFIX.length);\n // A separator is REQUIRED: `Bearerness xyz` starts with the word and is a different scheme, so\n // `rest` must begin with the whitespace that ends the scheme token.\n if (rest === '' || (rest[0] !== ' ' && rest[0] !== '\\t')) return undefined;\n const credential = rest.replace(/^[ \\t]+/, '');\n return credential === '' ? undefined : credential;\n}\n","/**\n * HANDOFF-001 (#1811): what actually gets built and sent, and how the destination knows it arrived\n * whole.\n *\n * Session mobility decides whether a source can offer a session and what its inventory says.\n * This Node-only module seals and verifies the exact payload bytes.\n *\n * ## Integrity is checked in a specific order\n *\n * Length first, then digest. A truncated transfer is the common failure and the cheap one to\n * detect; hashing a short buffer to discover it was short wastes the work and, worse, reports it as\n * a digest mismatch — which reads as corruption or tampering rather than a dropped connection.\n */\n\nimport { createHash } from 'node:crypto';\n\nimport type { IInteractiveSessionRecord } from '@robota-sdk/agent-interface-session';\nimport type { IHandoffIntegrity } from '@robota-sdk/agent-interface-session-mobility';\n\n/**\n * Serialize a record and describe what was serialized.\n *\n * The string is returned alongside the integrity data because the digest is only meaningful for\n * these exact bytes. A caller that re-serialized the record before sending could produce a\n * different key order and a digest mismatch on a payload that is perfectly correct — a corruption\n * report caused by the corruption check.\n */\nexport function sealHandoffRecord(record: IInteractiveSessionRecord): {\n serialized: string;\n integrity: IHandoffIntegrity;\n} {\n const serialized = JSON.stringify(record);\n const bytes = Buffer.from(serialized, 'utf8');\n return {\n serialized,\n integrity: {\n digest: createHash('sha256').update(bytes).digest('base64url'),\n byteLength: bytes.byteLength,\n },\n };\n}\n\n/** Why a received payload was not accepted. */\nexport type TIntegrityFailure = 'truncated' | 'digest-mismatch';\n\nexport interface IIntegrityVerdict {\n readonly intact: boolean;\n readonly failure?: TIntegrityFailure;\n /** What the manifest promised and what arrived, so a report can say more than \"it failed\". */\n readonly expectedBytes?: number;\n readonly actualBytes?: number;\n}\n\n/**\n * Check a received payload against the manifest's integrity metadata.\n *\n * Nothing is parsed here. Parsing a payload to check it is well-formed would mean building the\n * object graph before knowing whether the bytes are the ones that were sent, and a destination that\n * has already parsed a corrupt payload has already spent the effort this check exists to avoid.\n */\nexport function verifyHandoffPayload(\n serialized: string,\n integrity: IHandoffIntegrity,\n): IIntegrityVerdict {\n const bytes = Buffer.from(serialized, 'utf8');\n if (bytes.byteLength !== integrity.byteLength) {\n return {\n intact: false,\n failure: 'truncated',\n expectedBytes: integrity.byteLength,\n actualBytes: bytes.byteLength,\n };\n }\n const digest = createHash('sha256').update(bytes).digest('base64url');\n if (digest !== integrity.digest) return { intact: false, failure: 'digest-mismatch' };\n return { intact: true };\n}\n","/**\n * The access-token verifier behind `IAccessTokenVerifier`.\n *\n * The cryptography is `jose`'s, not ours: signature verification, claim validation and key import\n * are the parts most often got subtly wrong by hand. What this module adds is the policy around\n * them — which tokens are even offered to `jose`, which key a token may be checked against, and\n * where that key may be fetched from.\n *\n * Keys are found through the issuer's own metadata (RFC 8414, then OpenID discovery) and fetched\n * through the shared egress boundary with private-address reach granted to the issuer's host only,\n * so an internal authorization server works without opening the resource server's egress to every\n * internal address. Every fetch is `https`, byte-bounded, and refuses redirects: a key set is the\n * root of trust for every admission after it, and a redirect is a second origin nobody configured.\n *\n * The key set is cached, and refetched — metadata included — when a token names a `kid` the cache\n * does not hold or when the cache has outlived its maximum age, at most once per interval, so a\n * stream of tokens with invented `kid`s cannot turn this into a request amplifier against the\n * issuer. The age bound is what makes a key the issuer withdrew (rotation after a compromise) stop\n * admitting without anyone presenting a new `kid`. An outage is tolerated only while the cached keys\n * are younger than a hard limit; past it they are dropped and every token is refused, because an\n * outage is not a reason to keep trusting keys nobody has confirmed for an hour.\n */\n\nimport { decodeProtectedHeader, importJWK, jwtVerify } from 'jose';\n\nimport { fetchWithEgressPolicy } from '@robota-sdk/agent-core/node';\nimport type { IEgressDeps } from '@robota-sdk/agent-core/node';\nimport type {\n IAccessTokenVerifier,\n IAccessTokenVerifierConfig,\n TAccessTokenAdmission,\n TAccessTokenAlgorithm,\n TAccessTokenRefusal,\n} from '@robota-sdk/agent-interface-transport';\nimport type { JWK, JWTPayload } from 'jose';\n\n/** Longest token offered to the parser. Real access tokens are a few hundred bytes to a few KiB. */\nconst MAX_TOKEN_LENGTH = 8192;\n/** Byte cap on a metadata document or key set. */\nconst MAX_DOCUMENT_BYTES = 64 * 1024;\n/** Keys accepted from one key set. */\nconst MAX_KEYS = 32;\n/** Deadline for one metadata or key-set exchange. */\nconst FETCH_TIMEOUT_MS = 10_000;\n/** Minimum gap between two key-set fetches, whatever triggered them. */\nconst MIN_REFETCH_INTERVAL_MS = 30_000;\n/** Age after which the key set and metadata are refetched before the next verification. */\nconst MAX_KEY_AGE_MS = 10 * 60_000;\n/** Age past which cached keys are dropped if they could not be refreshed. */\nconst HARD_KEY_AGE_MS = 60 * 60_000;\n/** Clock skew tolerated on `exp` and `nbf`, in seconds. */\nconst CLOCK_SKEW_SECONDS = 60;\nconst ACCESS_TOKEN_TYP = 'at+jwt';\n\nconst SUPPORTED_ALGORITHMS: readonly TAccessTokenAlgorithm[] = ['RS256', 'ES256', 'EdDSA'];\n\n/** The key shape each algorithm requires. A key that disagrees is never offered to `jose`. */\nconst KEY_SHAPE: Readonly<Record<TAccessTokenAlgorithm, { kty: string; crv?: string }>> = {\n RS256: { kty: 'RSA' },\n ES256: { kty: 'EC', crv: 'P-256' },\n EdDSA: { kty: 'OKP', crv: 'Ed25519' },\n};\n\n/** What the verifier reaches outward through. Injected in tests; defaults to the real network. */\nexport interface IAccessTokenVerifierDeps {\n readonly fetch?: IEgressDeps['fetch'];\n readonly lookup?: IEgressDeps['lookup'];\n /** Wall-clock milliseconds since the epoch, for `exp`/`nbf`. Defaults to `Date.now`. */\n readonly now?: () => number;\n /** Monotonic milliseconds, for cache age and refetch intervals. Defaults to `performance.now`. */\n readonly monotonicNow?: () => number;\n}\n\ninterface IKeyCache {\n keys: readonly JWK[] | undefined;\n jwksUri: string | undefined;\n /** Monotonic time of the last successful key-set fetch. */\n fetchedAt: number | undefined;\n /** Monotonic time of the last attempt, successful or not; the rate bound runs from here. */\n lastAttemptAt: number | undefined;\n inflight: Promise<void> | undefined;\n}\n\nclass Refused {\n constructor(readonly refusal: TAccessTokenRefusal) {}\n}\n\n/**\n * Build a verifier for one issuer and one resource.\n *\n * @throws when the configuration cannot describe a safe verifier: a non-`https` issuer, no\n * algorithm or an unsupported one, no required scope, or no allowlisted subject or client. The\n * last is the single-tenant rule: without it any holder of a token from the issuer is admitted.\n */\nexport function createAccessTokenVerifier(\n config: IAccessTokenVerifierConfig,\n deps: IAccessTokenVerifierDeps = {},\n): IAccessTokenVerifier {\n const issuerUrl = validateConfig(config);\n const now = deps.now ?? Date.now;\n const monotonicNow = deps.monotonicNow ?? (() => performance.now());\n const egressDeps: IEgressDeps = { fetch: deps.fetch, lookup: deps.lookup };\n const issuerHost = issuerUrl.hostname.replace(/^\\[|\\]$/g, '').toLowerCase();\n const cache: IKeyCache = {\n keys: undefined,\n jwksUri: undefined,\n fetchedAt: undefined,\n lastAttemptAt: undefined,\n inflight: undefined,\n };\n const allowedSubjects = new Set(config.allowedSubjects ?? []);\n const allowedClients = new Set(config.allowedClients ?? []);\n\n async function fetchJson(url: string): Promise<unknown> {\n if (new URL(url).protocol !== 'https:') return undefined;\n const result = await fetchWithEgressPolicy(\n url,\n {\n headers: { accept: 'application/json' },\n timeoutMs: FETCH_TIMEOUT_MS,\n maxResponseBytes: MAX_DOCUMENT_BYTES,\n },\n { allowedHosts: [issuerHost], maxRedirects: 0 },\n egressDeps,\n );\n if (!result.ok || result.status !== 200) return undefined;\n return JSON.parse(new TextDecoder().decode(result.body)) as unknown;\n }\n\n async function discoverJwksUri(): Promise<string | undefined> {\n for (const url of metadataUrls(issuerUrl)) {\n const metadata = await fetchJson(url).catch(() => undefined);\n if (!isRecord(metadata)) continue;\n // RFC 8414 §3.3: metadata naming another issuer must not be used — and a server that\n // answered with the wrong issuer is not one to ask a second question of.\n if (metadata.issuer !== config.issuer) return undefined;\n const jwksUri = metadata.jwks_uri;\n if (typeof jwksUri !== 'string' || !URL.canParse(jwksUri)) return undefined;\n return new URL(jwksUri).protocol === 'https:' ? jwksUri : undefined;\n }\n return undefined;\n }\n\n function keyAge(): number {\n return cache.fetchedAt === undefined ? Infinity : monotonicNow() - cache.fetchedAt;\n }\n\n async function fetchKeys(): Promise<void> {\n cache.lastAttemptAt = monotonicNow();\n try {\n // Metadata is rediscovered with the key set once the cache is old: a `jwks_uri` is part of\n // what the issuer can rotate.\n if (keyAge() >= MAX_KEY_AGE_MS) cache.jwksUri = undefined;\n cache.jwksUri ??= await discoverJwksUri();\n if (cache.jwksUri === undefined) return;\n const keys = parseKeySet(await fetchJson(cache.jwksUri));\n if (keys === undefined) return;\n cache.keys = keys;\n cache.fetchedAt = monotonicNow();\n } catch {\n // allow-fallback: a failed fetch leaves the cache as it was, and `currentKeys` drops it once\n // it passes the hard age limit — the fail-closed direction.\n }\n }\n\n /** Refetch unless one was attempted within the interval. Concurrent callers share one fetch. */\n async function refreshKeys(): Promise<void> {\n if (cache.inflight !== undefined) return cache.inflight;\n if (\n cache.lastAttemptAt !== undefined &&\n monotonicNow() - cache.lastAttemptAt < MIN_REFETCH_INTERVAL_MS\n ) {\n return;\n }\n cache.inflight = fetchKeys().finally(() => {\n cache.inflight = undefined;\n });\n return cache.inflight;\n }\n\n /** The cached keys, refreshed first when old; `undefined` when none may be trusted. */\n async function currentKeys(): Promise<readonly JWK[] | undefined> {\n if (keyAge() >= MAX_KEY_AGE_MS) await refreshKeys();\n if (keyAge() >= HARD_KEY_AGE_MS) {\n cache.keys = undefined;\n cache.jwksUri = undefined;\n cache.fetchedAt = undefined;\n }\n return cache.keys;\n }\n\n async function selectKey(kid: string | undefined, alg: TAccessTokenAlgorithm): Promise<JWK> {\n let keys = await currentKeys();\n if (keys === undefined) throw new Refused('keys-unavailable');\n if (kid === undefined) {\n if (keys.length > 1) throw new Refused('ambiguous-key');\n const only = keys[0];\n if (only === undefined) throw new Refused('unknown-key');\n if (!keyAgrees(only, alg)) throw new Refused('key-mismatch');\n return only;\n }\n let named = keys.filter((key) => key.kid === kid);\n if (named.length === 0) {\n await refreshKeys();\n keys = cache.keys ?? [];\n named = keys.filter((key) => key.kid === kid);\n }\n if (named.length === 0) throw new Refused('unknown-key');\n // Two keys may share a `kid` across algorithms; the one checked is the one that fits the token.\n const fitting = named.find((key) => keyAgrees(key, alg));\n if (fitting === undefined) throw new Refused('key-mismatch');\n return fitting;\n }\n\n async function check(token: string): Promise<TAccessTokenAdmission> {\n if (typeof token !== 'string' || token.length === 0) throw new Refused('malformed');\n if (token.length > MAX_TOKEN_LENGTH) throw new Refused('oversize');\n\n let header: ReturnType<typeof decodeProtectedHeader>;\n try {\n header = decodeProtectedHeader(token);\n } catch {\n throw new Refused('malformed');\n }\n if (typeof header.typ !== 'string' || normalizeTyp(header.typ) !== ACCESS_TOKEN_TYP) {\n throw new Refused('wrong-type');\n }\n const alg = config.algorithms.find((allowed) => allowed === header.alg);\n if (alg === undefined) throw new Refused('unsupported-algorithm');\n if (header.kid !== undefined && typeof header.kid !== 'string') throw new Refused('malformed');\n\n const jwk = await selectKey(header.kid, alg);\n let key: Awaited<ReturnType<typeof importJWK>>;\n try {\n key = await importJWK(jwk, alg);\n } catch {\n throw new Refused('key-mismatch');\n }\n\n let payload: JWTPayload;\n try {\n ({ payload } = await jwtVerify(token, key, {\n issuer: config.issuer,\n audience: config.resource,\n algorithms: [alg],\n typ: ACCESS_TOKEN_TYP,\n clockTolerance: CLOCK_SKEW_SECONDS,\n currentDate: new Date(now()),\n requiredClaims: ['exp', 'sub', 'client_id'],\n }));\n } catch (error) {\n throw new Refused(refusalFor(error));\n }\n\n const granted = typeof payload.scope === 'string' ? payload.scope.split(' ') : [];\n if (!config.requiredScopes.every((scope) => granted.includes(scope))) {\n throw new Refused('missing-scope');\n }\n const subject = payload.sub;\n const client = payload.client_id;\n const allowed =\n (typeof subject === 'string' && allowedSubjects.has(subject)) ||\n (typeof client === 'string' && allowedClients.has(client));\n if (!allowed) throw new Refused('principal-not-allowed');\n return { admitted: true };\n }\n\n return {\n async verify(token: string): Promise<TAccessTokenAdmission> {\n try {\n return await check(token);\n } catch (error) {\n // Only the reason word leaves: never the token, a claim, or an underlying error message.\n return { admitted: false, refusal: error instanceof Refused ? error.refusal : 'malformed' };\n }\n },\n };\n}\n\nfunction validateConfig(config: IAccessTokenVerifierConfig): URL {\n if (!URL.canParse(config.issuer)) throw new Error('access-token verifier: issuer is not a URL');\n const issuerUrl = new URL(config.issuer);\n if (issuerUrl.protocol !== 'https:' || issuerUrl.search !== '' || issuerUrl.hash !== '') {\n throw new Error('access-token verifier: issuer must be an https URL with no query or fragment');\n }\n if (!URL.canParse(config.resource)) {\n throw new Error('access-token verifier: resource is not a URL');\n }\n if (\n config.algorithms.length === 0 ||\n !config.algorithms.every((alg) => SUPPORTED_ALGORITHMS.includes(alg))\n ) {\n throw new Error(\n 'access-token verifier: algorithms must be a non-empty subset of RS256, ES256, EdDSA',\n );\n }\n if (config.requiredScopes.length === 0) {\n throw new Error('access-token verifier: at least one required scope is needed');\n }\n const principals = (config.allowedSubjects?.length ?? 0) + (config.allowedClients?.length ?? 0);\n if (principals === 0) {\n throw new Error(\n 'access-token verifier: name at least one allowed subject or client — this host is ' +\n 'single-tenant, and issuer + audience + scope alone admit anyone the issuer serves',\n );\n }\n return issuerUrl;\n}\n\n/** RFC 8414 §3.1 inserts the well-known segment before the issuer's path; OpenID appends it. */\nfunction metadataUrls(issuer: URL): readonly string[] {\n const path = issuer.pathname === '/' ? '' : issuer.pathname.replace(/\\/$/, '');\n return [\n `${issuer.origin}/.well-known/oauth-authorization-server${path}`,\n `${issuer.origin}${path}/.well-known/openid-configuration`,\n ];\n}\n\nfunction parseKeySet(body: unknown): readonly JWK[] | undefined {\n if (!isRecord(body) || !Array.isArray(body.keys) || body.keys.length > MAX_KEYS) return undefined;\n return body.keys.filter((key): key is JWK => isRecord(key) && typeof key.kty === 'string');\n}\n\nfunction keyAgrees(jwk: JWK, alg: TAccessTokenAlgorithm): boolean {\n const shape = KEY_SHAPE[alg];\n if (jwk.kty !== shape.kty) return false;\n if (shape.crv !== undefined && jwk.crv !== shape.crv) return false;\n if (jwk.alg !== undefined && jwk.alg !== alg) return false;\n if (jwk.use !== undefined && jwk.use !== 'sig') return false;\n if (jwk.key_ops !== undefined && !jwk.key_ops.includes('verify')) return false;\n // A public key set never carries private material; one that does is not a key set to trust.\n return jwk.d === undefined;\n}\n\nfunction refusalFor(error: unknown): TAccessTokenRefusal {\n const code = isRecord(error) ? error.code : undefined;\n const claim = isRecord(error) ? error.claim : undefined;\n switch (code) {\n case 'ERR_JWT_EXPIRED':\n return 'expired';\n case 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED':\n return 'bad-signature';\n case 'ERR_JOSE_ALG_NOT_ALLOWED':\n return 'unsupported-algorithm';\n case 'ERR_JWT_CLAIM_VALIDATION_FAILED':\n if (claim === 'iss') return 'wrong-issuer';\n if (claim === 'aud') return 'wrong-audience';\n if (claim === 'nbf') return 'not-yet-valid';\n if (claim === 'typ') return 'wrong-type';\n return 'malformed';\n default:\n return 'malformed';\n }\n}\n\nfunction normalizeTyp(typ: string): string {\n const lower = typ.toLowerCase();\n return lower.startsWith('application/') ? lower.slice('application/'.length) : lower;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n","/**\n * The file carrier: one file over one frame channel, in either direction, whatever the channel runs on.\n *\n * ## Shape\n *\n * The sender offers name, size and hash, and sends nothing more until the receiver accepts; the\n * receiver decides (its operator's yes, somewhere to put the file) before a byte arrives. Content\n * then moves in chunks, and the receiver checks the whole against the offer before it is kept. A\n * mismatch discards it. There is no resume: a transfer that ends early is simply gone.\n *\n * ## Backpressure\n *\n * The receiver grants credit as it persists chunks, and the sender keeps at most a window of chunks\n * unconfirmed. So neither side buffers more than the window, however fast the sender reads or slow\n * the receiver's disk, and the bound holds on every channel without asking it about its buffers. A\n * receiver that is sent past its credit ends the transfer: holding the excess is exactly what the\n * window exists to prevent.\n *\n * ## Why chunks are base64 in JSON text\n *\n * Every channel it runs on carries text frames, and a frame is decoded as hostile input on arrival.\n * One encoding for every frame keeps that decoding in one place; base64 costs a third of bandwidth,\n * which the size limit already bounds.\n */\n\nimport { createHash, type Hash } from 'node:crypto';\n\nimport type { IFileFrameChannel, IFileOffer } from '@robota-sdk/agent-interface-session-mobility';\n\n/** Why a transfer did not end with the file kept. */\nexport type TFileTransferRefusal =\n /** The receiving operator said no, could not be asked, or the connection does not carry files. */\n | 'declined'\n /** The file is over the size limit. */\n | 'too-large'\n /** The name is empty, or would leave the receiving directory. */\n | 'bad-name'\n /** A file of that name was already received; nothing is overwritten. */\n | 'exists'\n /** The receiving directory is not one this user owns outright, e.g. a symbolic link. */\n | 'unsafe-path'\n /** The content did not match the offered size or hash. The partial file is discarded. */\n | 'integrity'\n /** A frame broke the protocol. */\n | 'protocol'\n /** The channel ended first. */\n | 'closed'\n /** The other side went quiet. */\n | 'timeout'\n /** The receiver could not store the file. */\n | 'unavailable';\n\n/**\n * The default size limit. A transfer does not resume, so a dropped connection costs the whole file\n * again; a received file also sits on the receiver's disk until its operator removes it. The limit\n * keeps both costs small while covering what is sent between one's own sessions: sources, logs,\n * screenshots, a small archive.\n */\nexport const DEFAULT_MAX_FILE_BYTES = 32 * 1024 * 1024;\n\n/**\n * Bytes of content per chunk. Small enough that a frame, base64 and envelope included, stays well\n * inside the message size every channel it runs on carries.\n */\nexport const FILE_CHUNK_BYTES = 16 * 1024;\n/** Chunks the sender may have unconfirmed. */\nexport const FILE_CREDIT_WINDOW = 16;\n/** A frame longer than a full chunk and its envelope is not one this protocol sends. */\nconst MAX_FRAME_CHARS = Math.ceil(FILE_CHUNK_BYTES / 3) * 4 + 512;\nconst MAX_NAME_CHARS = 1024;\nconst SHA256_HEX = /^[0-9a-f]{64}$/;\nconst BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;\n/** Between frames once content flows. */\nconst DEFAULT_IDLE_MS = 30_000;\n/** For the receiving operator to answer. */\nconst DEFAULT_DECISION_MS = 5 * 60_000;\n/** How long the receiver waits, after its last frame, for the sender to close. */\nconst LAST_WORD_MS = 5_000;\n\n/** Where content comes from. `read` yields the content once, in order, in pieces of any size. */\nexport interface IFileSource {\n readonly size: number;\n read(): AsyncIterable<Uint8Array>;\n}\n\n/** Where received content goes until it is verified; nothing is kept unless `commit` runs. */\nexport interface IFileSink {\n write(chunk: Uint8Array): Promise<void>;\n /** Keep the verified file. Resolves with where it is. */\n commit(): Promise<string>;\n /** Drop what was written. */\n discard(): Promise<void>;\n}\n\n/** The receiver's decision on an offer: somewhere to put it, or why not. */\nexport type TFileAdmission =\n | { readonly sink: IFileSink }\n | { readonly refused: TFileTransferRefusal; readonly detail?: string };\n\nexport type TFileSendOutcome =\n | { readonly ok: true }\n | { readonly ok: false; readonly reason: TFileTransferRefusal; readonly detail?: string };\n\nexport type TFileReceiveOutcome =\n | { readonly ok: true; readonly offer: IFileOffer; readonly location: string }\n | {\n readonly ok: false;\n readonly reason: TFileTransferRefusal;\n readonly detail?: string;\n readonly offer?: IFileOffer;\n };\n\ntype TFrame =\n | ({ readonly t: 'file-offer' } & IFileOffer)\n | { readonly t: 'file-accept' }\n | { readonly t: 'file-refuse'; readonly reason: TFileTransferRefusal; readonly detail?: string }\n | { readonly t: 'file-chunk'; readonly seq: number; readonly data: string }\n | { readonly t: 'file-credit'; readonly upTo: number }\n | { readonly t: 'file-end'; readonly chunks: number }\n | { readonly t: 'file-done' };\n\nconst REFUSALS: ReadonlySet<string> = new Set<TFileTransferRefusal>([\n 'declined',\n 'too-large',\n 'bad-name',\n 'exists',\n 'unsafe-path',\n 'integrity',\n 'protocol',\n 'closed',\n 'timeout',\n 'unavailable',\n]);\n\n/** Code points that could repaint a terminal, hide text or reorder it. */\nconst UNPRINTABLE_RANGES: readonly (readonly [number, number])[] = [\n [0x00, 0x1f],\n [0x7f, 0x9f],\n [0x200b, 0x200f],\n [0x2028, 0x202e],\n [0x2066, 0x2069],\n [0xfeff, 0xfeff],\n];\n\nfunction printable(text: string): string {\n let out = '';\n for (const char of text) {\n const code = char.codePointAt(0) ?? 0;\n out += UNPRINTABLE_RANGES.some(([low, high]) => code >= low && code <= high) ? ' ' : char;\n }\n return out;\n}\n\nfunction isCount(value: unknown): value is number {\n return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;\n}\n\n/** Decode one frame as hostile input: anything not exactly a frame of this protocol is `undefined`. */\nfunction decodeFrame(text: string): TFrame | undefined {\n if (text.length > MAX_FRAME_CHARS) return undefined;\n let value: unknown;\n try {\n value = JSON.parse(text);\n } catch {\n return undefined;\n }\n if (typeof value !== 'object' || value === null) return undefined;\n const frame = value as Record<string, unknown>;\n switch (frame['t']) {\n case 'file-offer': {\n const { transferId, name, size, sha256 } = frame;\n if (typeof transferId !== 'string' || transferId.length === 0 || transferId.length > 128) {\n return undefined;\n }\n if (typeof name !== 'string' || name.length > MAX_NAME_CHARS) return undefined;\n if (!isCount(size) || typeof sha256 !== 'string' || !SHA256_HEX.test(sha256)) {\n return undefined;\n }\n return { t: 'file-offer', transferId, name, size, sha256 };\n }\n case 'file-accept':\n return { t: 'file-accept' };\n case 'file-refuse': {\n const reason = frame['reason'];\n const detail = frame['detail'];\n if (typeof reason !== 'string' || !REFUSALS.has(reason)) return undefined;\n return {\n t: 'file-refuse',\n reason: reason as TFileTransferRefusal,\n // The peer's words, shown to an operator and handed to a model: printable text only.\n ...(typeof detail === 'string' ? { detail: printable(detail).slice(0, 500) } : {}),\n };\n }\n case 'file-chunk': {\n const { seq, data } = frame;\n // An empty chunk moves nothing and would let a sender hold an approved transfer open forever.\n if (!isCount(seq) || typeof data !== 'string' || data === '' || !BASE64.test(data)) {\n return undefined;\n }\n return { t: 'file-chunk', seq, data };\n }\n case 'file-credit':\n return isCount(frame['upTo']) ? { t: 'file-credit', upTo: frame['upTo'] } : undefined;\n case 'file-end':\n return isCount(frame['chunks']) ? { t: 'file-end', chunks: frame['chunks'] } : undefined;\n case 'file-done':\n return { t: 'file-done' };\n default:\n return undefined;\n }\n}\n\nclass TransferEnded extends Error {\n constructor(\n readonly reason: TFileTransferRefusal,\n readonly detail?: string,\n ) {\n super(detail ?? reason);\n }\n}\n\n/**\n * The frames of one channel, taken one at a time. Frames beyond `capacity` waiting to be taken are a\n * peer ignoring the protocol's pacing, and end the transfer rather than growing the queue.\n */\nclass FrameReader {\n private readonly queue: TFrame[] = [];\n private waiter?: { resolve: (frame: TFrame) => void; reject: (error: TransferEnded) => void };\n private failure?: TransferEnded;\n private readonly stops: (() => void)[] = [];\n\n constructor(\n channel: IFileFrameChannel,\n private readonly capacity: number,\n ) {\n this.stops.push(\n channel.onFrame((text) => {\n const frame = decodeFrame(text);\n if (frame === undefined) {\n this.fail(new TransferEnded('protocol', 'a frame could not be read'));\n return;\n }\n if (this.waiter !== undefined) {\n const { resolve } = this.waiter;\n this.waiter = undefined;\n resolve(frame);\n return;\n }\n if (this.queue.length >= this.capacity) {\n this.fail(new TransferEnded('protocol', 'the peer sent past its credit'));\n return;\n }\n this.queue.push(frame);\n }),\n channel.onClose(() => this.fail(new TransferEnded('closed', 'the channel closed'))),\n );\n }\n\n private fail(error: TransferEnded): void {\n if (this.failure !== undefined) return;\n this.failure = error;\n // A peer that broke the protocol is not read further; frames it queued are not acted on.\n if (error.reason === 'protocol') this.queue.length = 0;\n const waiter = this.waiter;\n this.waiter = undefined;\n waiter?.reject(error);\n }\n\n /** The next frame, or the reason there will be none. */\n next(timeoutMs: number): Promise<TFrame> {\n const queued = this.queue.shift();\n if (queued !== undefined) return Promise.resolve(queued);\n if (this.failure !== undefined) return Promise.reject(this.failure);\n return new Promise<TFrame>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.waiter = undefined;\n reject(new TransferEnded('timeout', `nothing arrived within ${timeoutMs}ms`));\n }, timeoutMs);\n timer.unref?.();\n this.waiter = {\n resolve: (frame) => {\n clearTimeout(timer);\n resolve(frame);\n },\n reject: (error) => {\n clearTimeout(timer);\n reject(error);\n },\n };\n });\n }\n\n /** Whether the channel already ended or broke the protocol. */\n get ended(): boolean {\n return this.failure !== undefined;\n }\n\n stop(): void {\n for (const stop of this.stops.splice(0)) stop();\n }\n}\n\nfunction send(channel: IFileFrameChannel, frame: TFrame): void {\n try {\n channel.send(JSON.stringify(frame));\n } catch (error) {\n throw new TransferEnded('closed', error instanceof Error ? error.message : String(error));\n }\n}\n\nfunction ended(error: unknown): TransferEnded {\n return error instanceof TransferEnded\n ? error\n : new TransferEnded('unavailable', error instanceof Error ? error.message : String(error));\n}\n\n/** Cut a source's content into chunks of exactly `size` bytes, the last one shorter. */\nasync function* rechunk(source: AsyncIterable<Uint8Array>, size: number): AsyncGenerator<Buffer> {\n let pending = Buffer.alloc(0);\n for await (const piece of source) {\n pending = pending.length === 0 ? Buffer.from(piece) : Buffer.concat([pending, piece]);\n while (pending.length >= size) {\n yield pending.subarray(0, size);\n pending = pending.subarray(size);\n }\n }\n if (pending.length > 0) yield pending;\n}\n\nexport interface ISendFileOptions {\n readonly channel: IFileFrameChannel;\n readonly offer: IFileOffer;\n readonly source: IFileSource;\n readonly chunkBytes?: number;\n readonly window?: number;\n readonly idleMs?: number;\n /** How long the receiving operator may take to answer. */\n readonly decisionMs?: number;\n}\n\n/**\n * Offer a file and, once accepted, send it. Resolves when the receiver confirms it kept the file, or\n * with why it did not. Closes the channel either way.\n */\nexport async function sendFileOverChannel(options: ISendFileOptions): Promise<TFileSendOutcome> {\n const { channel, offer, source } = options;\n const chunkBytes = options.chunkBytes ?? FILE_CHUNK_BYTES;\n const window = options.window ?? FILE_CREDIT_WINDOW;\n const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;\n // The receiver sends one credit per chunk and one final frame; nothing else is legitimate.\n const reader = new FrameReader(channel, window + 2);\n try {\n if (source.size !== offer.size) {\n throw new TransferEnded('integrity', 'the source is not the size offered');\n }\n send(channel, { t: 'file-offer', ...offer });\n const answer = await reader.next(options.decisionMs ?? DEFAULT_DECISION_MS);\n if (answer.t === 'file-refuse') throw new TransferEnded(answer.reason, answer.detail);\n if (answer.t !== 'file-accept') throw new TransferEnded('protocol', 'expected an answer');\n\n let sent = 0;\n let credited = 0;\n let bytes = 0;\n const take = async (): Promise<void> => {\n const frame = await reader.next(idleMs);\n if (frame.t === 'file-refuse') throw new TransferEnded(frame.reason, frame.detail);\n if (frame.t !== 'file-credit' || frame.upTo <= credited || frame.upTo > sent) {\n throw new TransferEnded('protocol', 'expected credit');\n }\n credited = frame.upTo;\n };\n for await (const chunk of rechunk(source.read(), chunkBytes)) {\n bytes += chunk.length;\n if (bytes > offer.size) throw new TransferEnded('integrity', 'the source grew while sending');\n while (sent - credited >= window) await take();\n send(channel, { t: 'file-chunk', seq: sent, data: chunk.toString('base64') });\n sent += 1;\n }\n if (bytes !== offer.size)\n throw new TransferEnded('integrity', 'the source shrank while sending');\n send(channel, { t: 'file-end', chunks: sent });\n for (;;) {\n const frame = await reader.next(idleMs);\n if (frame.t === 'file-done') return { ok: true };\n if (frame.t === 'file-refuse') throw new TransferEnded(frame.reason, frame.detail);\n if (frame.t !== 'file-credit' || frame.upTo <= credited || frame.upTo > sent) {\n throw new TransferEnded('protocol', 'expected credit or completion');\n }\n credited = frame.upTo;\n }\n } catch (error) {\n const end = ended(error);\n return {\n ok: false,\n reason: end.reason,\n ...(end.detail !== undefined ? { detail: end.detail } : {}),\n };\n } finally {\n reader.stop();\n channel.close();\n }\n}\n\nexport interface IReceiveFileOptions {\n readonly channel: IFileFrameChannel;\n /** Offers above this are refused without asking anyone. */\n readonly maxBytes?: number;\n /**\n * Decide on a well-formed offer within the size limit: ask whoever must approve it, and open\n * somewhere to put it. `signal` aborts when the sender goes away.\n */\n readonly admit: (offer: IFileOffer, signal: AbortSignal) => Promise<TFileAdmission>;\n readonly window?: number;\n readonly idleMs?: number;\n}\n\n/**\n * Receive one file: the channel's first frame is the offer. Resolves with where the verified file was\n * kept, or with why nothing was. Closes the channel either way.\n */\nexport async function receiveFileOverChannel(\n options: IReceiveFileOptions,\n): Promise<TFileReceiveOutcome> {\n const { channel } = options;\n const maxBytes = options.maxBytes ?? DEFAULT_MAX_FILE_BYTES;\n const window = options.window ?? FILE_CREDIT_WINDOW;\n const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;\n // The sender may be a window of chunks ahead of what was written, plus its end frame.\n const reader = new FrameReader(channel, window + 1);\n const gone = new AbortController();\n const stopWatching = channel.onClose(() => gone.abort());\n let offer: IFileOffer | undefined;\n let sink: IFileSink | undefined;\n let lastWord = true;\n try {\n const first = await reader.next(idleMs);\n if (first.t !== 'file-offer') throw new TransferEnded('protocol', 'expected an offer');\n offer = {\n transferId: first.transferId,\n name: first.name,\n size: first.size,\n sha256: first.sha256,\n };\n if (offer.size > maxBytes) {\n throw new TransferEnded(\n 'too-large',\n `${offer.size} bytes is over the ${maxBytes}-byte limit`,\n );\n }\n const admission = await options.admit(offer, gone.signal);\n if ('refused' in admission) throw new TransferEnded(admission.refused, admission.detail);\n sink = admission.sink;\n if (reader.ended) throw new TransferEnded('closed', 'the sender left before the answer');\n send(channel, { t: 'file-accept' });\n\n const hash: Hash = createHash('sha256');\n let next = 0;\n let bytes = 0;\n for (;;) {\n const frame = await reader.next(idleMs);\n if (frame.t === 'file-end') {\n if (frame.chunks !== next || bytes !== offer.size) {\n throw new TransferEnded('integrity', 'the content is not the size offered');\n }\n if (hash.digest('hex') !== offer.sha256) {\n throw new TransferEnded('integrity', 'the content does not match the offered sha256');\n }\n const location = await sink.commit();\n sink = undefined;\n send(channel, { t: 'file-done' });\n return { ok: true, offer, location };\n }\n if (frame.t !== 'file-chunk' || frame.seq !== next) {\n throw new TransferEnded('protocol', 'expected the next chunk');\n }\n const data = Buffer.from(frame.data, 'base64');\n bytes += data.length;\n if (bytes > offer.size) throw new TransferEnded('integrity', 'more content than offered');\n hash.update(data);\n await sink.write(data);\n next += 1;\n send(channel, { t: 'file-credit', upTo: next });\n }\n } catch (error) {\n const end = ended(error);\n lastWord = end.reason !== 'protocol';\n if (sink !== undefined) {\n // allow-fallback: the transfer already failed for `end`; a failed cleanup must not replace why.\n await sink.discard().catch(() => undefined);\n }\n if (end.reason !== 'closed' && end.reason !== 'timeout') {\n try {\n send(channel, {\n t: 'file-refuse',\n reason: end.reason,\n ...(end.detail !== undefined ? { detail: end.detail } : {}),\n });\n } catch {\n // allow-fallback: the sender is already gone; the outcome below says why.\n }\n }\n return {\n ok: false,\n reason: end.reason,\n ...(end.detail !== undefined ? { detail: end.detail } : {}),\n ...(offer !== undefined ? { offer } : {}),\n };\n } finally {\n // The sender closes once it has the last word; closing first could cut that word off.\n if (lastWord) await closed(gone.signal, LAST_WORD_MS);\n stopWatching();\n reader.stop();\n channel.close();\n }\n}\n\n/** Resolves when `signal` aborts, or after `ms`. */\nfunction closed(signal: AbortSignal, ms: number): Promise<void> {\n if (signal.aborted) return Promise.resolve();\n return new Promise<void>((resolve) => {\n const timer = setTimeout(done, ms);\n timer.unref?.();\n signal.addEventListener('abort', done, { once: true });\n function done(): void {\n clearTimeout(timer);\n signal.removeEventListener('abort', done);\n resolve();\n }\n });\n}\n"],"mappings":"8NA4BA,SAAgB,GAA6B,CAC3C,IAAM,EAAQ,EAAY,EAAW,CAAC,CAAC,SAAS,KAAK,EACrD,GAAI,EAAM,SAAW,GACnB,MAAU,MAAM,yEAAyE,EAE3F,OAAO,CACT,CAyBA,SAAgB,EACd,EAA0D,CAAC,EACtC,CACrB,IAAM,EAAgB,SAAU,GAAU,EAAO,OAAS,GACpD,EAAW,EAAO,QAAU,IAAA,IAAa,EAAO,QAAU,MAAQ,EAAO,QAAU,GAEzF,GAAI,GAAY,EACd,MAAU,MACR,wNAGF,EAGF,GAAI,EACF,MAAO,CAAE,MAAO,EAAO,KAAgB,EAOzC,GAAI,EAAO,QAAU,MAAQ,EAAe,CAC1C,IAAM,EAAS,EAAO,YAAY,KAAK,EACvC,GAAI,CAAC,EACH,MAAU,MACR,yKAEF,EAEF,MAAO,CAAE,MAAO,KAAM,WAAY,CAAO,CAC3C,CAEA,MAAO,CAAE,MAAO,EAAmB,CAAE,CACvC,CAoBA,SAAgB,EAAkB,EAAkB,EAAwC,CAC1F,GAAI,IAAc,IAAA,GAAW,MAAO,GACpC,IAAM,EAAI,OAAO,KAAK,EAAU,MAAM,EAChC,EAAI,OAAO,KAAK,EAAW,MAAM,EAEvC,OADI,EAAE,SAAW,EAAE,QACZ,EAAgB,EAAG,CAAC,CAC7B,CAwBA,SAAgB,EAAiB,EAAuD,CAEtF,GADI,CAAC,GACD,EAAO,MAAM,EAAG,CAAoB,CAAC,CAAC,YAAY,IAAM,SAAe,OAC3E,IAAM,EAAO,EAAO,MAAM,CAAoB,EAG9C,GAAI,IAAS,IAAO,EAAK,KAAO,KAAO,EAAK,KAAO,IAAO,OAC1D,IAAM,EAAa,EAAK,QAAQ,UAAW,EAAE,EAC7C,OAAO,IAAe,GAAK,IAAA,GAAY,CACzC,CC7HA,SAAgB,EAAkB,EAGhC,CACA,IAAM,EAAa,KAAK,UAAU,CAAM,EAClC,EAAQ,OAAO,KAAK,EAAY,MAAM,EAC5C,MAAO,CACL,aACA,UAAW,CACT,OAAQ,EAAW,QAAQ,CAAC,CAAC,OAAO,CAAK,CAAC,CAAC,OAAO,WAAW,EAC7D,WAAY,EAAM,UACpB,CACF,CACF,CAoBA,SAAgB,EACd,EACA,EACmB,CACnB,IAAM,EAAQ,OAAO,KAAK,EAAY,MAAM,EAW5C,OAVI,EAAM,aAAe,EAAU,WAQpB,EAAW,QAAQ,CAAC,CAAC,OAAO,CAAK,CAAC,CAAC,OAAO,WAChD,IAAM,EAAU,OAClB,CAAE,OAAQ,EAAK,EADkB,CAAE,OAAQ,GAAO,QAAS,iBAAkB,EAR3E,CACL,OAAQ,GACR,QAAS,YACT,cAAe,EAAU,WACzB,YAAa,EAAM,UACrB,CAKJ,CCvCA,MAUM,EAAiB,GAAK,IAKtB,EAAmB,SAEnB,EAAyD,CAAC,QAAS,QAAS,OAAO,EAGnF,EAAoF,CACxF,MAAO,CAAE,IAAK,KAAM,EACpB,MAAO,CAAE,IAAK,KAAM,IAAK,OAAQ,EACjC,MAAO,CAAE,IAAK,MAAO,IAAK,SAAU,CACtC,EAsBA,IAAM,EAAN,KAAc,CACS,QAArB,YAAY,EAAuC,CAA9B,KAAA,QAAA,CAA+B,CACtD,EASA,SAAgB,EACd,EACA,EAAiC,CAAC,EACZ,CACtB,IAAM,EAAY,EAAe,CAAM,EACjC,EAAM,EAAK,KAAO,KAAK,IACvB,EAAe,EAAK,mBAAuB,YAAY,IAAI,GAC3D,EAA0B,CAAE,MAAO,EAAK,MAAO,OAAQ,EAAK,MAAO,EACnE,EAAa,EAAU,SAAS,QAAQ,WAAY,EAAE,CAAC,CAAC,YAAY,EACpE,EAAmB,CACvB,KAAM,IAAA,GACN,QAAS,IAAA,GACT,UAAW,IAAA,GACX,cAAe,IAAA,GACf,SAAU,IAAA,EACZ,EACM,EAAkB,IAAI,IAAI,EAAO,iBAAmB,CAAC,CAAC,EACtD,EAAiB,IAAI,IAAI,EAAO,gBAAkB,CAAC,CAAC,EAE1D,eAAe,EAAU,EAA+B,CACtD,GAAI,IAAI,IAAI,CAAG,CAAC,CAAC,WAAa,SAAU,OACxC,IAAM,EAAS,MAAM,EACnB,EACA,CACE,QAAS,CAAE,OAAQ,kBAAmB,EACtC,UAAW,IACX,iBAAkB,KACpB,EACA,CAAE,aAAc,CAAC,CAAU,EAAG,aAAc,CAAE,EAC9C,CACF,EACI,MAAC,EAAO,IAAM,EAAO,SAAW,KACpC,OAAO,KAAK,MAAM,IAAI,YAAY,CAAC,CAAC,OAAO,EAAO,IAAI,CAAC,CACzD,CAEA,eAAe,GAA+C,CAC5D,IAAK,IAAM,KAAO,EAAa,CAAS,EAAG,CACzC,IAAM,EAAW,MAAM,EAAU,CAAG,CAAC,CAAC,UAAY,IAAA,EAAS,EAC3D,GAAI,CAAC,EAAS,CAAQ,EAAG,SAGzB,GAAI,EAAS,SAAW,EAAO,OAAQ,OACvC,IAAM,EAAU,EAAS,SAEzB,OADI,OAAO,GAAY,UAAY,CAAC,IAAI,SAAS,CAAO,EAAG,OACpD,IAAI,IAAI,CAAO,CAAC,CAAC,WAAa,SAAW,EAAU,IAAA,EAC5D,CAEF,CAEA,SAAS,GAAiB,CACxB,OAAO,EAAM,YAAc,IAAA,GAAY,IAAW,EAAa,EAAI,EAAM,SAC3E,CAEA,eAAe,GAA2B,CACxC,EAAM,cAAgB,EAAa,EACnC,GAAI,CAKF,GAFI,EAAO,GAAK,IAAgB,EAAM,QAAU,IAAA,IAChD,EAAM,UAAY,MAAM,EAAgB,EACpC,EAAM,UAAY,IAAA,GAAW,OACjC,IAAM,EAAO,EAAY,MAAM,EAAU,EAAM,OAAO,CAAC,EACvD,GAAI,IAAS,IAAA,GAAW,OACxB,EAAM,KAAO,EACb,EAAM,UAAY,EAAa,CACjC,MAAQ,CAGR,CACF,CAGA,eAAe,GAA6B,CAC1C,GAAI,EAAM,WAAa,IAAA,GAAW,OAAO,EAAM,SAE7C,OAAM,gBAAkB,IAAA,IACxB,EAAa,EAAI,EAAM,cAAgB,KAOzC,MAHA,GAAM,SAAW,EAAU,CAAC,CAAC,YAAc,CACzC,EAAM,SAAW,IAAA,EACnB,CAAC,EACM,EAAM,QACf,CAGA,eAAe,GAAmD,CAOhE,OANI,EAAO,GAAK,GAAgB,MAAM,EAAY,EAC9C,EAAO,GAAK,OACd,EAAM,KAAO,IAAA,GACb,EAAM,QAAU,IAAA,GAChB,EAAM,UAAY,IAAA,IAEb,EAAM,IACf,CAEA,eAAe,EAAU,EAAyB,EAA0C,CAC1F,IAAI,EAAO,MAAM,EAAY,EAC7B,GAAI,IAAS,IAAA,GAAW,MAAM,IAAI,EAAQ,kBAAkB,EAC5D,GAAI,IAAQ,IAAA,GAAW,CACrB,GAAI,EAAK,OAAS,EAAG,MAAM,IAAI,EAAQ,eAAe,EACtD,IAAM,EAAO,EAAK,GAClB,GAAI,IAAS,IAAA,GAAW,MAAM,IAAI,EAAQ,aAAa,EACvD,GAAI,CAAC,EAAU,EAAM,CAAG,EAAG,MAAM,IAAI,EAAQ,cAAc,EAC3D,OAAO,CACT,CACA,IAAI,EAAQ,EAAK,OAAQ,GAAQ,EAAI,MAAQ,CAAG,EAMhD,GALI,EAAM,SAAW,IACnB,MAAM,EAAY,EAClB,EAAO,EAAM,MAAQ,CAAC,EACtB,EAAQ,EAAK,OAAQ,GAAQ,EAAI,MAAQ,CAAG,GAE1C,EAAM,SAAW,EAAG,MAAM,IAAI,EAAQ,aAAa,EAEvD,IAAM,EAAU,EAAM,KAAM,GAAQ,EAAU,EAAK,CAAG,CAAC,EACvD,GAAI,IAAY,IAAA,GAAW,MAAM,IAAI,EAAQ,cAAc,EAC3D,OAAO,CACT,CAEA,eAAe,EAAM,EAA+C,CAClE,GAAI,OAAO,GAAU,UAAY,EAAM,SAAW,EAAG,MAAM,IAAI,EAAQ,WAAW,EAClF,GAAI,EAAM,OAAS,KAAkB,MAAM,IAAI,EAAQ,UAAU,EAEjE,IAAI,EACJ,GAAI,CACF,EAAS,EAAsB,CAAK,CACtC,MAAQ,CACN,MAAM,IAAI,EAAQ,WAAW,CAC/B,CACA,GAAI,OAAO,EAAO,KAAQ,UAAY,EAAa,EAAO,GAAG,IAAM,EACjE,MAAM,IAAI,EAAQ,YAAY,EAEhC,IAAM,EAAM,EAAO,WAAW,KAAM,GAAY,IAAY,EAAO,GAAG,EACtE,GAAI,IAAQ,IAAA,GAAW,MAAM,IAAI,EAAQ,uBAAuB,EAChE,GAAI,EAAO,MAAQ,IAAA,IAAa,OAAO,EAAO,KAAQ,SAAU,MAAM,IAAI,EAAQ,WAAW,EAE7F,IAAM,EAAM,MAAM,EAAU,EAAO,IAAK,CAAG,EACvC,EACJ,GAAI,CACF,EAAM,MAAM,EAAU,EAAK,CAAG,CAChC,MAAQ,CACN,MAAM,IAAI,EAAQ,cAAc,CAClC,CAEA,IAAI,EACJ,GAAI,EACD,YAAc,MAAM,EAAU,EAAO,EAAK,CACzC,OAAQ,EAAO,OACf,SAAU,EAAO,SACjB,WAAY,CAAC,CAAG,EAChB,IAAK,EACL,eAAgB,GAChB,YAAa,IAAI,KAAK,EAAI,CAAC,EAC3B,eAAgB,CAAC,MAAO,MAAO,WAAW,CAC5C,CAAC,EACH,OAAS,EAAO,CACd,MAAM,IAAI,EAAQ,EAAW,CAAK,CAAC,CACrC,CAEA,IAAM,EAAU,OAAO,EAAQ,OAAU,SAAW,EAAQ,MAAM,MAAM,GAAG,EAAI,CAAC,EAChF,GAAI,CAAC,EAAO,eAAe,MAAO,GAAU,EAAQ,SAAS,CAAK,CAAC,EACjE,MAAM,IAAI,EAAQ,eAAe,EAEnC,IAAM,EAAU,EAAQ,IAClB,EAAS,EAAQ,UAIvB,GAAI,EAFD,OAAO,GAAY,UAAY,EAAgB,IAAI,CAAO,GAC1D,OAAO,GAAW,UAAY,EAAe,IAAI,CAAM,GAC5C,MAAM,IAAI,EAAQ,uBAAuB,EACvD,MAAO,CAAE,SAAU,EAAK,CAC1B,CAEA,MAAO,CACL,MAAM,OAAO,EAA+C,CAC1D,GAAI,CACF,OAAO,MAAM,EAAM,CAAK,CAC1B,OAAS,EAAO,CAEd,MAAO,CAAE,SAAU,GAAO,QAAS,aAAiB,EAAU,EAAM,QAAU,WAAY,CAC5F,CACF,CACF,CACF,CAEA,SAAS,EAAe,EAAyC,CAC/D,GAAI,CAAC,IAAI,SAAS,EAAO,MAAM,EAAG,MAAU,MAAM,4CAA4C,EAC9F,IAAM,EAAY,IAAI,IAAI,EAAO,MAAM,EACvC,GAAI,EAAU,WAAa,UAAY,EAAU,SAAW,IAAM,EAAU,OAAS,GACnF,MAAU,MAAM,8EAA8E,EAEhG,GAAI,CAAC,IAAI,SAAS,EAAO,QAAQ,EAC/B,MAAU,MAAM,8CAA8C,EAEhE,GACE,EAAO,WAAW,SAAW,GAC7B,CAAC,EAAO,WAAW,MAAO,GAAQ,EAAqB,SAAS,CAAG,CAAC,EAEpE,MAAU,MACR,qFACF,EAEF,GAAI,EAAO,eAAe,SAAW,EACnC,MAAU,MAAM,8DAA8D,EAGhF,IADoB,EAAO,iBAAiB,QAAU,IAAM,EAAO,gBAAgB,QAAU,KAC1E,EACjB,MAAU,MACR,qKAEF,EAEF,OAAO,CACT,CAGA,SAAS,EAAa,EAAgC,CACpD,IAAM,EAAO,EAAO,WAAa,IAAM,GAAK,EAAO,SAAS,QAAQ,MAAO,EAAE,EAC7E,MAAO,CACL,GAAG,EAAO,OAAO,yCAAyC,IAC1D,GAAG,EAAO,SAAS,EAAK,kCAC1B,CACF,CAEA,SAAS,EAAY,EAA2C,CAC1D,MAAC,EAAS,CAAI,GAAK,CAAC,MAAM,QAAQ,EAAK,IAAI,GAAK,EAAK,KAAK,OAAS,IACvE,OAAO,EAAK,KAAK,OAAQ,GAAoB,EAAS,CAAG,GAAK,OAAO,EAAI,KAAQ,QAAQ,CAC3F,CAEA,SAAS,EAAU,EAAU,EAAqC,CAChE,IAAM,EAAQ,EAAU,GAOxB,OANI,EAAI,MAAQ,EAAM,KAClB,EAAM,MAAQ,IAAA,IAAa,EAAI,MAAQ,EAAM,KAC7C,EAAI,MAAQ,IAAA,IAAa,EAAI,MAAQ,GACrC,EAAI,MAAQ,IAAA,IAAa,EAAI,MAAQ,OACrC,EAAI,UAAY,IAAA,IAAa,CAAC,EAAI,QAAQ,SAAS,QAAQ,EAAU,GAElE,EAAI,IAAM,IAAA,EACnB,CAEA,SAAS,EAAW,EAAqC,CACvD,IAAM,EAAO,EAAS,CAAK,EAAI,EAAM,KAAO,IAAA,GACtC,EAAQ,EAAS,CAAK,EAAI,EAAM,MAAQ,IAAA,GAC9C,OAAQ,EAAR,CACE,IAAK,kBACH,MAAO,UACT,IAAK,wCACH,MAAO,gBACT,IAAK,2BACH,MAAO,wBACT,IAAK,kCAKH,OAJI,IAAU,MAAc,eACxB,IAAU,MAAc,iBACxB,IAAU,MAAc,gBACxB,IAAU,MAAc,aACrB,YACT,QACE,MAAO,WACX,CACF,CAEA,SAAS,EAAa,EAAqB,CACzC,IAAM,EAAQ,EAAI,YAAY,EAC9B,OAAO,EAAM,WAAW,cAAc,EAAI,EAAM,MAAM,EAAqB,EAAI,CACjF,CAEA,SAAS,EAAS,EAAkD,CAClE,OAAO,OAAO,GAAU,YAAY,GAAkB,CAAC,MAAM,QAAQ,CAAK,CAC5E,CChTA,MAAa,EAAyB,GAAK,KAAO,KAMrC,EAAmB,GAAK,KAExB,EAAqB,GAI5B,EAAa,iBACb,EAAS,yBAET,EAAkB,IAgDlB,EAAgC,IAAI,IAA0B,CAClE,WACA,YACA,WACA,SACA,cACA,YACA,WACA,SACA,UACA,aACF,CAAC,EAGK,EAA6D,CACjE,CAAC,EAAM,EAAI,EACX,CAAC,IAAM,GAAI,EACX,CAAC,KAAQ,IAAM,EACf,CAAC,KAAQ,IAAM,EACf,CAAC,KAAQ,IAAM,EACf,CAAC,MAAQ,KAAM,CACjB,EAEA,SAAS,EAAU,EAAsB,CACvC,IAAI,EAAM,GACV,IAAK,IAAM,KAAQ,EAAM,CACvB,IAAM,EAAO,EAAK,YAAY,CAAC,GAAK,EACpC,GAAO,EAAmB,MAAM,CAAC,EAAK,KAAU,GAAQ,GAAO,GAAQ,CAAI,EAAI,IAAM,CACvF,CACA,OAAO,CACT,CAEA,SAAS,EAAQ,EAAiC,CAChD,OAAO,OAAO,GAAU,UAAY,OAAO,cAAc,CAAK,GAAK,GAAS,CAC9E,CAGA,SAAS,EAAY,EAAkC,CACrD,GAAI,EAAK,OAAS,MAAiB,OACnC,IAAI,EACJ,GAAI,CACF,EAAQ,KAAK,MAAM,CAAI,CACzB,MAAQ,CACN,MACF,CACA,GAAI,OAAO,GAAU,WAAY,EAAgB,OACjD,IAAM,EAAQ,EACd,OAAQ,EAAM,EAAd,CACE,IAAK,aAAc,CACjB,GAAM,CAAE,aAAY,OAAM,OAAM,UAAW,EAQ3C,OAPI,OAAO,GAAe,UAAY,EAAW,SAAW,GAAK,EAAW,OAAS,KAGjF,OAAO,GAAS,UAAY,EAAK,OAAS,MAC1C,CAAC,EAAQ,CAAI,GAAK,OAAO,GAAW,UAAY,CAAC,EAAW,KAAK,CAAM,EACzE,OAEK,CAAE,EAAG,aAAc,aAAY,OAAM,OAAM,QAAO,CAC3D,CACA,IAAK,cACH,MAAO,CAAE,EAAG,aAAc,EAC5B,IAAK,cAAe,CAClB,IAAM,EAAS,EAAM,OACf,EAAS,EAAM,OAErB,OADI,OAAO,GAAW,UAAY,CAAC,EAAS,IAAI,CAAM,EAAG,OAClD,CACL,EAAG,cACK,SAER,GAAI,OAAO,GAAW,SAAW,CAAE,OAAQ,EAAU,CAAM,CAAC,CAAC,MAAM,EAAG,GAAG,CAAE,EAAI,CAAC,CAClF,CACF,CACA,IAAK,aAAc,CACjB,GAAM,CAAE,MAAK,QAAS,EAKtB,MAHI,CAAC,EAAQ,CAAG,GAAK,OAAO,GAAS,UAAY,IAAS,IAAM,CAAC,EAAO,KAAK,CAAI,EAC/E,OAEK,CAAE,EAAG,aAAc,MAAK,MAAK,CACtC,CACA,IAAK,cACH,OAAO,EAAQ,EAAM,IAAO,EAAI,CAAE,EAAG,cAAe,KAAM,EAAM,IAAQ,EAAI,IAAA,GAC9E,IAAK,WACH,OAAO,EAAQ,EAAM,MAAS,EAAI,CAAE,EAAG,WAAY,OAAQ,EAAM,MAAU,EAAI,IAAA,GACjF,IAAK,YACH,MAAO,CAAE,EAAG,WAAY,EAC1B,QACE,MACJ,CACF,CAEA,IAAM,EAAN,cAA4B,KAAM,CAErB,OACA,OAFX,YACE,EACA,EACA,CACA,MAAM,GAAU,CAAM,EAHb,KAAA,OAAA,EACA,KAAA,OAAA,CAGX,CACF,EAMM,EAAN,KAAkB,CAQG,SAPnB,MAAmC,CAAC,EACpC,OACA,QACA,MAAyC,CAAC,EAE1C,YACE,EACA,EACA,CADiB,KAAA,SAAA,EAEjB,KAAK,MAAM,KACT,EAAQ,QAAS,GAAS,CACxB,IAAM,EAAQ,EAAY,CAAI,EAC9B,GAAI,IAAU,IAAA,GAAW,CACvB,KAAK,KAAK,IAAI,EAAc,WAAY,2BAA2B,CAAC,EACpE,MACF,CACA,GAAI,KAAK,SAAW,IAAA,GAAW,CAC7B,GAAM,CAAE,WAAY,KAAK,OACzB,KAAK,OAAS,IAAA,GACd,EAAQ,CAAK,EACb,MACF,CACA,GAAI,KAAK,MAAM,QAAU,KAAK,SAAU,CACtC,KAAK,KAAK,IAAI,EAAc,WAAY,+BAA+B,CAAC,EACxE,MACF,CACA,KAAK,MAAM,KAAK,CAAK,CACvB,CAAC,EACD,EAAQ,YAAc,KAAK,KAAK,IAAI,EAAc,SAAU,oBAAoB,CAAC,CAAC,CACpF,CACF,CAEA,KAAa,EAA4B,CACvC,GAAI,KAAK,UAAY,IAAA,GAAW,OAChC,KAAK,QAAU,EAEX,EAAM,SAAW,aAAY,KAAK,MAAM,OAAS,GACrD,IAAM,EAAS,KAAK,OACpB,KAAK,OAAS,IAAA,GACd,GAAQ,OAAO,CAAK,CACtB,CAGA,KAAK,EAAoC,CACvC,IAAM,EAAS,KAAK,MAAM,MAAM,EAGhC,OAFI,IAAW,IAAA,GACX,KAAK,UAAY,IAAA,GACd,IAAI,SAAiB,EAAS,IAAW,CAC9C,IAAM,EAAQ,eAAiB,CAC7B,KAAK,OAAS,IAAA,GACd,EAAO,IAAI,EAAc,UAAW,0BAA0B,EAAU,GAAG,CAAC,CAC9E,EAAG,CAAS,EACZ,EAAM,QAAQ,EACd,KAAK,OAAS,CACZ,QAAU,GAAU,CAClB,aAAa,CAAK,EAClB,EAAQ,CAAK,CACf,EACA,OAAS,GAAU,CACjB,aAAa,CAAK,EAClB,EAAO,CAAK,CACd,CACF,CACF,CAAC,EAjBsC,QAAQ,OAAO,KAAK,OAAO,EADjC,QAAQ,QAAQ,CAAM,CAmBzD,CAGA,IAAI,OAAiB,CACnB,OAAO,KAAK,UAAY,IAAA,EAC1B,CAEA,MAAa,CACX,IAAK,IAAM,KAAQ,KAAK,MAAM,OAAO,CAAC,EAAG,EAAK,CAChD,CACF,EAEA,SAAS,EAAK,EAA4B,EAAqB,CAC7D,GAAI,CACF,EAAQ,KAAK,KAAK,UAAU,CAAK,CAAC,CACpC,OAAS,EAAO,CACd,MAAM,IAAI,EAAc,SAAU,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAAC,CAC1F,CACF,CAEA,SAAS,EAAM,EAA+B,CAC5C,OAAO,aAAiB,EACpB,EACA,IAAI,EAAc,cAAe,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAAC,CAC7F,CAGA,eAAgB,EAAQ,EAAmC,EAAsC,CAC/F,IAAI,EAAU,OAAO,MAAM,CAAC,EAC5B,UAAW,IAAM,KAAS,EAExB,IADA,EAAU,EAAQ,SAAW,EAAI,OAAO,KAAK,CAAK,EAAI,OAAO,OAAO,CAAC,EAAS,CAAK,CAAC,EAC7E,EAAQ,QAAU,GACvB,MAAM,EAAQ,SAAS,EAAG,CAAI,EAC9B,EAAU,EAAQ,SAAS,CAAI,EAG/B,EAAQ,OAAS,IAAG,MAAM,EAChC,CAiBA,eAAsB,EAAoB,EAAsD,CAC9F,GAAM,CAAE,UAAS,QAAO,UAAW,EAC7B,EAAa,EAAQ,YAAA,MACrB,EAAS,EAAQ,QAAA,GACjB,EAAS,EAAQ,QAAU,EAE3B,EAAS,IAAI,EAAY,EAAS,EAAS,CAAC,EAClD,GAAI,CACF,GAAI,EAAO,OAAS,EAAM,KACxB,MAAM,IAAI,EAAc,YAAa,oCAAoC,EAE3E,EAAK,EAAS,CAAE,EAAG,aAAc,GAAG,CAAM,CAAC,EAC3C,IAAM,EAAS,MAAM,EAAO,KAAK,EAAQ,YAAc,GAAmB,EAC1E,GAAI,EAAO,IAAM,cAAe,MAAM,IAAI,EAAc,EAAO,OAAQ,EAAO,MAAM,EACpF,GAAI,EAAO,IAAM,cAAe,MAAM,IAAI,EAAc,WAAY,oBAAoB,EAExF,IAAI,EAAO,EACP,EAAW,EACX,EAAQ,EACN,EAAO,SAA2B,CACtC,IAAM,EAAQ,MAAM,EAAO,KAAK,CAAM,EACtC,GAAI,EAAM,IAAM,cAAe,MAAM,IAAI,EAAc,EAAM,OAAQ,EAAM,MAAM,EACjF,GAAI,EAAM,IAAM,eAAiB,EAAM,MAAQ,GAAY,EAAM,KAAO,EACtE,MAAM,IAAI,EAAc,WAAY,iBAAiB,EAEvD,EAAW,EAAM,IACnB,EACA,UAAW,IAAM,KAAS,EAAQ,EAAO,KAAK,EAAG,CAAU,EAAG,CAE5D,GADA,GAAS,EAAM,OACX,EAAQ,EAAM,KAAM,MAAM,IAAI,EAAc,YAAa,+BAA+B,EAC5F,KAAO,EAAO,GAAY,GAAQ,MAAM,EAAK,EAC7C,EAAK,EAAS,CAAE,EAAG,aAAc,IAAK,EAAM,KAAM,EAAM,SAAS,QAAQ,CAAE,CAAC,EAC5E,GAAQ,CACV,CACA,GAAI,IAAU,EAAM,KAClB,MAAM,IAAI,EAAc,YAAa,iCAAiC,EAExE,IADA,EAAK,EAAS,CAAE,EAAG,WAAY,OAAQ,CAAK,CAAC,IACpC,CACP,IAAM,EAAQ,MAAM,EAAO,KAAK,CAAM,EACtC,GAAI,EAAM,IAAM,YAAa,MAAO,CAAE,GAAI,EAAK,EAC/C,GAAI,EAAM,IAAM,cAAe,MAAM,IAAI,EAAc,EAAM,OAAQ,EAAM,MAAM,EACjF,GAAI,EAAM,IAAM,eAAiB,EAAM,MAAQ,GAAY,EAAM,KAAO,EACtE,MAAM,IAAI,EAAc,WAAY,+BAA+B,EAErE,EAAW,EAAM,IACnB,CACF,OAAS,EAAO,CACd,IAAM,EAAM,EAAM,CAAK,EACvB,MAAO,CACL,GAAI,GACJ,OAAQ,EAAI,OACZ,GAAI,EAAI,SAAW,IAAA,GAAqC,CAAC,EAA1B,CAAE,OAAQ,EAAI,MAAO,CACtD,CACF,QAAU,CACR,EAAO,KAAK,EACZ,EAAQ,MAAM,CAChB,CACF,CAmBA,eAAsB,EACpB,EAC8B,CAC9B,GAAM,CAAE,WAAY,EACd,EAAW,EAAQ,UAAA,SACnB,EAAS,EAAQ,QAAA,GACjB,EAAS,EAAQ,QAAU,EAE3B,EAAS,IAAI,EAAY,EAAS,EAAS,CAAC,EAC5C,EAAO,IAAI,gBACX,EAAe,EAAQ,YAAc,EAAK,MAAM,CAAC,EACnD,EACA,EACA,EAAW,GACf,GAAI,CACF,IAAM,EAAQ,MAAM,EAAO,KAAK,CAAM,EACtC,GAAI,EAAM,IAAM,aAAc,MAAM,IAAI,EAAc,WAAY,mBAAmB,EAOrF,GANA,EAAQ,CACN,WAAY,EAAM,WAClB,KAAM,EAAM,KACZ,KAAM,EAAM,KACZ,OAAQ,EAAM,MAChB,EACI,EAAM,KAAO,EACf,MAAM,IAAI,EACR,YACA,GAAG,EAAM,KAAK,qBAAqB,EAAS,YAC9C,EAEF,IAAM,EAAY,MAAM,EAAQ,MAAM,EAAO,EAAK,MAAM,EACxD,GAAI,YAAa,EAAW,MAAM,IAAI,EAAc,EAAU,QAAS,EAAU,MAAM,EAEvF,GADA,EAAO,EAAU,KACb,EAAO,MAAO,MAAM,IAAI,EAAc,SAAU,mCAAmC,EACvF,EAAK,EAAS,CAAE,EAAG,aAAc,CAAC,EAElC,IAAM,EAAa,EAAW,QAAQ,EAClC,EAAO,EACP,EAAQ,EACZ,OAAS,CACP,IAAM,EAAQ,MAAM,EAAO,KAAK,CAAM,EACtC,GAAI,EAAM,IAAM,WAAY,CAC1B,GAAI,EAAM,SAAW,GAAQ,IAAU,EAAM,KAC3C,MAAM,IAAI,EAAc,YAAa,qCAAqC,EAE5E,GAAI,EAAK,OAAO,KAAK,IAAM,EAAM,OAC/B,MAAM,IAAI,EAAc,YAAa,+CAA+C,EAEtF,IAAM,EAAW,MAAM,EAAK,OAAO,EAGnC,MAFA,GAAO,IAAA,GACP,EAAK,EAAS,CAAE,EAAG,WAAY,CAAC,EACzB,CAAE,GAAI,GAAM,QAAO,UAAS,CACrC,CACA,GAAI,EAAM,IAAM,cAAgB,EAAM,MAAQ,EAC5C,MAAM,IAAI,EAAc,WAAY,yBAAyB,EAE/D,IAAM,EAAO,OAAO,KAAK,EAAM,KAAM,QAAQ,EAE7C,GADA,GAAS,EAAK,OACV,EAAQ,EAAM,KAAM,MAAM,IAAI,EAAc,YAAa,2BAA2B,EACxF,EAAK,OAAO,CAAI,EAChB,MAAM,EAAK,MAAM,CAAI,EACrB,GAAQ,EACR,EAAK,EAAS,CAAE,EAAG,cAAe,KAAM,CAAK,CAAC,CAChD,CACF,OAAS,EAAO,CACd,IAAM,EAAM,EAAM,CAAK,EAMvB,GALA,EAAW,EAAI,SAAW,WACtB,IAAS,IAAA,IAEX,MAAM,EAAK,QAAQ,CAAC,CAAC,UAAY,IAAA,EAAS,EAExC,EAAI,SAAW,UAAY,EAAI,SAAW,UAC5C,GAAI,CACF,EAAK,EAAS,CACZ,EAAG,cACH,OAAQ,EAAI,OACZ,GAAI,EAAI,SAAW,IAAA,GAAqC,CAAC,EAA1B,CAAE,OAAQ,EAAI,MAAO,CACtD,CAAC,CACH,MAAQ,CAER,CAEF,MAAO,CACL,GAAI,GACJ,OAAQ,EAAI,OACZ,GAAI,EAAI,SAAW,IAAA,GAAqC,CAAC,EAA1B,CAAE,OAAQ,EAAI,MAAO,EACpD,GAAI,IAAU,IAAA,GAAwB,CAAC,EAAb,CAAE,OAAM,CACpC,CACF,QAAU,CAEJ,GAAU,MAAM,EAAO,EAAK,OAAQ,GAAY,EACpD,EAAa,EACb,EAAO,KAAK,EACZ,EAAQ,MAAM,CAChB,CACF,CAGA,SAAS,EAAO,EAAqB,EAA2B,CAE9D,OADI,EAAO,QAAgB,QAAQ,QAAQ,EACpC,IAAI,QAAe,GAAY,CACpC,IAAM,EAAQ,WAAW,EAAM,CAAE,EACjC,EAAM,QAAQ,EACd,EAAO,iBAAiB,QAAS,EAAM,CAAE,KAAM,EAAK,CAAC,EACrD,SAAS,GAAa,CACpB,aAAa,CAAK,EAClB,EAAO,oBAAoB,QAAS,CAAI,EACxC,EAAQ,CACV,CACF,CAAC,CACH"}
|
|
1
|
+
{"version":3,"file":"node.js","names":[],"sources":["../../src/node/admission.ts","../../src/node/handoff-manifest.ts","../../src/node/access-token-verifier.ts","../../src/node/bearer-resource-gate.ts","../../src/node/file-transfer.ts"],"sourcesContent":["/**\n * SEC-008 — the machinery behind the admission decision.\n *\n * The SHAPE of the decision is a contract and lives in `@robota-sdk/agent-interface-transport`; an\n * interface package is inert by rule, and minting and comparing a credential need `node:crypto`. So\n * the types are declared there and produced here, in the package every transport already shares.\n *\n * Splitting them is not bookkeeping. Putting the crypto in the contract package would have given\n * every consumer of those types a runtime dependency edge on a Node builtin — the same defect\n * CORE-028 removed from another package, reintroduced one package over.\n */\n\nimport { randomBytes, timingSafeEqual } from 'node:crypto';\n\nimport type {\n ITransportAdmission,\n ITransportAdmissionConfig,\n} from '@robota-sdk/agent-interface-transport';\n\n/** Bytes of entropy per minted token. 32 bytes = 256 bits, hex-encoded to 64 characters. */\nconst TOKEN_BYTES = 32;\n\n/**\n * Mint a per-launch credential.\n *\n * Throws rather than returning a short or empty string if entropy is unavailable, so a transport\n * built on it fails to construct instead of binding with a guessable credential.\n */\nexport function mintTransportToken(): string {\n const token = randomBytes(TOKEN_BYTES).toString('hex');\n if (token.length !== TOKEN_BYTES * 2) {\n throw new Error('transport admission: could not mint a credential of the required length');\n }\n return token;\n}\n\n/**\n * Resolve the admission decision for one transport.\n *\n * IDEMPOTENT: it accepts an already-resolved `ITransportAdmission` and returns it unchanged, so\n * `resolve(resolve(x))` is `resolve(x)`. That is not a convenience — it is what lets a caller\n * holding either shape just call this, instead of deciding which shape it holds first.\n *\n * The HTTP route used to make that decision with `'token' in options.admission`, and review showed\n * it cannot work: BOTH interfaces declare a `token`, so a config is only distinguishable from a\n * resolution by the VALUE. `{ token: '' }` — documented as \"mint a fresh one\" — was read as\n * pre-resolved and installed the empty string as the required credential, which a peer sending an\n * empty bearer would then match. Making this function idempotent deletes that discriminator\n * instead of repairing it.\n *\n * @throws if `open` is set without a reason — an unexplained open transport is the state this\n * module exists to remove, so it is refused at construction rather than recorded and shipped.\n * @throws if a token and `open: true` are asked for together. The two are contradictory, and the\n * silent precedence this used to give (token wins) is the safe DIRECTION but the wrong ANSWER: a\n * caller that wrote both does not know what it is asking for, and one of the two things it believes\n * is false. `WebRtcTransport` already refused the analogous `secret` + `open` pair in its own\n * constructor; a seam whose whole claim is \"one place to read and one place to change\" cannot leave\n * that protection to whichever transport happens to have implemented it.\n */\nexport function resolveAdmission(\n config: ITransportAdmissionConfig | ITransportAdmission = {},\n): ITransportAdmission {\n const requestedOpen = 'open' in config && config.open === true;\n const hasToken = config.token !== undefined && config.token !== null && config.token !== '';\n\n if (hasToken && requestedOpen) {\n throw new Error(\n 'transport admission: a token and `open: true` are contradictory — one requires a credential ' +\n 'and the other requires none. Asking for both means one of the two beliefs behind the call ' +\n 'is wrong; say which is intended.',\n );\n }\n\n if (hasToken) {\n return { token: config.token as string };\n }\n\n // `token: null` is the RESOLVED open state, not an absent token — the one value that tells an\n // already-resolved admission apart from a config. It still has to carry its reason: an\n // admission reaching this function without one was either built by hand or corrupted, and both\n // are the unexplained-open state the reason exists to prevent.\n if (config.token === null || requestedOpen) {\n const reason = config.openReason?.trim();\n if (!reason) {\n throw new Error(\n 'transport admission: an open transport requires `openReason`. An open transport is a ' +\n 'decision, and a decision nobody wrote down is indistinguishable from an oversight.',\n );\n }\n return { token: null, openReason: reason };\n }\n\n return { token: mintTransportToken() };\n}\n\n/**\n * Whether a presented credential matches the required one.\n *\n * Compared with `timingSafeEqual` over the raw bytes, so the answer takes the same time whatever the\n * first differing character is. The length check before it is unavoidable — `timingSafeEqual` throws\n * on unequal lengths — and it does leak one bit: whether the presented credential is the right\n * LENGTH.\n *\n * For a minted token that costs nothing: the length is fixed at 64 hex characters and published in\n * this file. A host that supplies its own token of some other length leaks that length instead, and\n * review was right to point out that the earlier wording (\"fixed and public\") quietly assumed the\n * minted case for every case. Length is not the secret in either — but the claim now says which\n * situation it is describing.\n *\n * A missing credential is a mismatch, not a special case: a peer that sends nothing and a peer that\n * sends the wrong thing are equally unadmitted, and giving them different answers would tell an\n * attacker which half they got right.\n */\nexport function credentialMatches(expected: string, presented: string | undefined): boolean {\n if (presented === undefined) return false;\n const a = Buffer.from(expected, 'utf8');\n const b = Buffer.from(presented, 'utf8');\n if (a.length !== b.length) return false;\n return timingSafeEqual(a, b);\n}\n\n/**\n * Extract a bearer credential from an `Authorization` header value.\n *\n * Returns `undefined` for anything that is not a bearer token, including a header that merely\n * starts with the right word — a peer sending `Bearerness xyz` is sending something else.\n *\n * The SCHEME is matched case-insensitively, per RFC 7235: `auth-scheme` is a token and tokens are\n * compared without regard to case, so `bearer …` and `BEARER …` are the same request. Review found\n * the exact-case match; it failed safe, refusing a conformant client with a 401 it could not act on,\n * which is the kind of refusal that gets read as a server bug and worked around rather than fixed.\n * The credential itself is untouched — it is not a token in that sense and its case is significant.\n *\n * Separator: one or more spaces or tabs, not exactly one space. That is the same clause's `BWS`.\n *\n * PARSED, not matched. `/^Bearer[ \\t]+(.+)$/i` said the same thing and CodeQL was right about it: two\n * adjacent variable-length runs over attacker-controlled input is a polynomial backtrack, and this\n * input arrives on an unauthenticated request — the one place a slow path is worth reaching for.\n * Reading the prefix, then trimming, has no backtracking to exploit and says the same thing more\n * plainly.\n */\nconst BEARER_PREFIX = 'bearer';\n\nexport function bearerCredential(header: string | undefined | null): string | undefined {\n if (!header) return undefined;\n if (header.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX) return undefined;\n const rest = header.slice(BEARER_PREFIX.length);\n // A separator is REQUIRED: `Bearerness xyz` starts with the word and is a different scheme, so\n // `rest` must begin with the whitespace that ends the scheme token.\n if (rest === '' || (rest[0] !== ' ' && rest[0] !== '\\t')) return undefined;\n const credential = rest.replace(/^[ \\t]+/, '');\n return credential === '' ? undefined : credential;\n}\n","/**\n * HANDOFF-001 (#1811): what actually gets built and sent, and how the destination knows it arrived\n * whole.\n *\n * Session mobility decides whether a source can offer a session and what its inventory says.\n * This Node-only module seals and verifies the exact payload bytes.\n *\n * ## Integrity is checked in a specific order\n *\n * Length first, then digest. A truncated transfer is the common failure and the cheap one to\n * detect; hashing a short buffer to discover it was short wastes the work and, worse, reports it as\n * a digest mismatch — which reads as corruption or tampering rather than a dropped connection.\n */\n\nimport { createHash } from 'node:crypto';\n\nimport type { IInteractiveSessionRecord } from '@robota-sdk/agent-interface-session';\nimport type { IHandoffIntegrity } from '@robota-sdk/agent-interface-session-mobility';\n\n/**\n * Serialize a record and describe what was serialized.\n *\n * The string is returned alongside the integrity data because the digest is only meaningful for\n * these exact bytes. A caller that re-serialized the record before sending could produce a\n * different key order and a digest mismatch on a payload that is perfectly correct — a corruption\n * report caused by the corruption check.\n */\nexport function sealHandoffRecord(record: IInteractiveSessionRecord): {\n serialized: string;\n integrity: IHandoffIntegrity;\n} {\n const serialized = JSON.stringify(record);\n const bytes = Buffer.from(serialized, 'utf8');\n return {\n serialized,\n integrity: {\n digest: createHash('sha256').update(bytes).digest('base64url'),\n byteLength: bytes.byteLength,\n },\n };\n}\n\n/** Why a received payload was not accepted. */\nexport type TIntegrityFailure = 'truncated' | 'digest-mismatch';\n\nexport interface IIntegrityVerdict {\n readonly intact: boolean;\n readonly failure?: TIntegrityFailure;\n /** What the manifest promised and what arrived, so a report can say more than \"it failed\". */\n readonly expectedBytes?: number;\n readonly actualBytes?: number;\n}\n\n/**\n * Check a received payload against the manifest's integrity metadata.\n *\n * Nothing is parsed here. Parsing a payload to check it is well-formed would mean building the\n * object graph before knowing whether the bytes are the ones that were sent, and a destination that\n * has already parsed a corrupt payload has already spent the effort this check exists to avoid.\n */\nexport function verifyHandoffPayload(\n serialized: string,\n integrity: IHandoffIntegrity,\n): IIntegrityVerdict {\n const bytes = Buffer.from(serialized, 'utf8');\n if (bytes.byteLength !== integrity.byteLength) {\n return {\n intact: false,\n failure: 'truncated',\n expectedBytes: integrity.byteLength,\n actualBytes: bytes.byteLength,\n };\n }\n const digest = createHash('sha256').update(bytes).digest('base64url');\n if (digest !== integrity.digest) return { intact: false, failure: 'digest-mismatch' };\n return { intact: true };\n}\n","/**\n * The access-token verifier behind `IAccessTokenVerifier`.\n *\n * The cryptography is `jose`'s, not ours: signature verification, claim validation and key import\n * are the parts most often got subtly wrong by hand. What this module adds is the policy around\n * them — which tokens are even offered to `jose`, which key a token may be checked against, and\n * where that key may be fetched from.\n *\n * Keys are found through the issuer's own metadata (RFC 8414, then OpenID discovery) and fetched\n * through the shared egress boundary with private-address reach granted to the issuer's host only,\n * so an internal authorization server works without opening the resource server's egress to every\n * internal address. Every fetch is `https`, byte-bounded, and refuses redirects: a key set is the\n * root of trust for every admission after it, and a redirect is a second origin nobody configured.\n *\n * The key set is cached, and refetched — metadata included — when a token names a `kid` the cache\n * does not hold or when the cache has outlived its maximum age, at most once per interval, so a\n * stream of tokens with invented `kid`s cannot turn this into a request amplifier against the\n * issuer. The age bound is what makes a key the issuer withdrew (rotation after a compromise) stop\n * admitting without anyone presenting a new `kid`. An outage is tolerated only while the cached keys\n * are younger than a hard limit; past it they are dropped and every token is refused, because an\n * outage is not a reason to keep trusting keys nobody has confirmed for an hour.\n */\n\nimport { decodeProtectedHeader, importJWK, jwtVerify } from 'jose';\n\nimport { fetchWithEgressPolicy } from '@robota-sdk/agent-core/node';\nimport type { IEgressDeps } from '@robota-sdk/agent-core/node';\nimport type {\n IAccessTokenVerifier,\n IAccessTokenVerifierConfig,\n TAccessTokenAdmission,\n TAccessTokenAlgorithm,\n TAccessTokenRefusal,\n} from '@robota-sdk/agent-interface-transport';\nimport type { JWK, JWTPayload } from 'jose';\n\n/** Longest token offered to the parser. Real access tokens are a few hundred bytes to a few KiB. */\nconst MAX_TOKEN_LENGTH = 8192;\n/** Byte cap on a metadata document or key set. */\nconst MAX_DOCUMENT_BYTES = 64 * 1024;\n/** Keys accepted from one key set. */\nconst MAX_KEYS = 32;\n/** Deadline for one metadata or key-set exchange. */\nconst FETCH_TIMEOUT_MS = 10_000;\n/** Minimum gap between two key-set fetches, whatever triggered them. */\nconst MIN_REFETCH_INTERVAL_MS = 30_000;\n/** Age after which the key set and metadata are refetched before the next verification. */\nconst MAX_KEY_AGE_MS = 10 * 60_000;\n/** Age past which cached keys are dropped if they could not be refreshed. */\nconst HARD_KEY_AGE_MS = 60 * 60_000;\n/** Clock skew tolerated on `exp` and `nbf`, in seconds. */\nconst CLOCK_SKEW_SECONDS = 60;\nconst ACCESS_TOKEN_TYP = 'at+jwt';\n\nconst SUPPORTED_ALGORITHMS: readonly TAccessTokenAlgorithm[] = ['RS256', 'ES256', 'EdDSA'];\n\n/** The key shape each algorithm requires. A key that disagrees is never offered to `jose`. */\nconst KEY_SHAPE: Readonly<Record<TAccessTokenAlgorithm, { kty: string; crv?: string }>> = {\n RS256: { kty: 'RSA' },\n ES256: { kty: 'EC', crv: 'P-256' },\n EdDSA: { kty: 'OKP', crv: 'Ed25519' },\n};\n\n/** What the verifier reaches outward through. Injected in tests; defaults to the real network. */\nexport interface IAccessTokenVerifierDeps {\n readonly fetch?: IEgressDeps['fetch'];\n readonly lookup?: IEgressDeps['lookup'];\n /** Wall-clock milliseconds since the epoch, for `exp`/`nbf`. Defaults to `Date.now`. */\n readonly now?: () => number;\n /** Monotonic milliseconds, for cache age and refetch intervals. Defaults to `performance.now`. */\n readonly monotonicNow?: () => number;\n}\n\ninterface IKeyCache {\n keys: readonly JWK[] | undefined;\n jwksUri: string | undefined;\n /** Monotonic time of the last successful key-set fetch. */\n fetchedAt: number | undefined;\n /** Monotonic time of the last attempt, successful or not; the rate bound runs from here. */\n lastAttemptAt: number | undefined;\n inflight: Promise<void> | undefined;\n}\n\nclass Refused {\n constructor(readonly refusal: TAccessTokenRefusal) {}\n}\n\n/**\n * Build a verifier for one issuer and one resource.\n *\n * @throws when the configuration cannot describe a safe verifier: a non-`https` issuer, no\n * algorithm or an unsupported one, no required scope, or no allowlisted subject or client. The\n * last is the single-tenant rule: without it any holder of a token from the issuer is admitted.\n */\nexport function createAccessTokenVerifier(\n config: IAccessTokenVerifierConfig,\n deps: IAccessTokenVerifierDeps = {},\n): IAccessTokenVerifier {\n const issuerUrl = validateConfig(config);\n const now = deps.now ?? Date.now;\n const monotonicNow = deps.monotonicNow ?? (() => performance.now());\n const egressDeps: IEgressDeps = { fetch: deps.fetch, lookup: deps.lookup };\n const issuerHost = issuerUrl.hostname.replace(/^\\[|\\]$/g, '').toLowerCase();\n const cache: IKeyCache = {\n keys: undefined,\n jwksUri: undefined,\n fetchedAt: undefined,\n lastAttemptAt: undefined,\n inflight: undefined,\n };\n const allowedSubjects = new Set(config.allowedSubjects ?? []);\n const allowedClients = new Set(config.allowedClients ?? []);\n\n async function fetchJson(url: string): Promise<unknown> {\n if (new URL(url).protocol !== 'https:') return undefined;\n const result = await fetchWithEgressPolicy(\n url,\n {\n headers: { accept: 'application/json' },\n timeoutMs: FETCH_TIMEOUT_MS,\n maxResponseBytes: MAX_DOCUMENT_BYTES,\n },\n { allowedHosts: [issuerHost], maxRedirects: 0 },\n egressDeps,\n );\n if (!result.ok || result.status !== 200) return undefined;\n return JSON.parse(new TextDecoder().decode(result.body)) as unknown;\n }\n\n async function discoverJwksUri(): Promise<string | undefined> {\n for (const url of metadataUrls(issuerUrl)) {\n const metadata = await fetchJson(url).catch(() => undefined);\n if (!isRecord(metadata)) continue;\n // RFC 8414 §3.3: metadata naming another issuer must not be used — and a server that\n // answered with the wrong issuer is not one to ask a second question of.\n if (metadata.issuer !== config.issuer) return undefined;\n const jwksUri = metadata.jwks_uri;\n if (typeof jwksUri !== 'string' || !URL.canParse(jwksUri)) return undefined;\n return new URL(jwksUri).protocol === 'https:' ? jwksUri : undefined;\n }\n return undefined;\n }\n\n function keyAge(): number {\n return cache.fetchedAt === undefined ? Infinity : monotonicNow() - cache.fetchedAt;\n }\n\n async function fetchKeys(): Promise<void> {\n cache.lastAttemptAt = monotonicNow();\n try {\n // Metadata is rediscovered with the key set once the cache is old: a `jwks_uri` is part of\n // what the issuer can rotate.\n if (keyAge() >= MAX_KEY_AGE_MS) cache.jwksUri = undefined;\n cache.jwksUri ??= await discoverJwksUri();\n if (cache.jwksUri === undefined) return;\n const keys = parseKeySet(await fetchJson(cache.jwksUri));\n if (keys === undefined) return;\n cache.keys = keys;\n cache.fetchedAt = monotonicNow();\n } catch {\n // allow-fallback: a failed fetch leaves the cache as it was, and `currentKeys` drops it once\n // it passes the hard age limit — the fail-closed direction.\n }\n }\n\n /** Refetch unless one was attempted within the interval. Concurrent callers share one fetch. */\n async function refreshKeys(): Promise<void> {\n if (cache.inflight !== undefined) return cache.inflight;\n if (\n cache.lastAttemptAt !== undefined &&\n monotonicNow() - cache.lastAttemptAt < MIN_REFETCH_INTERVAL_MS\n ) {\n return;\n }\n cache.inflight = fetchKeys().finally(() => {\n cache.inflight = undefined;\n });\n return cache.inflight;\n }\n\n /** The cached keys, refreshed first when old; `undefined` when none may be trusted. */\n async function currentKeys(): Promise<readonly JWK[] | undefined> {\n if (keyAge() >= MAX_KEY_AGE_MS) await refreshKeys();\n if (keyAge() >= HARD_KEY_AGE_MS) {\n cache.keys = undefined;\n cache.jwksUri = undefined;\n cache.fetchedAt = undefined;\n }\n return cache.keys;\n }\n\n async function selectKey(kid: string | undefined, alg: TAccessTokenAlgorithm): Promise<JWK> {\n let keys = await currentKeys();\n if (keys === undefined) throw new Refused('keys-unavailable');\n if (kid === undefined) {\n if (keys.length > 1) throw new Refused('ambiguous-key');\n const only = keys[0];\n if (only === undefined) throw new Refused('unknown-key');\n if (!keyAgrees(only, alg)) throw new Refused('key-mismatch');\n return only;\n }\n let named = keys.filter((key) => key.kid === kid);\n if (named.length === 0) {\n await refreshKeys();\n keys = cache.keys ?? [];\n named = keys.filter((key) => key.kid === kid);\n }\n if (named.length === 0) throw new Refused('unknown-key');\n // Two keys may share a `kid` across algorithms; the one checked is the one that fits the token.\n const fitting = named.find((key) => keyAgrees(key, alg));\n if (fitting === undefined) throw new Refused('key-mismatch');\n return fitting;\n }\n\n async function check(token: string): Promise<TAccessTokenAdmission> {\n if (typeof token !== 'string' || token.length === 0) throw new Refused('malformed');\n if (token.length > MAX_TOKEN_LENGTH) throw new Refused('oversize');\n\n let header: ReturnType<typeof decodeProtectedHeader>;\n try {\n header = decodeProtectedHeader(token);\n } catch {\n throw new Refused('malformed');\n }\n if (typeof header.typ !== 'string' || normalizeTyp(header.typ) !== ACCESS_TOKEN_TYP) {\n throw new Refused('wrong-type');\n }\n const alg = config.algorithms.find((allowed) => allowed === header.alg);\n if (alg === undefined) throw new Refused('unsupported-algorithm');\n if (header.kid !== undefined && typeof header.kid !== 'string') throw new Refused('malformed');\n\n const jwk = await selectKey(header.kid, alg);\n let key: Awaited<ReturnType<typeof importJWK>>;\n try {\n key = await importJWK(jwk, alg);\n } catch {\n throw new Refused('key-mismatch');\n }\n\n let payload: JWTPayload;\n try {\n ({ payload } = await jwtVerify(token, key, {\n issuer: config.issuer,\n audience: config.resource,\n algorithms: [alg],\n typ: ACCESS_TOKEN_TYP,\n clockTolerance: CLOCK_SKEW_SECONDS,\n currentDate: new Date(now()),\n requiredClaims: ['exp', 'sub', 'client_id'],\n }));\n } catch (error) {\n throw new Refused(refusalFor(error));\n }\n\n const granted = typeof payload.scope === 'string' ? payload.scope.split(' ') : [];\n if (!config.requiredScopes.every((scope) => granted.includes(scope))) {\n throw new Refused('missing-scope');\n }\n const subject = payload.sub;\n const client = payload.client_id;\n const allowed =\n (typeof subject === 'string' && allowedSubjects.has(subject)) ||\n (typeof client === 'string' && allowedClients.has(client));\n if (!allowed) throw new Refused('principal-not-allowed');\n return { admitted: true };\n }\n\n return {\n async verify(token: string): Promise<TAccessTokenAdmission> {\n try {\n return await check(token);\n } catch (error) {\n // Only the reason word leaves: never the token, a claim, or an underlying error message.\n return { admitted: false, refusal: error instanceof Refused ? error.refusal : 'malformed' };\n }\n },\n };\n}\n\nfunction validateConfig(config: IAccessTokenVerifierConfig): URL {\n if (!URL.canParse(config.issuer)) throw new Error('access-token verifier: issuer is not a URL');\n const issuerUrl = new URL(config.issuer);\n if (issuerUrl.protocol !== 'https:' || issuerUrl.search !== '' || issuerUrl.hash !== '') {\n throw new Error('access-token verifier: issuer must be an https URL with no query or fragment');\n }\n if (!URL.canParse(config.resource)) {\n throw new Error('access-token verifier: resource is not a URL');\n }\n if (\n config.algorithms.length === 0 ||\n !config.algorithms.every((alg) => SUPPORTED_ALGORITHMS.includes(alg))\n ) {\n throw new Error(\n 'access-token verifier: algorithms must be a non-empty subset of RS256, ES256, EdDSA',\n );\n }\n if (config.requiredScopes.length === 0) {\n throw new Error('access-token verifier: at least one required scope is needed');\n }\n const principals = (config.allowedSubjects?.length ?? 0) + (config.allowedClients?.length ?? 0);\n if (principals === 0) {\n throw new Error(\n 'access-token verifier: name at least one allowed subject or client — this host is ' +\n 'single-tenant, and issuer + audience + scope alone admit anyone the issuer serves',\n );\n }\n return issuerUrl;\n}\n\n/** RFC 8414 §3.1 inserts the well-known segment before the issuer's path; OpenID appends it. */\nfunction metadataUrls(issuer: URL): readonly string[] {\n const path = issuer.pathname === '/' ? '' : issuer.pathname.replace(/\\/$/, '');\n return [\n `${issuer.origin}/.well-known/oauth-authorization-server${path}`,\n `${issuer.origin}${path}/.well-known/openid-configuration`,\n ];\n}\n\nfunction parseKeySet(body: unknown): readonly JWK[] | undefined {\n if (!isRecord(body) || !Array.isArray(body.keys) || body.keys.length > MAX_KEYS) return undefined;\n return body.keys.filter((key): key is JWK => isRecord(key) && typeof key.kty === 'string');\n}\n\nfunction keyAgrees(jwk: JWK, alg: TAccessTokenAlgorithm): boolean {\n const shape = KEY_SHAPE[alg];\n if (jwk.kty !== shape.kty) return false;\n if (shape.crv !== undefined && jwk.crv !== shape.crv) return false;\n if (jwk.alg !== undefined && jwk.alg !== alg) return false;\n if (jwk.use !== undefined && jwk.use !== 'sig') return false;\n if (jwk.key_ops !== undefined && !jwk.key_ops.includes('verify')) return false;\n // A public key set never carries private material; one that does is not a key set to trust.\n return jwk.d === undefined;\n}\n\nfunction refusalFor(error: unknown): TAccessTokenRefusal {\n const code = isRecord(error) ? error.code : undefined;\n const claim = isRecord(error) ? error.claim : undefined;\n switch (code) {\n case 'ERR_JWT_EXPIRED':\n return 'expired';\n case 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED':\n return 'bad-signature';\n case 'ERR_JOSE_ALG_NOT_ALLOWED':\n return 'unsupported-algorithm';\n case 'ERR_JWT_CLAIM_VALIDATION_FAILED':\n if (claim === 'iss') return 'wrong-issuer';\n if (claim === 'aud') return 'wrong-audience';\n if (claim === 'nbf') return 'not-yet-valid';\n if (claim === 'typ') return 'wrong-type';\n return 'malformed';\n default:\n return 'malformed';\n }\n}\n\nfunction normalizeTyp(typ: string): string {\n const lower = typ.toLowerCase();\n return lower.startsWith('application/') ? lower.slice('application/'.length) : lower;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n","/**\n * The shared admission gate of an OAuth resource server reached over HTTP: RFC 6750 bearer challenges,\n * RFC 9728 protected-resource metadata, the public-URL name checks, and a failure-only throttle per\n * peer address. Every remote HTTP carrier that admits by access token composes these pieces, so the\n * rules are one set of code rather than one per carrier.\n *\n * The host is reached through its PUBLIC URL, usually behind a reverse proxy, so every name the gate\n * checks comes from that URL rather than from the listener: the Host and Origin a request must carry\n * (the proxy must preserve Host) and the paths it serves. The listener's own address is never a name a\n * client uses.\n *\n * Refusals answer with an empty body and, for a token problem, only the standard challenge: an error\n * code from RFC 6750 and where the metadata lives. Only failures are counted, so a peer holding a valid\n * token is never throttled, however many failures share its address. What a carrier reports about a\n * refused peer is a coarse address class, never the address.\n *\n * This module binds no listener; a carrier owns its socket and calls these per request.\n */\n\nimport { BlockList, isIP } from 'node:net';\n\nimport type { IncomingMessage, ServerResponse } from 'node:http';\n\nconst WELL_KNOWN_PREFIX = '/.well-known/oauth-protected-resource';\n/** Window over which one address's failures are counted. */\nconst FAILURE_WINDOW_MS = 60_000;\n/** Failures one address may have in a window before its further failures answer 429. */\nconst MAX_FAILURES_PER_WINDOW = 20;\n/** Addresses tracked at once; the oldest is forgotten first, so a spray cannot grow memory. */\nconst MAX_TRACKED_ADDRESSES = 4096;\n/** RFC 6749 scope-token characters: printable ASCII except space, `\"` and `\\`. */\nconst SCOPE_TOKEN = /^[\\x21\\x23-\\x5b\\x5d-\\x7e]+$/;\n\n/** A coarse class of a peer's address; the address itself is never reported. */\nexport type TRemoteAddressClass = 'loopback' | 'private' | 'public' | 'unknown';\n\n/** Where a refused request came from, and whether its address is over the failure budget. */\nexport interface IBearerFailure {\n readonly remote: TRemoteAddressClass;\n readonly throttled: boolean;\n readonly retryAfterSeconds: number;\n}\n\n/** The public-facing identity of one resource server and its per-address failure budget. */\nexport interface IBearerResourceServer {\n /** The parsed public URL; its path is the resource's base path. */\n readonly url: URL;\n /**\n * Host and Origin must name the public URL. On a mismatch the 403 is written and false returned;\n * nothing is counted, since no credential was judged.\n */\n checkNames(req: IncomingMessage, res: ServerResponse): boolean;\n /** Count one failure against the request's peer. */\n fail(req: IncomingMessage): IBearerFailure;\n /** The request's peer class without counting anything. */\n remote(req: IncomingMessage): TRemoteAddressClass;\n}\n\n/** The challenges and metadata of one protected resource (one audience). */\nexport interface IProtectedResource {\n /** The RFC 9728 path clients fetch the metadata from. */\n readonly wellKnownPath: string;\n readonly metadata: string;\n readonly challenges: {\n readonly missing: string;\n readonly invalid: string;\n readonly scope: string;\n };\n}\n\nconst LOOPBACK_RANGES = new BlockList();\nLOOPBACK_RANGES.addSubnet('127.0.0.0', 8, 'ipv4');\nLOOPBACK_RANGES.addAddress('::1', 'ipv6');\nconst PRIVATE_RANGES = new BlockList();\nPRIVATE_RANGES.addSubnet('10.0.0.0', 8, 'ipv4');\nPRIVATE_RANGES.addSubnet('172.16.0.0', 12, 'ipv4');\nPRIVATE_RANGES.addSubnet('192.168.0.0', 16, 'ipv4');\nPRIVATE_RANGES.addSubnet('169.254.0.0', 16, 'ipv4');\nPRIVATE_RANGES.addSubnet('100.64.0.0', 10, 'ipv4');\nPRIVATE_RANGES.addSubnet('fc00::', 7, 'ipv6');\nPRIVATE_RANGES.addSubnet('fe80::', 10, 'ipv6');\n\n/** A literal address in one spelling: IPv4-mapped IPv6 folded to IPv4, IPv6 lowercased. */\nfunction normalizeAddress(value: string | undefined): string | undefined {\n if (value === undefined) return undefined;\n const text = value.trim().toLowerCase();\n const mapped = text.startsWith('::ffff:') ? text.slice('::ffff:'.length) : undefined;\n if (mapped !== undefined && isIP(mapped) === 4) return mapped;\n return isIP(text) === 0 ? undefined : text;\n}\n\nfunction classifyAddress(address: string | undefined): TRemoteAddressClass {\n if (address === undefined) return 'unknown';\n const family = isIP(address) === 4 ? 'ipv4' : 'ipv6';\n if (LOOPBACK_RANGES.check(address, family)) return 'loopback';\n if (PRIVATE_RANGES.check(address, family)) return 'private';\n return 'public';\n}\n\n/** Parse a resource server's public URL: plain `https`, no credentials, query or fragment. */\nexport function parsePublicHttpsUrl(publicUrl: string, label: string): URL {\n let url: URL;\n try {\n url = new URL(publicUrl);\n } catch {\n throw new Error(`${label} public URL must be an absolute https URL`);\n }\n if (url.protocol !== 'https:') throw new Error(`${label} public URL must use https`);\n if (url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') {\n throw new Error(`${label} public URL must not carry credentials, a query or a fragment`);\n }\n return url;\n}\n\ninterface IFailureWindow {\n count: number;\n startedAt: number;\n}\n\n/** Counts failures per address in fixed windows. Only failures are ever recorded here. */\nfunction createFailureCounter(now: () => number) {\n const windows = new Map<string, IFailureWindow>();\n return (address: string): { throttled: boolean; retryAfterSeconds: number } => {\n const at = now();\n let entry = windows.get(address);\n if (entry === undefined || at - entry.startedAt >= FAILURE_WINDOW_MS) {\n windows.delete(address);\n if (windows.size >= MAX_TRACKED_ADDRESSES) {\n for (const [key, value] of windows) {\n if (at - value.startedAt >= FAILURE_WINDOW_MS) windows.delete(key);\n }\n const oldest = windows.keys().next();\n if (windows.size >= MAX_TRACKED_ADDRESSES && oldest.done !== true) {\n windows.delete(oldest.value);\n }\n }\n entry = { count: 0, startedAt: at };\n windows.set(address, entry);\n }\n entry.count += 1;\n const remainingMs = FAILURE_WINDOW_MS - (at - entry.startedAt);\n return {\n throttled: entry.count > MAX_FAILURES_PER_WINDOW,\n retryAfterSeconds: Math.max(1, Math.ceil(remainingMs / 1000)),\n };\n };\n}\n\n/**\n * Build the public identity of a resource server. Throws when the public URL is not plain `https`,\n * or a trusted proxy is not a literal address. `label` names the carrier in those messages.\n */\nexport function createBearerResourceServer(options: {\n readonly publicUrl: string;\n /** Proxy addresses whose `X-Forwarded-For` is believed. Absent: the header is ignored. */\n readonly trustedProxies?: readonly string[];\n readonly label: string;\n readonly now?: () => number;\n}): IBearerResourceServer {\n const url = parsePublicHttpsUrl(options.publicUrl, options.label);\n const trustedProxies = new Set<string>();\n for (const proxy of options.trustedProxies ?? []) {\n const address = normalizeAddress(proxy);\n if (address === undefined) {\n throw new Error(`${options.label} trusted proxy must be a literal IP address`);\n }\n trustedProxies.add(address);\n }\n const expectedHosts = new Set([url.host]);\n if (url.port === '') expectedHosts.add(`${url.hostname}:443`);\n const failures = createFailureCounter(options.now ?? (() => performance.now()));\n\n function remoteAddress(req: IncomingMessage): string | undefined {\n const peer = normalizeAddress(req.socket.remoteAddress);\n if (peer === undefined || !trustedProxies.has(peer)) return peer;\n const header = req.headers['x-forwarded-for'];\n const hops = (Array.isArray(header) ? header.join(',') : (header ?? '')).split(',');\n // Walk from the proxy inward: the nearest hop not itself a trusted proxy is the client, as the\n // trusted proxy saw it. Hops further left were written by the client and are not believed.\n for (let index = hops.length - 1; index >= 0; index -= 1) {\n const hop = normalizeAddress(hops[index]);\n if (hop === undefined) return peer;\n if (!trustedProxies.has(hop)) return hop;\n }\n return peer;\n }\n\n return {\n url,\n checkNames(req, res) {\n const host = req.headers.host?.toLowerCase();\n const origin = req.headers.origin;\n if (\n host === undefined ||\n !expectedHosts.has(host) ||\n (origin !== undefined && origin !== url.origin)\n ) {\n res.writeHead(403).end();\n return false;\n }\n return true;\n },\n fail(req) {\n const address = remoteAddress(req);\n return { remote: classifyAddress(address), ...failures(address ?? 'unknown') };\n },\n remote(req) {\n return classifyAddress(remoteAddress(req));\n },\n };\n}\n\n/**\n * Describe one protected resource: where its RFC 9728 metadata lives and the challenges that point\n * there. Throws for a non-`https` issuer, or no scope or a scope a challenge cannot quote.\n */\nexport function describeProtectedResource(options: {\n /** The resource identifier (`aud`), an `https` URL. */\n readonly resource: string;\n readonly issuer: string;\n readonly scopes: readonly string[];\n readonly label: string;\n}): IProtectedResource {\n const url = parsePublicHttpsUrl(options.resource, options.label);\n let issuer: URL;\n try {\n issuer = new URL(options.issuer);\n } catch {\n throw new Error(`${options.label} authorization issuer must be an absolute https URL`);\n }\n if (issuer.protocol !== 'https:') {\n throw new Error(`${options.label} authorization issuer must use https`);\n }\n if (options.scopes.length === 0 || !options.scopes.every((scope) => SCOPE_TOKEN.test(scope))) {\n throw new Error(`${options.label} authorization needs at least one valid scope`);\n }\n // RFC 9728 §3.1: the well-known segment goes between the host and the resource's path, with a\n // lone terminating slash dropped.\n const wellKnownPath = `${WELL_KNOWN_PREFIX}${url.pathname === '/' ? '' : url.pathname}`;\n const metadataUrl = `${url.origin}${wellKnownPath}`;\n const scopeList = options.scopes.join(' ');\n return {\n wellKnownPath,\n metadata: JSON.stringify({\n resource: options.resource,\n authorization_servers: [options.issuer],\n scopes_supported: [...options.scopes],\n bearer_methods_supported: ['header'],\n }),\n challenges: {\n missing: `Bearer resource_metadata=\"${metadataUrl}\"`,\n invalid: `Bearer error=\"invalid_token\", resource_metadata=\"${metadataUrl}\"`,\n scope: `Bearer error=\"insufficient_scope\", scope=\"${scopeList}\", resource_metadata=\"${metadataUrl}\"`,\n },\n };\n}\n\n/** Answer a metadata request: GET only, the JSON document, nothing else. */\nexport function serveProtectedResourceMetadata(\n req: IncomingMessage,\n res: ServerResponse,\n resource: IProtectedResource,\n): void {\n if (req.method !== 'GET') {\n res.writeHead(405, { Allow: 'GET' }).end();\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' }).end(resource.metadata);\n}\n\n/**\n * Answer a refused bearer token after its failure was counted: 429 once the peer is over budget,\n * 403 `insufficient_scope` for a missing scope, otherwise 401 with the missing-token or\n * `invalid_token` challenge. Always an empty body.\n */\nexport function refuseBearerToken(\n res: ServerResponse,\n resource: IProtectedResource,\n refusal: 'missing-token' | 'missing-scope' | 'invalid-token',\n failure: IBearerFailure,\n): void {\n if (failure.throttled) {\n res.writeHead(429, { 'Retry-After': String(failure.retryAfterSeconds) }).end();\n } else if (refusal === 'missing-scope') {\n res.writeHead(403, { 'WWW-Authenticate': resource.challenges.scope }).end();\n } else {\n const challenge =\n refusal === 'missing-token' ? resource.challenges.missing : resource.challenges.invalid;\n res.writeHead(401, { 'WWW-Authenticate': challenge }).end();\n }\n}\n","/**\n * The file carrier: one file over one frame channel, in either direction, whatever the channel runs on.\n *\n * ## Shape\n *\n * The sender offers name, size and hash, and sends nothing more until the receiver accepts; the\n * receiver decides (its operator's yes, somewhere to put the file) before a byte arrives. Content\n * then moves in chunks, and the receiver checks the whole against the offer before it is kept. A\n * mismatch discards it. There is no resume: a transfer that ends early is simply gone.\n *\n * ## Backpressure\n *\n * The receiver grants credit as it persists chunks, and the sender keeps at most a window of chunks\n * unconfirmed. So neither side buffers more than the window, however fast the sender reads or slow\n * the receiver's disk, and the bound holds on every channel without asking it about its buffers. A\n * receiver that is sent past its credit ends the transfer: holding the excess is exactly what the\n * window exists to prevent.\n *\n * ## Why chunks are base64 in JSON text\n *\n * Every channel it runs on carries text frames, and a frame is decoded as hostile input on arrival.\n * One encoding for every frame keeps that decoding in one place; base64 costs a third of bandwidth,\n * which the size limit already bounds.\n */\n\nimport { createHash, type Hash } from 'node:crypto';\n\nimport type { IFileFrameChannel, IFileOffer } from '@robota-sdk/agent-interface-session-mobility';\n\n/** Why a transfer did not end with the file kept. */\nexport type TFileTransferRefusal =\n /** The receiving operator said no, could not be asked, or the connection does not carry files. */\n | 'declined'\n /** The file is over the size limit. */\n | 'too-large'\n /** The name is empty, or would leave the receiving directory. */\n | 'bad-name'\n /** A file of that name was already received; nothing is overwritten. */\n | 'exists'\n /** The receiving directory is not one this user owns outright, e.g. a symbolic link. */\n | 'unsafe-path'\n /** The content did not match the offered size or hash. The partial file is discarded. */\n | 'integrity'\n /** A frame broke the protocol. */\n | 'protocol'\n /** The channel ended first. */\n | 'closed'\n /** The other side went quiet. */\n | 'timeout'\n /** The receiver could not store the file. */\n | 'unavailable';\n\n/**\n * The default size limit. A transfer does not resume, so a dropped connection costs the whole file\n * again; a received file also sits on the receiver's disk until its operator removes it. The limit\n * keeps both costs small while covering what is sent between one's own sessions: sources, logs,\n * screenshots, a small archive.\n */\nexport const DEFAULT_MAX_FILE_BYTES = 32 * 1024 * 1024;\n\n/**\n * Bytes of content per chunk. Small enough that a frame, base64 and envelope included, stays well\n * inside the message size every channel it runs on carries.\n */\nexport const FILE_CHUNK_BYTES = 16 * 1024;\n/** Chunks the sender may have unconfirmed. */\nexport const FILE_CREDIT_WINDOW = 16;\n/** A frame longer than a full chunk and its envelope is not one this protocol sends. */\nconst MAX_FRAME_CHARS = Math.ceil(FILE_CHUNK_BYTES / 3) * 4 + 512;\nconst MAX_NAME_CHARS = 1024;\nconst SHA256_HEX = /^[0-9a-f]{64}$/;\nconst BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;\n/** Between frames once content flows. */\nconst DEFAULT_IDLE_MS = 30_000;\n/** For the receiving operator to answer. */\nconst DEFAULT_DECISION_MS = 5 * 60_000;\n/** How long the receiver waits, after its last frame, for the sender to close. */\nconst LAST_WORD_MS = 5_000;\n\n/** Where content comes from. `read` yields the content once, in order, in pieces of any size. */\nexport interface IFileSource {\n readonly size: number;\n read(): AsyncIterable<Uint8Array>;\n}\n\n/** Where received content goes until it is verified; nothing is kept unless `commit` runs. */\nexport interface IFileSink {\n write(chunk: Uint8Array): Promise<void>;\n /** Keep the verified file. Resolves with where it is. */\n commit(): Promise<string>;\n /** Drop what was written. */\n discard(): Promise<void>;\n}\n\n/** The receiver's decision on an offer: somewhere to put it, or why not. */\nexport type TFileAdmission =\n | { readonly sink: IFileSink }\n | { readonly refused: TFileTransferRefusal; readonly detail?: string };\n\nexport type TFileSendOutcome =\n | { readonly ok: true }\n | { readonly ok: false; readonly reason: TFileTransferRefusal; readonly detail?: string };\n\nexport type TFileReceiveOutcome =\n | { readonly ok: true; readonly offer: IFileOffer; readonly location: string }\n | {\n readonly ok: false;\n readonly reason: TFileTransferRefusal;\n readonly detail?: string;\n readonly offer?: IFileOffer;\n };\n\ntype TFrame =\n | ({ readonly t: 'file-offer' } & IFileOffer)\n | { readonly t: 'file-accept' }\n | { readonly t: 'file-refuse'; readonly reason: TFileTransferRefusal; readonly detail?: string }\n | { readonly t: 'file-chunk'; readonly seq: number; readonly data: string }\n | { readonly t: 'file-credit'; readonly upTo: number }\n | { readonly t: 'file-end'; readonly chunks: number }\n | { readonly t: 'file-done' };\n\nconst REFUSALS: ReadonlySet<string> = new Set<TFileTransferRefusal>([\n 'declined',\n 'too-large',\n 'bad-name',\n 'exists',\n 'unsafe-path',\n 'integrity',\n 'protocol',\n 'closed',\n 'timeout',\n 'unavailable',\n]);\n\n/** Code points that could repaint a terminal, hide text or reorder it. */\nconst UNPRINTABLE_RANGES: readonly (readonly [number, number])[] = [\n [0x00, 0x1f],\n [0x7f, 0x9f],\n [0x200b, 0x200f],\n [0x2028, 0x202e],\n [0x2066, 0x2069],\n [0xfeff, 0xfeff],\n];\n\nfunction printable(text: string): string {\n let out = '';\n for (const char of text) {\n const code = char.codePointAt(0) ?? 0;\n out += UNPRINTABLE_RANGES.some(([low, high]) => code >= low && code <= high) ? ' ' : char;\n }\n return out;\n}\n\nfunction isCount(value: unknown): value is number {\n return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;\n}\n\n/** Decode one frame as hostile input: anything not exactly a frame of this protocol is `undefined`. */\nfunction decodeFrame(text: string): TFrame | undefined {\n if (text.length > MAX_FRAME_CHARS) return undefined;\n let value: unknown;\n try {\n value = JSON.parse(text);\n } catch {\n return undefined;\n }\n if (typeof value !== 'object' || value === null) return undefined;\n const frame = value as Record<string, unknown>;\n switch (frame['t']) {\n case 'file-offer': {\n const { transferId, name, size, sha256 } = frame;\n if (typeof transferId !== 'string' || transferId.length === 0 || transferId.length > 128) {\n return undefined;\n }\n if (typeof name !== 'string' || name.length > MAX_NAME_CHARS) return undefined;\n if (!isCount(size) || typeof sha256 !== 'string' || !SHA256_HEX.test(sha256)) {\n return undefined;\n }\n return { t: 'file-offer', transferId, name, size, sha256 };\n }\n case 'file-accept':\n return { t: 'file-accept' };\n case 'file-refuse': {\n const reason = frame['reason'];\n const detail = frame['detail'];\n if (typeof reason !== 'string' || !REFUSALS.has(reason)) return undefined;\n return {\n t: 'file-refuse',\n reason: reason as TFileTransferRefusal,\n // The peer's words, shown to an operator and handed to a model: printable text only.\n ...(typeof detail === 'string' ? { detail: printable(detail).slice(0, 500) } : {}),\n };\n }\n case 'file-chunk': {\n const { seq, data } = frame;\n // An empty chunk moves nothing and would let a sender hold an approved transfer open forever.\n if (!isCount(seq) || typeof data !== 'string' || data === '' || !BASE64.test(data)) {\n return undefined;\n }\n return { t: 'file-chunk', seq, data };\n }\n case 'file-credit':\n return isCount(frame['upTo']) ? { t: 'file-credit', upTo: frame['upTo'] } : undefined;\n case 'file-end':\n return isCount(frame['chunks']) ? { t: 'file-end', chunks: frame['chunks'] } : undefined;\n case 'file-done':\n return { t: 'file-done' };\n default:\n return undefined;\n }\n}\n\nclass TransferEnded extends Error {\n constructor(\n readonly reason: TFileTransferRefusal,\n readonly detail?: string,\n ) {\n super(detail ?? reason);\n }\n}\n\n/**\n * The frames of one channel, taken one at a time. Frames beyond `capacity` waiting to be taken are a\n * peer ignoring the protocol's pacing, and end the transfer rather than growing the queue.\n */\nclass FrameReader {\n private readonly queue: TFrame[] = [];\n private waiter?: { resolve: (frame: TFrame) => void; reject: (error: TransferEnded) => void };\n private failure?: TransferEnded;\n private readonly stops: (() => void)[] = [];\n\n constructor(\n channel: IFileFrameChannel,\n private readonly capacity: number,\n ) {\n this.stops.push(\n channel.onFrame((text) => {\n const frame = decodeFrame(text);\n if (frame === undefined) {\n this.fail(new TransferEnded('protocol', 'a frame could not be read'));\n return;\n }\n if (this.waiter !== undefined) {\n const { resolve } = this.waiter;\n this.waiter = undefined;\n resolve(frame);\n return;\n }\n if (this.queue.length >= this.capacity) {\n this.fail(new TransferEnded('protocol', 'the peer sent past its credit'));\n return;\n }\n this.queue.push(frame);\n }),\n channel.onClose(() => this.fail(new TransferEnded('closed', 'the channel closed'))),\n );\n }\n\n private fail(error: TransferEnded): void {\n if (this.failure !== undefined) return;\n this.failure = error;\n // A peer that broke the protocol is not read further; frames it queued are not acted on.\n if (error.reason === 'protocol') this.queue.length = 0;\n const waiter = this.waiter;\n this.waiter = undefined;\n waiter?.reject(error);\n }\n\n /** The next frame, or the reason there will be none. */\n next(timeoutMs: number): Promise<TFrame> {\n const queued = this.queue.shift();\n if (queued !== undefined) return Promise.resolve(queued);\n if (this.failure !== undefined) return Promise.reject(this.failure);\n return new Promise<TFrame>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.waiter = undefined;\n reject(new TransferEnded('timeout', `nothing arrived within ${timeoutMs}ms`));\n }, timeoutMs);\n timer.unref?.();\n this.waiter = {\n resolve: (frame) => {\n clearTimeout(timer);\n resolve(frame);\n },\n reject: (error) => {\n clearTimeout(timer);\n reject(error);\n },\n };\n });\n }\n\n /** Whether the channel already ended or broke the protocol. */\n get ended(): boolean {\n return this.failure !== undefined;\n }\n\n stop(): void {\n for (const stop of this.stops.splice(0)) stop();\n }\n}\n\nfunction send(channel: IFileFrameChannel, frame: TFrame): void {\n try {\n channel.send(JSON.stringify(frame));\n } catch (error) {\n throw new TransferEnded('closed', error instanceof Error ? error.message : String(error));\n }\n}\n\nfunction ended(error: unknown): TransferEnded {\n return error instanceof TransferEnded\n ? error\n : new TransferEnded('unavailable', error instanceof Error ? error.message : String(error));\n}\n\n/** Cut a source's content into chunks of exactly `size` bytes, the last one shorter. */\nasync function* rechunk(source: AsyncIterable<Uint8Array>, size: number): AsyncGenerator<Buffer> {\n let pending = Buffer.alloc(0);\n for await (const piece of source) {\n pending = pending.length === 0 ? Buffer.from(piece) : Buffer.concat([pending, piece]);\n while (pending.length >= size) {\n yield pending.subarray(0, size);\n pending = pending.subarray(size);\n }\n }\n if (pending.length > 0) yield pending;\n}\n\nexport interface ISendFileOptions {\n readonly channel: IFileFrameChannel;\n readonly offer: IFileOffer;\n readonly source: IFileSource;\n readonly chunkBytes?: number;\n readonly window?: number;\n readonly idleMs?: number;\n /** How long the receiving operator may take to answer. */\n readonly decisionMs?: number;\n}\n\n/**\n * Offer a file and, once accepted, send it. Resolves when the receiver confirms it kept the file, or\n * with why it did not. Closes the channel either way.\n */\nexport async function sendFileOverChannel(options: ISendFileOptions): Promise<TFileSendOutcome> {\n const { channel, offer, source } = options;\n const chunkBytes = options.chunkBytes ?? FILE_CHUNK_BYTES;\n const window = options.window ?? FILE_CREDIT_WINDOW;\n const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;\n // The receiver sends one credit per chunk and one final frame; nothing else is legitimate.\n const reader = new FrameReader(channel, window + 2);\n try {\n if (source.size !== offer.size) {\n throw new TransferEnded('integrity', 'the source is not the size offered');\n }\n send(channel, { t: 'file-offer', ...offer });\n const answer = await reader.next(options.decisionMs ?? DEFAULT_DECISION_MS);\n if (answer.t === 'file-refuse') throw new TransferEnded(answer.reason, answer.detail);\n if (answer.t !== 'file-accept') throw new TransferEnded('protocol', 'expected an answer');\n\n let sent = 0;\n let credited = 0;\n let bytes = 0;\n const take = async (): Promise<void> => {\n const frame = await reader.next(idleMs);\n if (frame.t === 'file-refuse') throw new TransferEnded(frame.reason, frame.detail);\n if (frame.t !== 'file-credit' || frame.upTo <= credited || frame.upTo > sent) {\n throw new TransferEnded('protocol', 'expected credit');\n }\n credited = frame.upTo;\n };\n for await (const chunk of rechunk(source.read(), chunkBytes)) {\n bytes += chunk.length;\n if (bytes > offer.size) throw new TransferEnded('integrity', 'the source grew while sending');\n while (sent - credited >= window) await take();\n send(channel, { t: 'file-chunk', seq: sent, data: chunk.toString('base64') });\n sent += 1;\n }\n if (bytes !== offer.size)\n throw new TransferEnded('integrity', 'the source shrank while sending');\n send(channel, { t: 'file-end', chunks: sent });\n for (;;) {\n const frame = await reader.next(idleMs);\n if (frame.t === 'file-done') return { ok: true };\n if (frame.t === 'file-refuse') throw new TransferEnded(frame.reason, frame.detail);\n if (frame.t !== 'file-credit' || frame.upTo <= credited || frame.upTo > sent) {\n throw new TransferEnded('protocol', 'expected credit or completion');\n }\n credited = frame.upTo;\n }\n } catch (error) {\n const end = ended(error);\n return {\n ok: false,\n reason: end.reason,\n ...(end.detail !== undefined ? { detail: end.detail } : {}),\n };\n } finally {\n reader.stop();\n channel.close();\n }\n}\n\nexport interface IReceiveFileOptions {\n readonly channel: IFileFrameChannel;\n /** Offers above this are refused without asking anyone. */\n readonly maxBytes?: number;\n /**\n * Decide on a well-formed offer within the size limit: ask whoever must approve it, and open\n * somewhere to put it. `signal` aborts when the sender goes away.\n */\n readonly admit: (offer: IFileOffer, signal: AbortSignal) => Promise<TFileAdmission>;\n readonly window?: number;\n readonly idleMs?: number;\n}\n\n/**\n * Receive one file: the channel's first frame is the offer. Resolves with where the verified file was\n * kept, or with why nothing was. Closes the channel either way.\n */\nexport async function receiveFileOverChannel(\n options: IReceiveFileOptions,\n): Promise<TFileReceiveOutcome> {\n const { channel } = options;\n const maxBytes = options.maxBytes ?? DEFAULT_MAX_FILE_BYTES;\n const window = options.window ?? FILE_CREDIT_WINDOW;\n const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;\n // The sender may be a window of chunks ahead of what was written, plus its end frame.\n const reader = new FrameReader(channel, window + 1);\n const gone = new AbortController();\n const stopWatching = channel.onClose(() => gone.abort());\n let offer: IFileOffer | undefined;\n let sink: IFileSink | undefined;\n let lastWord = true;\n try {\n const first = await reader.next(idleMs);\n if (first.t !== 'file-offer') throw new TransferEnded('protocol', 'expected an offer');\n offer = {\n transferId: first.transferId,\n name: first.name,\n size: first.size,\n sha256: first.sha256,\n };\n if (offer.size > maxBytes) {\n throw new TransferEnded(\n 'too-large',\n `${offer.size} bytes is over the ${maxBytes}-byte limit`,\n );\n }\n const admission = await options.admit(offer, gone.signal);\n if ('refused' in admission) throw new TransferEnded(admission.refused, admission.detail);\n sink = admission.sink;\n if (reader.ended) throw new TransferEnded('closed', 'the sender left before the answer');\n send(channel, { t: 'file-accept' });\n\n const hash: Hash = createHash('sha256');\n let next = 0;\n let bytes = 0;\n for (;;) {\n const frame = await reader.next(idleMs);\n if (frame.t === 'file-end') {\n if (frame.chunks !== next || bytes !== offer.size) {\n throw new TransferEnded('integrity', 'the content is not the size offered');\n }\n if (hash.digest('hex') !== offer.sha256) {\n throw new TransferEnded('integrity', 'the content does not match the offered sha256');\n }\n const location = await sink.commit();\n sink = undefined;\n send(channel, { t: 'file-done' });\n return { ok: true, offer, location };\n }\n if (frame.t !== 'file-chunk' || frame.seq !== next) {\n throw new TransferEnded('protocol', 'expected the next chunk');\n }\n const data = Buffer.from(frame.data, 'base64');\n bytes += data.length;\n if (bytes > offer.size) throw new TransferEnded('integrity', 'more content than offered');\n hash.update(data);\n await sink.write(data);\n next += 1;\n send(channel, { t: 'file-credit', upTo: next });\n }\n } catch (error) {\n const end = ended(error);\n lastWord = end.reason !== 'protocol';\n if (sink !== undefined) {\n // allow-fallback: the transfer already failed for `end`; a failed cleanup must not replace why.\n await sink.discard().catch(() => undefined);\n }\n if (end.reason !== 'closed' && end.reason !== 'timeout') {\n try {\n send(channel, {\n t: 'file-refuse',\n reason: end.reason,\n ...(end.detail !== undefined ? { detail: end.detail } : {}),\n });\n } catch {\n // allow-fallback: the sender is already gone; the outcome below says why.\n }\n }\n return {\n ok: false,\n reason: end.reason,\n ...(end.detail !== undefined ? { detail: end.detail } : {}),\n ...(offer !== undefined ? { offer } : {}),\n };\n } finally {\n // The sender closes once it has the last word; closing first could cut that word off.\n if (lastWord) await closed(gone.signal, LAST_WORD_MS);\n stopWatching();\n reader.stop();\n channel.close();\n }\n}\n\n/** Resolves when `signal` aborts, or after `ms`. */\nfunction closed(signal: AbortSignal, ms: number): Promise<void> {\n if (signal.aborted) return Promise.resolve();\n return new Promise<void>((resolve) => {\n const timer = setTimeout(done, ms);\n timer.unref?.();\n signal.addEventListener('abort', done, { once: true });\n function done(): void {\n clearTimeout(timer);\n signal.removeEventListener('abort', done);\n resolve();\n }\n });\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-transport",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.83",
|
|
4
4
|
"description": "Transport package boundary for Robota SDK — runtime hosts are owned by agent-framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/node/index.js",
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
"default": "./dist/node/node.cjs"
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
}
|
|
70
|
+
},
|
|
71
|
+
"./package.json": "./package.json"
|
|
71
72
|
},
|
|
72
73
|
"repository": {
|
|
73
74
|
"type": "git",
|
|
@@ -78,21 +79,22 @@
|
|
|
78
79
|
"url": "https://github.com/woojubb/robota/issues"
|
|
79
80
|
},
|
|
80
81
|
"engines": {
|
|
81
|
-
"node": ">=22.
|
|
82
|
+
"node": ">=22.12.0"
|
|
82
83
|
},
|
|
83
84
|
"files": [
|
|
84
85
|
"dist",
|
|
85
|
-
"src"
|
|
86
|
+
"src",
|
|
87
|
+
"CHANGELOG.md"
|
|
86
88
|
],
|
|
87
89
|
"dependencies": {
|
|
88
90
|
"jose": "6.2.3",
|
|
89
|
-
"@robota-sdk/agent-core": "3.0.0-beta.
|
|
90
|
-
"@robota-sdk/agent-interface-analytics": "3.0.0-beta.
|
|
91
|
-
"@robota-sdk/agent-interface-command": "3.0.0-beta.
|
|
92
|
-
"@robota-sdk/agent-interface-execution": "3.0.0-beta.
|
|
93
|
-
"@robota-sdk/agent-interface-session": "3.0.0-beta.
|
|
94
|
-
"@robota-sdk/agent-interface-
|
|
95
|
-
"@robota-sdk/agent-interface-
|
|
91
|
+
"@robota-sdk/agent-core": "3.0.0-beta.83",
|
|
92
|
+
"@robota-sdk/agent-interface-analytics": "3.0.0-beta.83",
|
|
93
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.83",
|
|
94
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.83",
|
|
95
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.83",
|
|
96
|
+
"@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.83",
|
|
97
|
+
"@robota-sdk/agent-interface-transport": "3.0.0-beta.83"
|
|
96
98
|
},
|
|
97
99
|
"devDependencies": {
|
|
98
100
|
"rimraf": "^5.0.10",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createBearerResourceServer,
|
|
5
|
+
describeProtectedResource,
|
|
6
|
+
} from '../node/bearer-resource-gate.js';
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
9
|
+
|
|
10
|
+
function request(headers: Record<string, string>, remoteAddress = '127.0.0.1'): IncomingMessage {
|
|
11
|
+
return { headers, socket: { remoteAddress } } as unknown as IncomingMessage;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function response() {
|
|
15
|
+
const written: { status?: number; headers?: Record<string, string> } = {};
|
|
16
|
+
const res = {
|
|
17
|
+
writeHead(status: number, headers?: Record<string, string>) {
|
|
18
|
+
written.status = status;
|
|
19
|
+
if (headers !== undefined) written.headers = headers;
|
|
20
|
+
return { end: () => undefined };
|
|
21
|
+
},
|
|
22
|
+
} as unknown as ServerResponse;
|
|
23
|
+
return { res, written };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('shared bearer resource-server gate', () => {
|
|
27
|
+
it('places the RFC 9728 metadata before the resource path and points every challenge at it', () => {
|
|
28
|
+
const resource = describeProtectedResource({
|
|
29
|
+
resource: 'https://robota.example/hooks/events/ci',
|
|
30
|
+
issuer: 'https://auth.example',
|
|
31
|
+
scopes: ['a', 'b'],
|
|
32
|
+
label: 'Test',
|
|
33
|
+
});
|
|
34
|
+
expect(resource.wellKnownPath).toBe('/.well-known/oauth-protected-resource/hooks/events/ci');
|
|
35
|
+
const metadata = 'https://robota.example/.well-known/oauth-protected-resource/hooks/events/ci';
|
|
36
|
+
expect(resource.challenges).toEqual({
|
|
37
|
+
missing: `Bearer resource_metadata="${metadata}"`,
|
|
38
|
+
invalid: `Bearer error="invalid_token", resource_metadata="${metadata}"`,
|
|
39
|
+
scope: `Bearer error="insufficient_scope", scope="a b", resource_metadata="${metadata}"`,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('believes X-Forwarded-For only from a trusted proxy, and only its rightmost untrusted hop', () => {
|
|
44
|
+
const server = createBearerResourceServer({
|
|
45
|
+
publicUrl: 'https://robota.example',
|
|
46
|
+
trustedProxies: ['127.0.0.1', '10.0.0.2'],
|
|
47
|
+
label: 'Test',
|
|
48
|
+
});
|
|
49
|
+
const budgetAfter = (count: number, req: IncomingMessage) => {
|
|
50
|
+
let last = server.fail(req);
|
|
51
|
+
for (let index = 1; index < count; index += 1) last = server.fail(req);
|
|
52
|
+
return last;
|
|
53
|
+
};
|
|
54
|
+
// From an untrusted peer the header is ignored: the peer itself is counted and classed.
|
|
55
|
+
const untrusted = request({ 'x-forwarded-for': '198.51.100.9' }, '203.0.113.5');
|
|
56
|
+
expect(server.remote(untrusted)).toBe('public');
|
|
57
|
+
expect(budgetAfter(21, untrusted).throttled).toBe(true);
|
|
58
|
+
expect(server.fail(request({}, '198.51.100.9')).throttled).toBe(false);
|
|
59
|
+
// From a trusted proxy: the nearest untrusted hop, skipping further trusted proxies.
|
|
60
|
+
const viaProxies = request({ 'x-forwarded-for': '6.6.6.6, 198.51.100.20, 10.0.0.2' });
|
|
61
|
+
expect(budgetAfter(21, viaProxies).throttled).toBe(true);
|
|
62
|
+
// A forged hop further left does not move the counted address.
|
|
63
|
+
const forged = request({ 'x-forwarded-for': '1.1.1.1, 198.51.100.20' });
|
|
64
|
+
expect(server.fail(forged).throttled).toBe(true);
|
|
65
|
+
// A garbage hop falls back to the proxy itself.
|
|
66
|
+
expect(server.remote(request({ 'x-forwarded-for': 'not-an-ip' }))).toBe('loopback');
|
|
67
|
+
// A different client behind the proxy has its own budget.
|
|
68
|
+
expect(server.fail(request({ 'x-forwarded-for': '198.51.100.21' })).throttled).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('refuses an unsafe configuration, naming the carrier', () => {
|
|
72
|
+
const base = {
|
|
73
|
+
resource: 'https://r.example/x',
|
|
74
|
+
issuer: 'https://i.example',
|
|
75
|
+
scopes: ['s'],
|
|
76
|
+
label: 'Carrier',
|
|
77
|
+
};
|
|
78
|
+
expect(() => describeProtectedResource({ ...base, resource: 'http://r.example/x' })).toThrow(
|
|
79
|
+
/^Carrier public URL must use https/,
|
|
80
|
+
);
|
|
81
|
+
expect(() => describeProtectedResource({ ...base, issuer: 'http://i.example' })).toThrow(
|
|
82
|
+
/^Carrier authorization issuer/,
|
|
83
|
+
);
|
|
84
|
+
expect(() => describeProtectedResource({ ...base, scopes: ['a b'] })).toThrow(/valid scope/);
|
|
85
|
+
expect(() =>
|
|
86
|
+
createBearerResourceServer({
|
|
87
|
+
publicUrl: 'https://r.example',
|
|
88
|
+
trustedProxies: ['proxy'],
|
|
89
|
+
label: 'Carrier',
|
|
90
|
+
}),
|
|
91
|
+
).toThrow(/^Carrier trusted proxy must be a literal IP address/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('checks Host and Origin against the public URL and counts only the failures it is told of', () => {
|
|
95
|
+
let clock = 0;
|
|
96
|
+
const server = createBearerResourceServer({
|
|
97
|
+
publicUrl: 'https://robota.example/hooks',
|
|
98
|
+
label: 'Test',
|
|
99
|
+
now: () => clock,
|
|
100
|
+
});
|
|
101
|
+
const ok = response();
|
|
102
|
+
expect(server.checkNames(request({ host: 'robota.example' }), ok.res)).toBe(true);
|
|
103
|
+
expect(
|
|
104
|
+
server.checkNames(
|
|
105
|
+
request({ host: 'robota.example:443', origin: 'https://robota.example' }),
|
|
106
|
+
ok.res,
|
|
107
|
+
),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
const bad = response();
|
|
110
|
+
expect(server.checkNames(request({ host: '127.0.0.1' }), bad.res)).toBe(false);
|
|
111
|
+
expect(bad.written.status).toBe(403);
|
|
112
|
+
const peer = request({ host: 'robota.example' }, '203.0.113.9');
|
|
113
|
+
let last = server.fail(peer);
|
|
114
|
+
for (let index = 1; index < 21; index += 1) last = server.fail(peer);
|
|
115
|
+
expect(last).toMatchObject({ remote: 'public', throttled: true });
|
|
116
|
+
expect(server.remote(request({}, '10.0.0.4'))).toBe('private');
|
|
117
|
+
clock = 60_000;
|
|
118
|
+
expect(server.fail(peer).throttled).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|