@openparachute/vault 0.6.5-rc.2 → 0.6.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.
Files changed (70) hide show
  1. package/.parachute/module.json +1 -0
  2. package/core/src/core.test.ts +7 -7
  3. package/core/src/do-param-cap.test.ts +161 -0
  4. package/core/src/links.ts +8 -13
  5. package/core/src/mcp.ts +1 -1
  6. package/core/src/notes.ts +33 -15
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/schema.ts +5 -5
  9. package/core/src/seed-packs.test.ts +357 -0
  10. package/core/src/seed-packs.ts +823 -0
  11. package/core/src/sql-in.test.ts +58 -0
  12. package/core/src/sql-in.ts +76 -0
  13. package/core/src/tag-hierarchy.ts +1 -1
  14. package/core/src/tag-schemas.ts +2 -2
  15. package/core/src/transcription/provider.ts +141 -0
  16. package/core/src/types.ts +1 -1
  17. package/core/src/vault-projection.ts +1 -1
  18. package/core/src/wikilinks.ts +10 -4
  19. package/package.json +1 -1
  20. package/src/add-pack.test.ts +142 -0
  21. package/src/admin-spa.ts +2 -1
  22. package/src/auth.ts +1 -1
  23. package/src/cli.ts +806 -7
  24. package/src/export-watch.ts +1 -1
  25. package/src/live-frame-parity.test.ts +201 -0
  26. package/src/module-manifest.ts +8 -0
  27. package/src/onboarding-seed.test.ts +188 -40
  28. package/src/onboarding-seed.ts +41 -46
  29. package/src/routes.ts +20 -3
  30. package/src/routing.test.ts +2 -2
  31. package/src/routing.ts +18 -6
  32. package/src/self-register.test.ts +19 -0
  33. package/src/self-register.ts +6 -1
  34. package/src/server.ts +133 -31
  35. package/src/services-manifest.ts +8 -0
  36. package/src/subscriptions.ts +100 -42
  37. package/src/tag-scope.ts +3 -3
  38. package/src/test-support/live-frame-corpus.ts +72 -0
  39. package/src/token-store.ts +2 -2
  40. package/src/transcription/build.test.ts +305 -0
  41. package/src/transcription/build.ts +332 -0
  42. package/src/transcription/capability.test.ts +118 -0
  43. package/src/transcription/capability.ts +96 -0
  44. package/src/transcription/download.test.ts +101 -0
  45. package/src/transcription/download.ts +71 -0
  46. package/src/transcription/install-python.test.ts +366 -0
  47. package/src/transcription/install-python.ts +471 -0
  48. package/src/transcription/install.test.ts +167 -0
  49. package/src/transcription/install.ts +296 -0
  50. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  51. package/src/transcription/providers/onnx-asr.ts +239 -0
  52. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  53. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  54. package/src/transcription/providers/scribe-http.test.ts +195 -0
  55. package/src/transcription/providers/scribe-http.ts +144 -0
  56. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  57. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  58. package/src/transcription/select.test.ts +299 -0
  59. package/src/transcription/select.ts +397 -0
  60. package/src/transcription/tiers.test.ts +197 -0
  61. package/src/transcription/tiers.ts +184 -0
  62. package/src/transcription-worker.test.ts +44 -0
  63. package/src/transcription-worker.ts +57 -122
  64. package/src/vault-create.test.ts +43 -10
  65. package/src/vault.test.ts +49 -1
  66. package/src/ws-server.ts +408 -0
  67. package/src/ws-subscribe.test.ts +474 -0
  68. package/src/ws-subscribe.ts +242 -0
  69. package/src/subscribe.test.ts +0 -609
  70. package/src/subscribe.ts +0 -248
@@ -355,13 +355,16 @@ describe("vault create — services.json registration (#208)", () => {
355
355
  });
356
356
 
357
357
  /**
358
- * Onboarding-guide seeding on create (demo-prep Workstream A — A1/A3).
358
+ * Default-pack seeding on create (originally demo-prep Workstream A — A1/A3;
359
+ * reshaped for named seed packs).
359
360
  *
360
- * A freshly-created vault must contain the `Getting Started` + `Surface Starter`
361
- * notes so a connected AI can read them and help set the vault up. Idempotent +
362
- * best-effort: the seed never fails a create and never clobbers an edited note.
361
+ * A freshly-created vault must contain the `welcome` pack (the five-guide
362
+ * welcome ring + the capture/guide tags) and the `getting-started`
363
+ * guide and NOT the `surface-starter` pack, which is opt-in via `add-pack`
364
+ * (ratified 2026-07-02). Idempotent + best-effort: the seed never fails a
365
+ * create and never clobbers an edited note.
363
366
  */
364
- describe("vault create — onboarding guide seeding (A1/A3)", () => {
367
+ describe("vault create — default pack seeding (welcome + getting-started)", () => {
365
368
  /** Read all (path, content) rows from a created vault's SQLite DB. */
366
369
  function readNotes(name: string): { path: string | null; content: string }[] {
367
370
  const dbPath = join(home, "vault", "data", name, "vault.db");
@@ -375,7 +378,20 @@ describe("vault create — onboarding guide seeding (A1/A3)", () => {
375
378
  }
376
379
  }
377
380
 
378
- test("create seeds Getting Started + Surface Starter notes", () => {
381
+ /** Read all tag names from a created vault's SQLite DB. */
382
+ function readTagNames(name: string): string[] {
383
+ const dbPath = join(home, "vault", "data", name, "vault.db");
384
+ const db = new Database(dbPath, { readonly: true });
385
+ try {
386
+ return (db.query("SELECT name FROM tags").all() as { name: string }[]).map(
387
+ (r) => r.name,
388
+ );
389
+ } finally {
390
+ db.close();
391
+ }
392
+ }
393
+
394
+ test("create seeds the welcome web + Getting Started — and NOT Surface Starter", () => {
379
395
  const { exitCode } = runCli(["create", "guided", "--json"], {
380
396
  PARACHUTE_HOME: home,
381
397
  });
@@ -383,12 +399,29 @@ describe("vault create — onboarding guide seeding (A1/A3)", () => {
383
399
 
384
400
  const notes = readNotes("guided");
385
401
  const gs = notes.find((n) => n.path === "Getting Started");
386
- const ss = notes.find((n) => n.path === "Surface Starter");
402
+ const welcome = notes.find((n) => n.path === "Welcome to your vault 🪂");
403
+ const captureAnything = notes.find((n) => n.path === "Capture anything");
404
+ const tagsGraph = notes.find((n) => n.path === "Tags and the graph");
405
+ const connectAi = notes.find((n) => n.path === "Connect your AI");
406
+ const yoursToKeep = notes.find((n) => n.path === "Yours to keep");
387
407
  expect(gs).toBeDefined();
388
- expect(ss).toBeDefined();
408
+ expect(welcome).toBeDefined();
409
+ expect(captureAnything).toBeDefined();
410
+ expect(tagsGraph).toBeDefined();
411
+ expect(connectAi).toBeDefined();
412
+ expect(yoursToKeep).toBeDefined();
389
413
  expect(gs!.content).toContain("# Getting Started");
390
- expect(gs!.content).toContain("[[Surface Starter]]");
391
- expect(ss!.content).toContain("@openparachute/surface-client");
414
+ // Surface Starter is out of the default seed — no note, no dangling link.
415
+ expect(notes.find((n) => n.path === "Surface Starter")).toBeUndefined();
416
+ expect(gs!.content).not.toContain("[[Surface Starter]]");
417
+ expect(gs!.content).toContain("add-pack");
418
+
419
+ // The capture tag Notes requires arrives with the welcome pack, alongside
420
+ // the guide (skill-file) tag — and nothing else (fresh-vault seed = 6 notes:
421
+ // the 5-guide ring + Getting Started. The retired capture/text + capture/voice
422
+ // subtypes and the vestigial #pinned seed are no longer seeded).
423
+ expect(readTagNames("guided").sort()).toEqual(["capture", "guide"]);
424
+ expect(notes).toHaveLength(6);
392
425
  });
393
426
 
394
427
  test("seeding doesn't break --json stdout (notes seeded silently)", () => {
package/src/vault.test.ts CHANGED
@@ -1093,7 +1093,7 @@ describe("scoped MCP wrapper", async () => {
1093
1093
  close();
1094
1094
  });
1095
1095
 
1096
- // -- tag-scoped MCP wrappers (patterns/tag-scoped-tokens.md) ------------
1096
+ // -- tag-scoped MCP wrappers (docs/contracts/tag-scoped-tokens.md) ------------
1097
1097
  //
1098
1098
  // These pin the behavior of `applyTagScopeWrappers` in mcp-tools.ts: each
1099
1099
  // wrapped tool's execute() honors the auth's scoped_tags allowlist. The
@@ -6505,3 +6505,51 @@ describe("handleVault: auto_transcribe (per-vault)", async () => {
6505
6505
 
6506
6506
  });
6507
6507
 
6508
+ describe("handleVault: transcription capability (scribe-fold Phase 1)", async () => {
6509
+ test("GET surfaces transcription: { enabled, provider } when a provider is available", async () => {
6510
+ const cfg = { name: "default" } as { name: string };
6511
+ const res = await handleVault(
6512
+ mkReq("GET", "/vault"),
6513
+ store,
6514
+ cfg as any,
6515
+ undefined,
6516
+ async () => ({ enabled: true, provider: "scribe-http" }),
6517
+ );
6518
+ expect(res.status).toBe(200);
6519
+ const body = await res.json() as any;
6520
+ expect(body.transcription).toEqual({ enabled: true, provider: "scribe-http" });
6521
+ });
6522
+
6523
+ test("GET surfaces transcription.enabled=false when no provider is available (no crash)", async () => {
6524
+ const cfg = { name: "default" } as { name: string };
6525
+ const res = await handleVault(
6526
+ mkReq("GET", "/vault"),
6527
+ store,
6528
+ cfg as any,
6529
+ undefined,
6530
+ async () => ({ enabled: false }),
6531
+ );
6532
+ expect(res.status).toBe(200);
6533
+ const body = await res.json() as any;
6534
+ expect(body.transcription).toEqual({ enabled: false });
6535
+ expect(body.transcription.provider).toBeUndefined();
6536
+ });
6537
+
6538
+ test("capability is a distinct axis from the auto_transcribe policy toggle", async () => {
6539
+ // A vault with auto_transcribe.enabled=true but NO provider available still
6540
+ // reports transcription.enabled=false — the mic should be gated on
6541
+ // capability, not policy.
6542
+ const cfg = { name: "default", auto_transcribe: { enabled: true } };
6543
+ const res = await handleVault(
6544
+ mkReq("GET", "/vault"),
6545
+ store,
6546
+ cfg as any,
6547
+ undefined,
6548
+ async () => ({ enabled: false }),
6549
+ );
6550
+ const body = await res.json() as any;
6551
+ expect(body.config.auto_transcribe.enabled).toBe(true);
6552
+ expect(body.transcription.enabled).toBe(false);
6553
+ });
6554
+ });
6555
+
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Live-query WebSocket binding — the SELF-HOST (Bun.serve) integration
3
+ * (WS-hibernation migration, 2026-07-04; contract
4
+ * `parachute-cloud/workers/vault/docs/live-query-ws.md`).
5
+ *
6
+ * The cloud door runs this logic inside a per-vault Durable Object with
7
+ * Hibernatable WebSockets; the self-host door runs it in the single long-lived
8
+ * `Bun.serve` process (`server.ts`). Same wire contract, minus hibernation — a
9
+ * self-run Bun box has no per-connection duration bill, so a socket just lives
10
+ * in memory (free): no attachment / rehydration / eviction / sweep, no
11
+ * lifetime cap. Per-socket state lives on `ws.data` and is mutated in place.
12
+ *
13
+ * ## Where the pieces live
14
+ * - `ws-subscribe.ts` — the PURE half (close codes, query validation, chunked
15
+ * snapshot, message parsing, verb-rank) shared byte-shaped with cloud.
16
+ * - `subscriptions.ts` — the transport-agnostic manager + `WsSink`
17
+ * (`{ type, ...data }`), the sole live-transport sink since Phase 5.
18
+ * - this module — the Bun.serve upgrade decision + `open`/`message`/`close`
19
+ * handlers + the per-vault live-socket registry (the WS cap).
20
+ *
21
+ * ## First-message auth (fork 2, ratified)
22
+ * The socket is accepted pre-auth; the FIRST message must be
23
+ * `{"type":"auth","token"}` (browsers can't header-auth a WebSocket and the hub
24
+ * ws-bridge drops subprotocols). Auth reuses the FULL self-host auth seam by
25
+ * synthesizing a `Bearer` request — the WS token validates exactly as an HTTP
26
+ * `Authorization` header would (operator bearer / hub JWT / legacy YAML keys /
27
+ * dropped pvt_* → 401). A pending socket that never auths within ~10s is closed
28
+ * 4408. Re-auth on the open socket (client re-sends `auth` on token refresh) may
29
+ * only narrow-or-equal the granted verb (a widen → 4403); no re-snapshot.
30
+ */
31
+
32
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
33
+ import type { Store } from "../core/src/types.ts";
34
+ import type { VaultConfig } from "./config.ts";
35
+ import { readVaultConfig } from "./config.ts";
36
+ import { getVaultStore } from "./vault-store.ts";
37
+ import { authenticateVaultRequest, type AuthResult } from "./auth.ts";
38
+ import { hasScopeForVault } from "./scopes.ts";
39
+ import { expandTokenTagScope, filterNotesByTagScope } from "./tag-scope.ts";
40
+ import { buildLiveMatcher } from "./live-match.ts";
41
+ import {
42
+ subscriptionManager,
43
+ WsSink,
44
+ type SubscriptionHandle,
45
+ type SubscriptionManager,
46
+ } from "./subscriptions.ts";
47
+ import {
48
+ MAX_WS_SUBSCRIPTIONS,
49
+ WS_AUTH_DEADLINE_MS,
50
+ WS_CLOSE,
51
+ buildSnapshotFrames,
52
+ parseClientMessage,
53
+ sameTagScope,
54
+ subscriptionCapResponse,
55
+ urlFromQuery,
56
+ validateWsSubscribeQuery,
57
+ vaultVerbRank,
58
+ } from "./ws-subscribe.ts";
59
+
60
+ /** Per-connection state on `ws.data`. Mutated in place (no hibernation). */
61
+ export interface SubscribeWsData {
62
+ vaultName: string;
63
+ /** Raw query string incl. leading `?` (e.g. `?tag=watch`). */
64
+ rawQuery: string;
65
+ state: "pending" | "ready";
66
+ /** Granted scopes recorded at auth (the re-auth narrow-or-equal check). */
67
+ grantedScopes: string[];
68
+ /** Granted tag-scope (`scoped_tags`) recorded at auth. The live matcher's
69
+ * tag-scope is frozen at initial auth, so a re-auth that changes this is
70
+ * refused (4403) rather than silently keeping the old scope. */
71
+ grantedTagScope: string[] | null;
72
+ /** Manager handle once the sub is live (null while pending). */
73
+ handle: SubscriptionHandle | null;
74
+ /** Pending-auth deadline timer (cleared on auth success / close). */
75
+ authTimer: ReturnType<typeof setTimeout> | null;
76
+ }
77
+
78
+ /** Upgrade decision returned to `server.ts`'s fetch. */
79
+ export type WsUpgradeVerdict =
80
+ | { kind: "upgraded" }
81
+ | { kind: "response"; response: Response }
82
+ | { kind: "pass" };
83
+
84
+ export interface SubscribeWsDeps {
85
+ /** Resolve a vault's store. Defaults to the process-wide `getVaultStore`. */
86
+ getStore?: (vaultName: string) => Store;
87
+ /** Read a vault's config. Defaults to `readVaultConfig`. */
88
+ getVaultConfig?: (vaultName: string) => VaultConfig | null;
89
+ /** The subscription manager. Defaults to the process-wide singleton. */
90
+ manager?: SubscriptionManager;
91
+ /**
92
+ * Authenticate a synthesized Bearer request. Defaults to
93
+ * `authenticateVaultRequest` (the full self-host auth seam). Injectable so
94
+ * tests can drive every close-code path (401 → 4401, 403 → 4403) without a
95
+ * live hub JWKS.
96
+ */
97
+ authenticate?: (
98
+ req: Request,
99
+ vaultConfig: VaultConfig,
100
+ ) => Promise<{ error: Response } | AuthResult>;
101
+ /** Per-vault concurrent WS-subscription cap. Defaults to
102
+ * {@link MAX_WS_SUBSCRIPTIONS}; tests set it low to exercise the 503. */
103
+ maxSubscriptions?: number;
104
+ /** Pending-auth deadline (ms). Defaults to {@link WS_AUTH_DEADLINE_MS};
105
+ * tests set it low to exercise the 4408 close without a 10s wait. */
106
+ authDeadlineMs?: number;
107
+ }
108
+
109
+ function json(data: unknown, status = 200): Response {
110
+ return Response.json(data, { status });
111
+ }
112
+
113
+ /** True iff the request is a WebSocket upgrade. */
114
+ export function isWebSocketUpgrade(req: Request): boolean {
115
+ return (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
116
+ }
117
+
118
+ const SUBSCRIBE_PATH = /^\/vault\/([^/]+)\/api\/subscribe$/;
119
+
120
+ /**
121
+ * The self-host live-query WebSocket binding: an upgrade-decision function
122
+ * (called from `server.ts`'s fetch BEFORE the normal route pipeline) plus the
123
+ * `Bun.serve` `websocket` handlers. Both share the per-vault live-socket
124
+ * registry (closure state) that enforces the WS cap.
125
+ */
126
+ export function createSubscribeWsBinding(deps: SubscribeWsDeps = {}): {
127
+ tryUpgrade: (req: Request, server: Server<SubscribeWsData>, path: string) => WsUpgradeVerdict;
128
+ handlers: WebSocketHandler<SubscribeWsData>;
129
+ } {
130
+ const getStore = deps.getStore ?? getVaultStore;
131
+ const getVaultConfig = deps.getVaultConfig ?? readVaultConfig;
132
+ const manager = deps.manager ?? subscriptionManager;
133
+ const authenticate = deps.authenticate ?? authenticateVaultRequest;
134
+ const maxSubscriptions = deps.maxSubscriptions ?? MAX_WS_SUBSCRIPTIONS;
135
+ const authDeadlineMs = deps.authDeadlineMs ?? WS_AUTH_DEADLINE_MS;
136
+
137
+ // Per-vault live sockets (pending + ready) — the WS cap counts over these,
138
+ // mirroring the cloud door's `getWebSockets()` live count. A pending socket
139
+ // that never auths self-heals off this set via the ~10s deadline close.
140
+ const liveSockets = new Map<string, Set<ServerWebSocket<SubscribeWsData>>>();
141
+ const liveCount = (vaultName: string): number => liveSockets.get(vaultName)?.size ?? 0;
142
+ const addLiveSocket = (vaultName: string, ws: ServerWebSocket<SubscribeWsData>): void => {
143
+ let set = liveSockets.get(vaultName);
144
+ if (!set) {
145
+ set = new Set();
146
+ liveSockets.set(vaultName, set);
147
+ }
148
+ set.add(ws);
149
+ };
150
+ const removeLiveSocket = (vaultName: string, ws: ServerWebSocket<SubscribeWsData>): void => {
151
+ const set = liveSockets.get(vaultName);
152
+ if (!set) return;
153
+ set.delete(ws);
154
+ if (set.size === 0) liveSockets.delete(vaultName);
155
+ };
156
+
157
+ const clearAuthTimer = (ws: ServerWebSocket<SubscribeWsData>): void => {
158
+ if (ws.data.authTimer) {
159
+ clearTimeout(ws.data.authTimer);
160
+ ws.data.authTimer = null;
161
+ }
162
+ };
163
+
164
+ /** Drop a socket's manager subscription + registry slot (idempotent). */
165
+ const cleanupSocket = (ws: ServerWebSocket<SubscribeWsData>): void => {
166
+ clearAuthTimer(ws);
167
+ removeLiveSocket(ws.data.vaultName, ws);
168
+ if (ws.data.handle) {
169
+ ws.data.handle.close();
170
+ ws.data.handle = null;
171
+ }
172
+ };
173
+
174
+ /** Close a socket with an explicit application code, then tear down its sub
175
+ * IMMEDIATELY (so no further event fans out to the closing socket). The
176
+ * `close` handler re-runs cleanup idempotently. */
177
+ const closeWs = (ws: ServerWebSocket<SubscribeWsData>, code: number, reason: string): void => {
178
+ try {
179
+ ws.close(code, reason);
180
+ } catch {
181
+ /* already closing / closed */
182
+ }
183
+ cleanupSocket(ws);
184
+ };
185
+
186
+ const closeCodeForAuthError = (res: Response): number =>
187
+ res.status === 403 ? WS_CLOSE.FORBIDDEN : WS_CLOSE.UNAUTHORIZED;
188
+
189
+ /**
190
+ * First auth on a pending socket. On success: build the matcher + tag-scope +
191
+ * snapshot (all awaited), then SYNCHRONOUSLY flip → ready, register, and flush
192
+ * the chunked snapshot (no await between register and flush, so a deferred
193
+ * hook dispatch can't slip a live event ahead of the snapshot — the SSE
194
+ * ordering). Failure → 4401 (auth) / 4403 (scope) / 4400 (bad query).
195
+ */
196
+ async function authenticatePendingSocket(ws: ServerWebSocket<SubscribeWsData>, token: string): Promise<void> {
197
+ const { vaultName, rawQuery } = ws.data;
198
+ const vaultConfig = getVaultConfig(vaultName);
199
+ if (!vaultConfig) {
200
+ closeWs(ws, WS_CLOSE.PROTOCOL, "vault not found");
201
+ return;
202
+ }
203
+ let store: Store;
204
+ try {
205
+ store = getStore(vaultName);
206
+ } catch {
207
+ closeWs(ws, WS_CLOSE.PROTOCOL, "vault not available");
208
+ return;
209
+ }
210
+
211
+ // Reuse the FULL self-host auth seam via a synthesized Bearer request.
212
+ const authReq = new Request(`http://ws.local/vault/${vaultName}/api/subscribe${rawQuery}`, {
213
+ headers: { Authorization: `Bearer ${token}` },
214
+ });
215
+ const auth = await authenticate(authReq, vaultConfig);
216
+ if ("error" in auth) {
217
+ closeWs(ws, closeCodeForAuthError(auth.error), "auth failed");
218
+ return;
219
+ }
220
+ // Subscribe is a READ operation (admin ⊇ write ⊇ read).
221
+ if (!hasScopeForVault(auth.scopes, vaultName, "read")) {
222
+ closeWs(ws, WS_CLOSE.FORBIDDEN, "insufficient scope");
223
+ return;
224
+ }
225
+
226
+ // Query was validated at upgrade; re-validate from the stored raw string.
227
+ const validated = validateWsSubscribeQuery(urlFromQuery(rawQuery));
228
+ if ("error" in validated) {
229
+ closeWs(ws, WS_CLOSE.PROTOCOL, "invalid subscription query");
230
+ return;
231
+ }
232
+
233
+ let tagScopeAllowed: Set<string> | null;
234
+ let matcher;
235
+ let snapshotNotes;
236
+ try {
237
+ tagScopeAllowed = await expandTokenTagScope(store, auth.scoped_tags);
238
+ matcher = await buildLiveMatcher(store, validated.queryOpts);
239
+ // Snapshot = the COMPLETE scoped matching set — strip paging so snapshot ⊇ live.
240
+ const raw = await store.queryNotes({
241
+ ...validated.queryOpts,
242
+ limit: Number.MAX_SAFE_INTEGER,
243
+ offset: undefined,
244
+ });
245
+ snapshotNotes = filterNotesByTagScope(raw, tagScopeAllowed, auth.scoped_tags);
246
+ } catch {
247
+ closeWs(ws, WS_CLOSE.PROTOCOL, "snapshot query failed");
248
+ return;
249
+ }
250
+ const frames = buildSnapshotFrames(snapshotNotes);
251
+
252
+ // --- SYNCHRONOUS from here (no await) so no write interleaves between
253
+ // register and the snapshot flush.
254
+ clearAuthTimer(ws);
255
+ ws.data.state = "ready";
256
+ ws.data.grantedScopes = auth.scopes;
257
+ ws.data.grantedTagScope = auth.scoped_tags;
258
+ const handle = manager.register({
259
+ vaultName,
260
+ matcher,
261
+ tagScopeAllowed,
262
+ tagScopeRaw: auth.scoped_tags,
263
+ sink: new WsSink(ws),
264
+ tracksFlush: false,
265
+ countsTowardCap: false,
266
+ });
267
+ if (!handle) {
268
+ closeWs(ws, WS_CLOSE.PROTOCOL, "could not register subscription");
269
+ return;
270
+ }
271
+ ws.data.handle = handle;
272
+
273
+ const sink = new WsSink(ws);
274
+ for (const frame of frames) if (!sink.sendRaw(frame)) break;
275
+ }
276
+
277
+ /**
278
+ * Re-auth on an open socket (client re-sends `auth` on token refresh):
279
+ * re-validate + enforce narrow-or-equal scope (a WIDEN → 4403), then update
280
+ * the recorded scopes. NO re-snapshot, NO re-register — the matcher + socket
281
+ * are unchanged (the self-host socket lives in memory regardless of exp; the
282
+ * re-auth check exists for wire-contract congruence with the cloud door).
283
+ *
284
+ * The live matcher's TAG-scope is frozen at initial auth (not re-derived per
285
+ * message), so a re-auth whose tag-scope differs in ANY way is refused (4403).
286
+ * The client reconnects fresh → the initial-connect path re-derives scope
287
+ * correctly. This closes a WS-only per-agent-isolation gap the SSE route can't
288
+ * have (SSE reconnects re-derive scope every time). See `sameTagScope`.
289
+ */
290
+ async function reauthReadySocket(ws: ServerWebSocket<SubscribeWsData>, token: string): Promise<void> {
291
+ const { vaultName, grantedScopes, grantedTagScope, rawQuery } = ws.data;
292
+ const vaultConfig = getVaultConfig(vaultName);
293
+ if (!vaultConfig) {
294
+ closeWs(ws, WS_CLOSE.PROTOCOL, "vault not found");
295
+ return;
296
+ }
297
+ const authReq = new Request(`http://ws.local/vault/${vaultName}/api/subscribe${rawQuery}`, {
298
+ headers: { Authorization: `Bearer ${token}` },
299
+ });
300
+ const auth = await authenticate(authReq, vaultConfig);
301
+ if ("error" in auth) {
302
+ closeWs(ws, closeCodeForAuthError(auth.error), "re-auth failed");
303
+ return;
304
+ }
305
+ if (vaultVerbRank(auth.scopes, vaultName) > vaultVerbRank(grantedScopes, vaultName)) {
306
+ closeWs(ws, WS_CLOSE.FORBIDDEN, "re-auth widens scope");
307
+ return;
308
+ }
309
+ // Tag-scope delta → force a clean reconnect (which re-derives scope). A
310
+ // narrowed/revoked/differently-scoped token must NOT keep the frozen matcher.
311
+ if (!sameTagScope(auth.scoped_tags, grantedTagScope)) {
312
+ closeWs(ws, WS_CLOSE.FORBIDDEN, "re-auth changes tag scope");
313
+ return;
314
+ }
315
+ ws.data.grantedScopes = auth.scopes;
316
+ }
317
+
318
+ function tryUpgrade(req: Request, server: Server<SubscribeWsData>, path: string): WsUpgradeVerdict {
319
+ const match = path.match(SUBSCRIBE_PATH);
320
+ if (!match) return { kind: "pass" };
321
+ const vaultName = match[1]!;
322
+
323
+ // WS upgrades are GET.
324
+ if (req.method !== "GET") {
325
+ return { kind: "response", response: json({ error: "Method not allowed", message: "subscribe is GET-only" }, 405) };
326
+ }
327
+ if (!getVaultConfig(vaultName)) {
328
+ return { kind: "response", response: json({ error: "Vault not found", vault: vaultName }, 404) };
329
+ }
330
+
331
+ const url = new URL(req.url);
332
+ // Same query rejects as the SSE route (byte-identical 400 body).
333
+ const validated = validateWsSubscribeQuery(url);
334
+ if ("error" in validated) return { kind: "response", response: validated.error };
335
+
336
+ // Per-vault WS cap (over the live sockets) — pending sockets count, and
337
+ // self-heal off the set via the ~10s auth-deadline close.
338
+ if (liveCount(vaultName) >= maxSubscriptions) {
339
+ return { kind: "response", response: subscriptionCapResponse() };
340
+ }
341
+
342
+ const data: SubscribeWsData = {
343
+ vaultName,
344
+ rawQuery: url.search,
345
+ state: "pending",
346
+ grantedScopes: [],
347
+ grantedTagScope: null,
348
+ handle: null,
349
+ authTimer: null,
350
+ };
351
+ const ok = server.upgrade(req, { data });
352
+ if (ok) return { kind: "upgraded" };
353
+ // Not a valid WS handshake (missing Sec-WebSocket-* headers).
354
+ return {
355
+ kind: "response",
356
+ response: json({ error: "Expected a WebSocket upgrade request" }, 426),
357
+ };
358
+ }
359
+
360
+ const handlers: WebSocketHandler<SubscribeWsData> = {
361
+ open(ws) {
362
+ addLiveSocket(ws.data.vaultName, ws);
363
+ // Pending-auth deadline: an accepted socket that never auths is closed
364
+ // 4408. `unref` so the timer never holds the process (or a test) open.
365
+ const timer = setTimeout(() => {
366
+ if (ws.data.state === "pending") closeWs(ws, WS_CLOSE.AUTH_TIMEOUT, "auth timeout");
367
+ }, authDeadlineMs);
368
+ (timer as { unref?: () => void }).unref?.();
369
+ ws.data.authTimer = timer;
370
+ },
371
+
372
+ async message(ws, message) {
373
+ // Client-driven liveness: the literal `ping` string → `pong`. Never JSON,
374
+ // so it short-circuits before `parseClientMessage`.
375
+ if (typeof message === "string" && message === "ping") {
376
+ try {
377
+ ws.send("pong");
378
+ } catch {
379
+ /* socket gone */
380
+ }
381
+ return;
382
+ }
383
+
384
+ const parsed = parseClientMessage(message as string | Uint8Array);
385
+ if (parsed.kind === "malformed") {
386
+ closeWs(ws, WS_CLOSE.PROTOCOL, "malformed message");
387
+ return;
388
+ }
389
+
390
+ if (ws.data.state === "pending") {
391
+ if (parsed.kind !== "auth") {
392
+ closeWs(ws, WS_CLOSE.PROTOCOL, "expected auth message");
393
+ return;
394
+ }
395
+ await authenticatePendingSocket(ws, parsed.token);
396
+ } else if (parsed.kind === "auth") {
397
+ await reauthReadySocket(ws, parsed.token);
398
+ }
399
+ // Any other message on a ready socket: ignored (forward-compat).
400
+ },
401
+
402
+ close(ws) {
403
+ cleanupSocket(ws);
404
+ },
405
+ };
406
+
407
+ return { tryUpgrade, handlers };
408
+ }