import type { TokenStore } from '../token-store.js'; import type { PairingRegistry } from '../ws/pairing-registry.js'; import type { AuditSink } from '../audit.js'; import type { RateLimiter } from '../rate-limit.js'; import type { AuditEvent, TokenRecord } from '../../protocol.js'; /** * Resolve the bearer token on a request to a `tid`. The opaque-token * scheme means "verify" is "look up the SHA-256 hash in the store and * check expiry." A missing prefix, an unknown hash, or an expired * record all collapse to the same `auth-failed` so a probe-by-hash * leak surface is uniform. * * REVOCATION is the one status this function does read, and it is not * folded into that uniform `auth-failed` (#190). Two reasons, in this * order: * * 1. It has to be read HERE, not only by the caller, because this is * the function every ADMISSION gate routes through. The MCP * router treats a verified bearer as an admission credential: an * `initialize` carrying one is marked `admitted`, which puts the * session OUTSIDE `maxUnauthenticatedSessions` and makes it * un-evictable for an anonymous caller. Revocation was enforced * only at tool-call time, so a revoked token could not DRIVE the * app but could still buy un-evictable slots — resource admission * for a dead `tid`. "Revoked" has to mean revoked at every gate, * not only the last one. * 2. It answers `403 revoked` rather than `401 auth-failed` because * that is what this codebase already answers everywhere else the * status is read — `withLapGates`' own follow-up check below and * `acceptConnection`'s WS admission both do — and the client * surface acts on it ("paste a new snippet"). Collapsing it into * `auth-failed` would keep the leak surface marginally more * uniform at the cost of changing an established answer, and the * "oracle" it costs is only reachable by someone already holding * the token value. * * Every OTHER status (paused / pending-resume / …) remains the * caller's job — the LAP gate does its own follow-up `findByTid` for * those, and its revoked branch is kept as defence in depth (it also * covers a record that vanished between the two reads). */ export type VerifyTidOptions = { /** Wall clock in ms; injectable for tests. Defaults to `Date.now()`. */ now?: number; /** * Sliding (inactivity) TTL in ms. When set, a token whose * `lastSeenAt + slidingTtlMs` is in the past collapses to the same * `auth-failed` as a hard-expired or unknown token. Undefined / `0` * disables the check. */ slidingTtlMs?: number; }; export declare function verifyAndReadTid(req: Request, tokenStore: TokenStore, opts?: VerifyTidOptions): Promise<{ ok: true; tid: string; } | { ok: false; status: number; code: string; }>; /** * The dependency bag every LAP handler needs. Previously copy-pasted as * six identical `LapXxxDeps` aliases across the handler files; now one * type owns the shape. */ export type LapGateDeps = { tokenStore: TokenStore; registry: PairingRegistry; auditSink: AuditSink; rateLimiter: RateLimiter; now?: () => number; /** Sliding (inactivity) TTL in ms; folded into the verify path. */ slidingTtlMs?: number; }; /** * Outcome of {@link readJsonCapped}: * - `ok` — the body parsed (or was malformed JSON, surfaced as * `body: null` so callers keep their existing "invalid → 400" path * rather than throwing). * - `empty` — no body / whitespace-only. Callers that default the * body (`?? {}`) treat this the same as `{ body: null }`. * - `too-large` — the byte count crossed `maxBytes` mid-stream; the * reader was cancelled and the gate answers 413. */ export type CappedJsonResult = { status: 'ok'; body: unknown; } | { status: 'empty'; } | { status: 'too-large'; }; /** * Read a request body through a byte-counting stream reader, aborting the * moment the running total crosses `maxBytes`. This is the ONLY size gate * that holds for a chunked / `Transfer-Encoding` request: `Content-Length` * is absent there, so a `req.json()` that trusts the header would buffer * the whole payload. Streaming with a hard cap bounds memory regardless of * how the body is framed. */ export declare function readJsonCapped(req: Request, maxBytes: number): Promise; /** * Canonical JSON responder — one copy instead of the seven byte-identical * private `json()` helpers that used to live in each handler file. */ export declare function json(body: unknown, status: number): Response; /** * Request context handed to a gated handler. The auth / paused / * rate-limit gates have already run by the time the handler body sees * this, so it carries the resolved `tid` + `rec` plus the small set of * post-gate primitives (`markActive`, `touch`, `audit`) and a `finish` * terminal for the common single-audit `lap-call` shape. */ export type LapContext = { req: Request; deps: LapGateDeps; tid: string; rec: TokenRecord; /** * The request's JSON body, read ONCE by the gate through a byte-capped * reader (see {@link readJsonCapped}) so a chunked/undeclared-length * body can't bypass the size limit. `null` for an empty body or one * that failed to parse (handlers keep their existing "null → invalid" * checks). Handlers MUST read this instead of `req.json()` — the gate * has already consumed the stream, so a second read would hang/throw. */ body: unknown; /** Resolved wall-clock reader (`deps.now ?? Date.now`). */ now: () => number; /** Shared JSON responder (same as the exported `json`). */ json: (body: unknown, status: number) => Response; /** * Emit the canonical 503 `paused` response for this tid — used when a * WS drops mid-request (RPC rejects with `paused`) or the hello frame * is missing. */ paused: () => Promise; /** `awaiting-claude` → `active` transition + browser notify. */ markActive: (nowMs: number) => Promise; /** Refresh the sliding-TTL clock. */ touch: (nowMs: number) => Promise; /** Write one audit entry (uid pulled from `rec`). */ audit: (event: AuditEvent, detail: object, at: number) => Promise; /** * Terminal for the common "one lap-call audit then 200" handlers: runs * `markActive`, touches (only when `touchOn: 'completion'` — arrival * handlers already touched in the gate), writes one audit entry, and * returns `json(out, 200)`. */ finish: (out: unknown, audit?: { event?: AuditEvent; detail?: object; }) => Promise; }; export type LapGateOptions = { /** * When the sliding-TTL clock is refreshed relative to the handler body. * * - `'arrival'` — touch the moment the request lands, BEFORE the * handler runs. Required for the long-poll endpoints (`/wait`, * `/confirm-result`) which can block past `slidingTtlMs`; touching * only after they resolve would let the inactivity expiry kill an * actively-polling agent. * - `'completion'` — touch inside `ctx.finish` (or the handler's own * terminal), after the work is done. The default for the short * request/response endpoints. * * This policy difference is preserved explicitly rather than by * omission — `/wait` and `/confirm-result` pass `'arrival'`, all * others `'completion'`. */ touchOn: 'arrival' | 'completion'; }; /** * The shared LAP gate sequence: * * verifyAndReadTid → findByTid → revoked? → isPaired/paused → * rate-limit → (touch@arrival) → handler → (finish: markActive → * touch@completion → audit → json) * * Every LAP handler runs this identical prefix; only the body-parse + * work + audit-detail differ. `makeForwardHandler` already modelled the * whole thing for the simple forwards — this generalizes it so the * bespoke handlers (`/message`, `/confirm-result`, `/observe`, …) share * the same gate instead of hand-rolling it. */ export declare function withLapGates(opts: LapGateOptions, handler: (ctx: LapContext) => Promise): (req: Request, deps: LapGateDeps) => Promise; //# sourceMappingURL=gate.d.ts.map