@openparachute/agent 0.2.3-rc.4 → 0.2.3-rc.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/agent",
3
- "version": "0.2.3-rc.4",
3
+ "version": "0.2.3-rc.5",
4
4
  "description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
package/src/auth.ts CHANGED
@@ -7,13 +7,26 @@
7
7
  * - Layer 1 (bridge / session↔channel): the bridge presents the token as an
8
8
  * `Authorization: Bearer` header on `/events` + `/api/*`.
9
9
  * - Layer 2 (human / chat UI): the page fetches a short-lived token from the
10
- * hub (`/admin/agent-token`) and attaches it as a Bearer header on the
11
- * `send` POST, and as a `?token=` query param on the `/ui/events` SSE
12
- * (EventSource can't set headers).
10
+ * hub (`/admin/agent-token`) and attaches it as a Bearer header on the
11
+ * `send` POST. For the browser SSE streams (`/ui/events`,
12
+ * `/api/channels/<ch>/turn-events`) — which an `EventSource` can't set a
13
+ * header on — the page does NOT put the JWT in the URL. Instead it mints a
14
+ * one-time SSE TICKET (`POST /api/ui/sse-ticket`, Bearer-authenticated) and
15
+ * opens `…?ticket=<nonce>`. See `requireSseTicket` below + `ui-ticket.ts`.
13
16
  *
14
- * `requireScope` accepts the token from EITHER source so one helper guards both
15
- * layers. The no-token path short-circuits before any JWKS fetch, keeping it
16
- * unit-testable without a live hub (same approach Layer 1 used).
17
+ * `requireScope` accepts the token from a Bearer header (and, for the
18
+ * agent:admin terminal WebSocket only, a `?token=` query param). The no-token
19
+ * path short-circuits before any JWKS fetch, keeping it unit-testable without a
20
+ * live hub (same approach Layer 1 used).
21
+ *
22
+ * WHY THE TICKET (agent#25). A full hub JWT in a `?token=` URL lands in any
23
+ * access/proxy log, browser history, or network trace — a credential leak
24
+ * mitigated before only by the token's short TTL. The browser SSE endpoints now
25
+ * trade the JWT for an opaque, single-use, ≤60s ticket (`requireSseTicket`); the
26
+ * JWT only ever travels in a `fetch` Bearer header. The legacy `?token=` SSE
27
+ * path was REMOVED (pre-1.0, no deprecation window). The terminal WebSocket
28
+ * (`agent:admin`) still uses `?token=` — a separate, operator-gated mechanism
29
+ * out of this change's scope.
17
30
  *
18
31
  * DUAL-ACCEPT (channel→agent rename transition,
19
32
  * `parachute-patterns/migrations/2026-06-17-channel-to-agent.md` rule 1). New
@@ -26,6 +39,7 @@
26
39
 
27
40
  import { validateHubJwt, HubJwtError } from "./hub-jwt.ts";
28
41
  import { extractBearer } from "@openparachute/scope-guard";
42
+ import { consumeTicket } from "./ui-ticket.ts";
29
43
 
30
44
  /** Agent scopes, declared here so callers share one spelling. */
31
45
  export const SCOPE_READ = "agent:read" as const;
@@ -79,15 +93,18 @@ export function json(data: unknown, status = 200): Response {
79
93
 
80
94
  /**
81
95
  * Extract a presented token from a request: the `Authorization: Bearer` header
82
- * first (the bridge + the UI's POST), falling back to a `?token=` query param
83
- * (the SSE case `EventSource` can't set headers). Returns null if neither is
84
- * present.
96
+ * first (the bridge, the UI's POST, the SSE-ticket mint), falling back to a
97
+ * `?token=` query param only when `allowQueryParam` is set. The ONLY caller that
98
+ * opts into the query param is the agent:admin terminal WebSocket
99
+ * (`new WebSocket()` can't set headers); the browser SSE streams moved to the
100
+ * one-time-ticket path (`requireSseTicket`) so a JWT never rides in a URL. Returns
101
+ * null if neither source is present.
85
102
  */
86
103
  export function extractToken(req: Request, url: URL, allowQueryParam = false): string | null {
87
104
  const bearer = extractBearer(req.headers.get("authorization"));
88
105
  if (bearer) return bearer;
89
- // `?token=` is opt-in (the SSE case only). The bridge + the UI POST present a
90
- // Bearer header, so they never enable it — keeps query-param tokens off every
106
+ // `?token=` is opt-in (the terminal WebSocket only). Every other caller presents
107
+ // a Bearer header, so they leave it false keeping query-param JWTs off every
91
108
  // endpoint that doesn't strictly need them (and out of those access logs).
92
109
  if (allowQueryParam) {
93
110
  const q = url.searchParams.get("token");
@@ -99,9 +116,10 @@ export function extractToken(req: Request, url: URL, allowQueryParam = false): s
99
116
  /**
100
117
  * Guard an HTTP endpoint on a hub-issued JWT carrying `scope`. The token arrives
101
118
  * as an `Authorization: Bearer` header; pass `allowQueryParam: true` to also
102
- * accept a `?token=` query param (the SSE case only — `EventSource` can't set
103
- * headers). Bridge + UI-POST callers leave it false, so query-param tokens are
104
- * confined to the one endpoint that needs them.
119
+ * accept a `?token=` query param (the agent:admin terminal WebSocket only —
120
+ * `new WebSocket()` can't set headers). All other callers leave it false, so
121
+ * query-param JWTs are confined to that one endpoint. Browser SSE streams use
122
+ * `requireSseTicket` (the one-time ticket), not this.
105
123
  *
106
124
  * Returns `null` when the request is authorized (caller proceeds), or a
107
125
  * `Response` (401/403) the caller must return as-is.
@@ -138,3 +156,74 @@ export async function requireScope(
138
156
  );
139
157
  }
140
158
  }
159
+
160
+ /**
161
+ * Mint endpoint for a one-time SSE ticket (agent#25). Authenticate the presented
162
+ * Bearer JWT for `scope` (the SAME validation `requireScope` runs — no-token →
163
+ * 401 pre-JWKS, bad/insufficient → 401/403), then issue a single-use, ≤60s
164
+ * opaque ticket carrying ONLY the token's validated scopes. The ticket — never
165
+ * the JWT — goes in the SSE URL. Returns the mint `Response` (200 `{ ticket,
166
+ * expires_at }`, or the gate's 401/403) for the caller to return as-is.
167
+ *
168
+ * `mintTicket` is injected (defaults to the real `ui-ticket.ts` store) so unit
169
+ * tests can assert what scopes get carried without reaching into the singleton.
170
+ * Critically, an UNAUTHENTICATED mint is impossible: the scope gate runs first
171
+ * and short-circuits before any ticket is created — minting without a valid
172
+ * bearer would be an auth bypass.
173
+ */
174
+ export async function mintSseTicket(
175
+ req: Request,
176
+ url: URL,
177
+ scope: string,
178
+ mint: (scopes: readonly string[]) => { ticket: string; expiresAt: number },
179
+ ): Promise<Response> {
180
+ const token = extractToken(req, url); // Bearer header ONLY — never a query param.
181
+ if (!token) {
182
+ return json({ error: "unauthorized", message: "Bearer token required" }, 401);
183
+ }
184
+ let scopes: string[];
185
+ try {
186
+ const claims = await validateHubJwt(token);
187
+ if (!grantsScope(claims.scopes, scope)) {
188
+ return json(
189
+ { error: "insufficient_scope", message: `requires ${scope}`, granted: claims.scopes },
190
+ 403,
191
+ );
192
+ }
193
+ // Carry the token's OWN validated scopes — never widen beyond what it holds.
194
+ scopes = claims.scopes;
195
+ } catch (err) {
196
+ return json(
197
+ { error: "unauthorized", message: err instanceof HubJwtError ? err.message : "invalid token" },
198
+ 401,
199
+ );
200
+ }
201
+ const { ticket, expiresAt } = mint(scopes);
202
+ return json({ ticket, expires_at: new Date(expiresAt).toISOString() });
203
+ }
204
+
205
+ /**
206
+ * Guard a browser SSE endpoint on a one-time `?ticket=<nonce>` (agent#25 — the
207
+ * EventSource auth path that replaced the leaky `?token=<JWT>`). Look up + CONSUME
208
+ * the ticket (single-use: a second connect 401s), then assert the ticket's carried
209
+ * scopes include `scope` (the ticket can never authorize more than the JWT that
210
+ * minted it — `mintSseTicket` stored exactly that JWT's scopes). Returns `null`
211
+ * when authorized (caller opens the stream) or a 401 `Response` to return as-is.
212
+ *
213
+ * No JWKS fetch on this path — the JWT was validated at MINT time and its scopes
214
+ * captured in the ticket; consume is a pure in-memory lookup. So an absent /
215
+ * expired / already-used / under-scoped ticket all map to 401 with no network I/O.
216
+ */
217
+ export function requireSseTicket(url: URL, scope: string): Response | null {
218
+ const consumed = consumeTicket(url.searchParams.get("ticket"));
219
+ if (!consumed) {
220
+ return json({ error: "unauthorized", message: "valid one-time SSE ticket required" }, 401);
221
+ }
222
+ if (!grantsScope(consumed.scopes, scope)) {
223
+ return json(
224
+ { error: "insufficient_scope", message: `ticket lacks ${scope}`, granted: consumed.scopes },
225
+ 403,
226
+ );
227
+ }
228
+ return null;
229
+ }
package/src/daemon.ts CHANGED
@@ -85,6 +85,8 @@ import { ClientRegistry, sseFrame } from "./routing.ts";
85
85
  import { DeliveryState } from "./delivery-state.ts";
86
86
  import {
87
87
  requireScope,
88
+ mintSseTicket,
89
+ requireSseTicket,
88
90
  extractToken,
89
91
  json as authJson,
90
92
  SCOPE_READ,
@@ -93,6 +95,7 @@ import {
93
95
  SCOPE_ADMIN,
94
96
  SCOPE_TERMINAL,
95
97
  } from "./auth.ts";
98
+ import { mintTicket } from "./ui-ticket.ts";
96
99
  import {
97
100
  createTerminalWsHandlers,
98
101
  type TerminalWsData,
@@ -1124,8 +1127,13 @@ function redirect(location: string): Response {
1124
1127
  // is `agent:write`.
1125
1128
  //
1126
1129
  // Layer 2 — human / chat UI — gates the http-ui transport's `send` (POST,
1127
- // `agent:send`) + `/ui/events` SSE (`?token=` query, `agent:read`) inside
1128
- // `http-ui.ts`'s ingestHttp using the same `requireScope`.
1130
+ // `agent:send`, Bearer) with `requireScope`. The browser SSE streams
1131
+ // (`/ui/events`, `/api/channels/<ch>/turn-events`, `agent:read`) gate on a
1132
+ // ONE-TIME ticket (`requireSseTicket`) instead of a `?token=<JWT>` query —
1133
+ // `EventSource` can't set a header, and a JWT in a URL leaks into access logs
1134
+ // (agent#25). The page mints the ticket at `POST /api/ui/sse-ticket` (Bearer,
1135
+ // agent:read) and opens `…?ticket=<nonce>`; the ticket is single-use + ≤60s and
1136
+ // carries only the minting token's scopes.
1129
1137
  //
1130
1138
  // Discovery + the page itself (/health, /.parachute/config[/schema], /ui) stay
1131
1139
  // OPEN — non-sensitive, and /ui must load to bootstrap its token fetch.
@@ -2745,8 +2753,21 @@ export function createFetchHandler(
2745
2753
  return json({ ok: true, reloaded: result });
2746
2754
  }
2747
2755
 
2756
+ // One-time SSE ticket mint — POST /api/ui/sse-ticket (agent#25). The chat
2757
+ // page can't put its hub JWT in an EventSource URL without leaking it into
2758
+ // access logs, so it trades the JWT (presented HERE as a Bearer header — no
2759
+ // leak) for a single-use, ≤60s opaque ticket it puts in the SSE URL instead.
2760
+ // Bearer-gated on `agent:read` (the scope both browser SSE streams require);
2761
+ // the minted ticket carries ONLY the token's own validated scopes, so it can
2762
+ // never authorize more than the JWT did. An unauthenticated mint is impossible
2763
+ // — `mintSseTicket` runs the scope gate before issuing anything. Returns
2764
+ // `{ ticket, expires_at }`. Externally `<hub>/agent/api/ui/sse-ticket`.
2765
+ if (req.method === "POST" && url.pathname === "/api/ui/sse-ticket") {
2766
+ return mintSseTicket(req, url, SCOPE_READ, mintTicket);
2767
+ }
2768
+
2748
2769
  // Turn-event SSE — GET /api/channels/<ch>/turn-events (chat-facing; gated on
2749
- // `agent:read`, same scope as the transcript poll + /ui/events). The streaming
2770
+ // a one-time SSE ticket carrying `agent:read`). The streaming
2750
2771
  // view (design 2026-06-16 build item #1): the chat subscribes here to watch a
2751
2772
  // PROGRAMMATIC turn work in real time — interim assistant text + tool_use, then a
2752
2773
  // done/error lifecycle event. EPHEMERAL by design: no backlog/replay (the durable
@@ -2758,11 +2779,12 @@ export function createFetchHandler(
2758
2779
  {
2759
2780
  const turnMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/turn-events$/);
2760
2781
  if (req.method === "GET" && turnMatch) {
2761
- // allowQueryParam=true: this SSE is consumed by a browser EventSource, which
2762
- // cannot set an Authorization headerit authenticates via ?token=. Without
2763
- // this the live-streaming view 401s in the browser and never connects. (The
2764
- // stdio-bridge /events SSE uses a Bearer header, so it doesn't need this.)
2765
- const denied = await requireScope(req, url, SCOPE_READ, true);
2782
+ // Browser EventSource can't set an Authorization header, so this SSE
2783
+ // authenticates via a one-time `?ticket=<nonce>` (agent#25) minted by
2784
+ // POST /api/ui/sse-ticket (Bearer-gated) and consumed single-use here. The
2785
+ // hub JWT never rides in this URL. (The stdio-bridge /events SSE uses a
2786
+ // Bearer header, so it never needed a query credential at all.)
2787
+ const denied = requireSseTicket(url, SCOPE_READ);
2766
2788
  if (denied) return denied;
2767
2789
  const channelName = decodeURIComponent(turnMatch[1]!);
2768
2790
  const clientId = crypto.randomUUID();
@@ -16,8 +16,9 @@
16
16
  * to them (mirroring the daemon's `/events` SSE pattern for bridges).
17
17
  *
18
18
  * It owns two HTTP surfaces via `ingestHttp` (scoped to ITS OWN channel name):
19
- * 1. POST /api/channels/<name>/send — body {text} → ctx.emit(...) → {ok:true}
20
- * 2. GET /ui/events?channel=<name> — SSE stream the browser subscribes to
19
+ * 1. POST /api/channels/<name>/send — body {text} → ctx.emit(...) → {ok:true}
20
+ * 2. GET /ui/events?channel=<name>&ticket= — SSE stream the browser subscribes to
21
+ * (one-time ticket auth — agent#25; the JWT never rides in this URL)
21
22
  * The static `/ui` chat page itself is global and served by the daemon, since
22
23
  * it's a channel picker across all http-ui channels.
23
24
  */
@@ -30,7 +31,7 @@ import type {
30
31
  PermissionArgs,
31
32
  } from "../transport.ts";
32
33
  import { sseFrame } from "../routing.ts";
33
- import { requireScope, json, SCOPE_SEND, SCOPE_READ } from "../auth.ts";
34
+ import { requireScope, requireSseTicket, json, SCOPE_SEND, SCOPE_READ } from "../auth.ts";
34
35
 
35
36
  /** A connected browser SSE client (one per open chat page on this channel). */
36
37
  interface UiClient {
@@ -168,11 +169,12 @@ export class HttpUiTransport implements Transport {
168
169
  url.pathname === "/ui/events" &&
169
170
  url.searchParams.get("channel") === channel
170
171
  ) {
171
- // Layer 2: gate on `agent:read`. EventSource can't set headers, so the
172
- // token rides in as a `?token=` query param the ONLY endpoint that opts
173
- // into the query-param fallback (allowQueryParam: true). No-token → 401
174
- // before the stream opens.
175
- const denied = await requireScope(req, url, SCOPE_READ, true);
172
+ // Layer 2: EventSource can't set headers, so this gates on a one-time
173
+ // `?ticket=<nonce>` (agent#25)minted by POST /api/ui/sse-ticket
174
+ // (Bearer-gated) and consumed single-use here, carrying `agent:read`. The
175
+ // hub JWT never appears in this URL (the leak the ticket closes). Absent /
176
+ // expired / already-used ticket 401 before the stream opens.
177
+ const denied = requireSseTicket(url, SCOPE_READ);
176
178
  if (denied) return denied;
177
179
  const clientId = crypto.randomUUID();
178
180
  const clients = this.uiClients;
package/src/ui-kit.ts CHANGED
@@ -376,9 +376,12 @@ export const SHELL_JS = `
376
376
  el.className = "app-status" + (kind ? " " + kind : "");
377
377
  }
378
378
  // Hub-minted agent token (cookie-gated to the logged-in operator). Cached on
379
- // window.__token; pages attach it as a Bearer header and/or ?token= param.
380
- // (Endpoint renamed /admin/channel-token /admin/agent-token in the
381
- // channel→agent rename; the hub 301-redirects the old path for old bookmarks.)
379
+ // window.__token; pages attach it as a Bearer header. (Browser SSE auth moved
380
+ // off the query-param token to a one-time ticket in agent#25, so the JWT never
381
+ // lands in a URL; this template is the retired server-rendered shell, superseded
382
+ // by the SPA.) Endpoint renamed /admin/channel-token to /admin/agent-token in
383
+ // the channel-to-agent rename; the hub 301-redirects the old path for old
384
+ // bookmarks.
382
385
  function fetchToken() {
383
386
  return fetch(window.location.origin + "/admin/agent-token", { credentials: "include" })
384
387
  .then(function (r) { if (!r.ok) throw new Error("token " + r.status); return r.json(); })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * One-time SSE tickets for the browser EventSource auth path (Layer 2, human↔UI).
3
+ *
4
+ * THE LEAK THIS CLOSES. A browser `EventSource` can't set an `Authorization`
5
+ * header, so the agent SPA used to put the hub JWT directly in the SSE URL
6
+ * (`/ui/events?token=<JWT>`, `/api/channels/<ch>/turn-events?token=<JWT>`). A
7
+ * full bearer JWT in a URL lands in any access log, proxy log, browser history,
8
+ * or network trace — mitigated before only by the token's ~10min TTL. That's a
9
+ * credential-in-a-URL leak.
10
+ *
11
+ * THE FIX. Trade the JWT for an opaque, single-use, very-short-lived TICKET that
12
+ * goes in the URL instead. The SPA presents its bearer JWT to a normal
13
+ * authenticated endpoint (a Bearer header on a `fetch`, no leak), which mints a
14
+ * ticket: a crypto-random 256-bit nonce (base64url) stored ONLY server-side in
15
+ * this TTL'd map, carrying the validated scope(s)/audience of the presenting
16
+ * token. The SPA opens `/ui/events?ticket=<nonce>`; the SSE consume path looks
17
+ * the nonce up, CONSUMES it (deletes immediately — single-use), and establishes
18
+ * the stream with the ticket's scopes. The JWT never appears in a URL or log.
19
+ *
20
+ * SECURITY PROPERTIES (all load-bearing):
21
+ * - Unguessable: 32 random bytes (256 bits) from `crypto.getRandomValues`,
22
+ * base64url-encoded. Far above the issue's 128-bit floor.
23
+ * - Single-use: `consume` DELETES the entry before returning, so a replayed
24
+ * ticket (a second connect, or a stolen URL) finds nothing → 401.
25
+ * - Short TTL: default 60s — just long enough to open the connection. An
26
+ * expired entry is treated as absent (and lazily pruned).
27
+ * - No scope widening: the ticket stores EXACTLY the scopes the minting token
28
+ * presented (validated upstream by `requireScope` before `mint` is called).
29
+ * The consume path asserts the required scope against the stored set, so a
30
+ * ticket can never authorize more than the JWT that minted it.
31
+ *
32
+ * This is process-local in-memory state by design (mirrors the daemon's other
33
+ * in-process registries). The daemon is single-instance per machine; tickets
34
+ * live ≤60s and are cheap to lose on restart (the SPA just re-mints). A
35
+ * module-level singleton is used because the two consume paths live in different
36
+ * modules (`http-ui.ts`'s `ingestHttp` and the daemon's turn-events route) and
37
+ * both must hit the SAME store — `ingestHttp(req, url)` has no place to thread an
38
+ * instance through. `_resetTicketsForTest` isolates unit tests.
39
+ */
40
+
41
+ /** A minted ticket's server-side record. Never leaves the process. */
42
+ interface TicketRecord {
43
+ /** The validated scopes carried from the minting JWT (the ceiling — never widened). */
44
+ scopes: string[];
45
+ /** Epoch ms after which the ticket is expired (treated as absent). */
46
+ expiresAt: number;
47
+ }
48
+
49
+ /** Default ticket lifetime — long enough to open an EventSource, no longer. */
50
+ export const TICKET_TTL_MS = 60_000;
51
+
52
+ /** Nonce entropy: 32 bytes = 256 bits, well above the 128-bit floor. */
53
+ const TICKET_BYTES = 32;
54
+
55
+ /** The process-local ticket store. nonce → record. */
56
+ const tickets = new Map<string, TicketRecord>();
57
+
58
+ /** base64url-encode bytes (no padding) — URL-safe, no `+`/`/`/`=`. */
59
+ function base64url(bytes: Uint8Array): string {
60
+ let bin = "";
61
+ for (const b of bytes) bin += String.fromCharCode(b);
62
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
63
+ }
64
+
65
+ /**
66
+ * Mint a single-use ticket carrying `scopes` (a COPY of the validated scopes from
67
+ * the presenting JWT — the caller must have already authenticated + scope-checked
68
+ * the token, so this never widens authority). Returns the opaque nonce + its
69
+ * absolute expiry. TTL defaults to {@link TICKET_TTL_MS}.
70
+ */
71
+ export function mintTicket(scopes: readonly string[], ttlMs = TICKET_TTL_MS): {
72
+ ticket: string;
73
+ expiresAt: number;
74
+ } {
75
+ pruneExpiredTickets();
76
+ const bytes = new Uint8Array(TICKET_BYTES);
77
+ crypto.getRandomValues(bytes);
78
+ const ticket = base64url(bytes);
79
+ const expiresAt = Date.now() + ttlMs;
80
+ tickets.set(ticket, { scopes: [...scopes], expiresAt });
81
+ return { ticket, expiresAt };
82
+ }
83
+
84
+ /**
85
+ * Consume a ticket: look it up, and if present + unexpired, DELETE it (single-use)
86
+ * and return its scopes. Returns `null` for an absent / expired / already-consumed
87
+ * ticket — the caller maps that to a 401. Deletion happens before return, so two
88
+ * concurrent consumes of the same nonce can't both succeed.
89
+ */
90
+ export function consumeTicket(ticket: string | null | undefined): { scopes: string[] } | null {
91
+ if (!ticket) return null;
92
+ const rec = tickets.get(ticket);
93
+ if (!rec) return null;
94
+ // Single-use: remove FIRST, so even an expired hit can't be retried and a
95
+ // concurrent second consume finds nothing.
96
+ tickets.delete(ticket);
97
+ if (Date.now() >= rec.expiresAt) return null;
98
+ return { scopes: rec.scopes };
99
+ }
100
+
101
+ /**
102
+ * Drop every expired ticket. Called opportunistically on each mint so the map
103
+ * can't grow unbounded if some tickets are never consumed; not on a timer (no
104
+ * background work in a possibly-idle daemon). O(n) over a map that's tiny in
105
+ * practice (≤ a handful of live tickets at 60s TTL).
106
+ */
107
+ export function pruneExpiredTickets(now = Date.now()): void {
108
+ for (const [k, rec] of tickets) {
109
+ if (now >= rec.expiresAt) tickets.delete(k);
110
+ }
111
+ }
112
+
113
+ /** Test seam: clear all tickets so unit tests start from a clean store. */
114
+ export function _resetTicketsForTest(): void {
115
+ tickets.clear();
116
+ }
117
+
118
+ /** Test seam: the current live ticket count (asserts single-use deletion). */
119
+ export function _ticketCountForTest(): number {
120
+ return tickets.size;
121
+ }