@openparachute/vault 0.6.5-rc.11 → 0.6.5-rc.13

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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Live-query WebSocket binding — the SELF-HOST (Bun) transport for
3
+ * `GET /vault/<name>/api/subscribe` with an `Upgrade: websocket` header
4
+ * (WS-hibernation migration, 2026-07-04; decision
5
+ * `Decisions/2026-07-04-live-query-ws-hibernation`, contract
6
+ * `parachute-cloud/workers/vault/docs/live-query-ws.md`).
7
+ *
8
+ * This module is the PURE, transport-agnostic half — close codes, query
9
+ * validation (the SAME rejects as the SSE route), chunked-snapshot framing,
10
+ * first-message-auth parsing, and the re-auth verb-rank check. The Bun.serve
11
+ * integration (accept / open / message / close, the per-vault socket registry
12
+ * + cap, the auth deadline) lives in `ws-server.ts`; it needs the running
13
+ * `server` + the live sockets.
14
+ *
15
+ * ## Why WS on self-host (no hibernation)
16
+ * Both doors speak ONE subscription contract. The cloud door adds Cloudflare
17
+ * Hibernatable WebSockets (an idle-but-open socket evicts the DO → ~$0 idle);
18
+ * the self-host door does NOT — a self-run Bun box has no per-connection
19
+ * duration bill, so the socket simply lives in memory (free). No
20
+ * attachment / rehydration / eviction / sweep, no lifetime cap. The wire
21
+ * contract — message `type` discriminators, first-message auth, close codes
22
+ * 4400/4401/4403/4408, client-driven ping/pong, chunked snapshot `done`
23
+ * flag, inner payload byte-identical to the SSE `data:` body — is identical.
24
+ *
25
+ * ## First-message auth (design fork 2, ratified)
26
+ * Browsers can't set headers on a WebSocket and the hub ws-bridge drops
27
+ * subprotocols, so auth is the FIRST socket message (`{"type":"auth","token"}`).
28
+ * The socket is accepted pre-auth (bounded by the per-vault cap + a ~10s
29
+ * pending-auth deadline) and no data flows until auth succeeds. Close codes are
30
+ * visible to browser JS: 4400 protocol, 4401 unauthorized/expired/revoked,
31
+ * 4403 scope, 4408 auth-timeout.
32
+ */
33
+ import type { Note, QueryOpts } from "../core/src/types.ts";
34
+ import { parseNotesQueryOpts } from "./routes.ts";
35
+ import { unsupportedSubscriptionReason } from "./live-match.ts";
36
+ import { hasScopeForVault, type VaultVerb } from "./scopes.ts";
37
+
38
+ /** Per-vault concurrent WS-subscription cap (mirrors the SSE cap). Enforced at
39
+ * upgrade via the live-socket count — over it → 503.
40
+ *
41
+ * This cap is SEPARATE from the SSE route's per-vault manager cap (also 100) —
42
+ * WS subs register with `countsTowardCap:false`, so worst case a vault holds up
43
+ * to 200 concurrent live connections (100 SSE + 100 WS). Intentional on
44
+ * self-host (no per-connection billing, so no reason to contend one budget);
45
+ * the two caps converge to one when SSE retires (migration Phase 5). */
46
+ export const MAX_WS_SUBSCRIPTIONS = 100;
47
+
48
+ /** A pending (accepted-but-unauthed) socket must send `{type:"auth"}` within
49
+ * this window; the deadline timer closes stragglers (4408). Self-host uses a
50
+ * plain per-socket timer — there is no hibernation to defeat with it. */
51
+ export const WS_AUTH_DEADLINE_MS = 10_000;
52
+
53
+ /** Conservative per-frame byte budget for chunked snapshots — well under the
54
+ * ~1 MiB WebSocket message ceiling. A single note larger than this still ships
55
+ * as its own one-note chunk (documented residual — note bodies are markdown,
56
+ * ~never > 1 MiB). Matched to the cloud door so a snapshot chunks identically. */
57
+ export const SNAPSHOT_CHUNK_BYTES = 512 * 1024;
58
+
59
+ /** Secondary snapshot-chunk bound (note count) so many tiny notes still split. */
60
+ export const SNAPSHOT_CHUNK_MAX_NOTES = 200;
61
+
62
+ /** Application close codes (3000–4999 app-defined range), surfaced to browser JS
63
+ * so surface-client can branch (Phase 2). Byte-identical to the cloud door. */
64
+ export const WS_CLOSE = {
65
+ /** Malformed frame / protocol violation / non-auth first message. */
66
+ PROTOCOL: 4400,
67
+ /** Auth failed, token expired, or revoked. */
68
+ UNAUTHORIZED: 4401,
69
+ /** Scope mismatch, or a re-auth that would WIDEN scope. */
70
+ FORBIDDEN: 4403,
71
+ /** Accepted socket never authed within {@link WS_AUTH_DEADLINE_MS}. */
72
+ AUTH_TIMEOUT: 4408,
73
+ } as const;
74
+
75
+ function json(data: unknown, status = 200): Response {
76
+ return Response.json(data, { status });
77
+ }
78
+
79
+ /**
80
+ * Validate a subscribe query for the WS path — the SAME rejects as the SSE
81
+ * route (`subscribe.ts`): `search` / `near` / `cursor` / `has_links` / date
82
+ * filters can't be evaluated against a single changed note. The 400 bodies +
83
+ * `UNSUPPORTED_SUBSCRIPTION_QUERY` code are byte-identical to the SSE route (and
84
+ * to the cloud door) so all three agree. Returns a ready 400 Response on
85
+ * rejection, else the parsed `QueryOpts`.
86
+ */
87
+ export function validateWsSubscribeQuery(url: URL): { error: Response } | { queryOpts: QueryOpts } {
88
+ if (url.searchParams.get("search")) {
89
+ return {
90
+ error: json(
91
+ {
92
+ error:
93
+ "search (full-text) is not supported for live subscriptions — FTS can't be evaluated against a single changed note. Drop `search` or poll GET /notes?search=.",
94
+ code: "UNSUPPORTED_SUBSCRIPTION_QUERY",
95
+ },
96
+ 400,
97
+ ),
98
+ };
99
+ }
100
+ if (url.searchParams.get("near[note_id]")) {
101
+ return {
102
+ error: json(
103
+ {
104
+ error:
105
+ "near (graph neighborhood) is not supported for live subscriptions — BFS can't be evaluated against a single changed note. Drop `near` or poll GET /notes?near[note_id]=.",
106
+ code: "UNSUPPORTED_SUBSCRIPTION_QUERY",
107
+ },
108
+ 400,
109
+ ),
110
+ };
111
+ }
112
+
113
+ const parsed = parseNotesQueryOpts(url);
114
+ if (parsed.error) return { error: parsed.error };
115
+ const queryOpts = parsed.queryOpts!;
116
+
117
+ const unsupported = unsupportedSubscriptionReason(queryOpts);
118
+ if (unsupported) {
119
+ return { error: json({ error: unsupported, code: "UNSUPPORTED_SUBSCRIPTION_QUERY" }, 400) };
120
+ }
121
+ return { queryOpts };
122
+ }
123
+
124
+ /** 503 body for the per-vault WS-subscription cap (mirrors the SSE 503). */
125
+ export function subscriptionCapResponse(): Response {
126
+ return json(
127
+ {
128
+ error:
129
+ "subscription cap reached for this vault — too many concurrent live subscriptions. Retry shortly or close idle streams.",
130
+ code: "SUBSCRIPTION_CAP_REACHED",
131
+ },
132
+ 503,
133
+ );
134
+ }
135
+
136
+ /** Reconstruct a URL carrying `q` (the raw stored query string incl. leading
137
+ * `?`) so the shared parser can run. Host/path are irrelevant — only
138
+ * `searchParams` is read. */
139
+ export function urlFromQuery(q: string): URL {
140
+ const u = new URL("https://ws.local/vault/_/api/subscribe");
141
+ u.search = q ?? "";
142
+ return u;
143
+ }
144
+
145
+ /**
146
+ * Frame a snapshot into one or more `{type:"snapshot", notes, done}` messages,
147
+ * each under the WS message ceiling. ALWAYS emits at least one frame (an empty
148
+ * set → a single `done:true` frame) so the client's snapshot handler fires
149
+ * exactly once per (re)connect. The consumer concatenates `notes` across frames
150
+ * until `done:true`, then replaces its set (the SSE self-correcting-reconnect
151
+ * semantics). Byte-shaped identically to the cloud door's `buildSnapshotFrames`.
152
+ */
153
+ export function buildSnapshotFrames(notes: Note[]): string[] {
154
+ const frames: string[] = [];
155
+ const enc = new TextEncoder();
156
+ let batch: Note[] = [];
157
+ let batchBytes = 0;
158
+ for (const note of notes) {
159
+ const noteBytes = enc.encode(JSON.stringify(note)).byteLength + 1; // +1 ~ comma
160
+ if (batch.length > 0 && (batchBytes + noteBytes > SNAPSHOT_CHUNK_BYTES || batch.length >= SNAPSHOT_CHUNK_MAX_NOTES)) {
161
+ frames.push(JSON.stringify({ type: "snapshot", notes: batch, done: false }));
162
+ batch = [];
163
+ batchBytes = 0;
164
+ }
165
+ batch.push(note);
166
+ batchBytes += noteBytes;
167
+ }
168
+ frames.push(JSON.stringify({ type: "snapshot", notes: batch, done: true }));
169
+ return frames;
170
+ }
171
+
172
+ /** A parsed client message (auth / re-auth / other / malformed). */
173
+ export type ParsedClientMessage =
174
+ | { kind: "auth"; token: string }
175
+ | { kind: "other"; type: string | undefined }
176
+ | { kind: "malformed" };
177
+
178
+ /** Parse a client WS message. The literal `ping` keepalive is handled by the
179
+ * server BEFORE this (answered `pong`), so only JSON objects reach here. */
180
+ export function parseClientMessage(message: string | ArrayBuffer | Uint8Array): ParsedClientMessage {
181
+ let text: string;
182
+ if (typeof message === "string") text = message;
183
+ else {
184
+ try {
185
+ text = new TextDecoder().decode(message);
186
+ } catch {
187
+ return { kind: "malformed" };
188
+ }
189
+ }
190
+ let parsed: unknown;
191
+ try {
192
+ parsed = JSON.parse(text);
193
+ } catch {
194
+ return { kind: "malformed" };
195
+ }
196
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "malformed" };
197
+ const obj = parsed as Record<string, unknown>;
198
+ if (obj.type === "auth") {
199
+ if (typeof obj.token !== "string" || obj.token.length === 0) return { kind: "malformed" };
200
+ return { kind: "auth", token: obj.token };
201
+ }
202
+ return { kind: "other", type: typeof obj.type === "string" ? obj.type : undefined };
203
+ }
204
+
205
+ const VERB_RANK: Record<VaultVerb, number> = { read: 0, write: 1, admin: 2 };
206
+
207
+ /**
208
+ * The highest verb a scope set grants for `vaultName` (0/1/2, or -1 for none).
209
+ * The WS re-auth check: a token refresh on an open socket may narrow-or-equal
210
+ * the granted verb, never widen it (a widen → 4403). Mirrors the cloud door's
211
+ * `vaultVerbRank`.
212
+ */
213
+ export function vaultVerbRank(scopes: string[], vaultName: string): number {
214
+ if (hasScopeForVault(scopes, vaultName, "admin")) return VERB_RANK.admin;
215
+ if (hasScopeForVault(scopes, vaultName, "write")) return VERB_RANK.write;
216
+ if (hasScopeForVault(scopes, vaultName, "read")) return VERB_RANK.read;
217
+ return -1;
218
+ }
219
+
220
+ /**
221
+ * True iff two tag-scope allowlists (`scoped_tags`) are the SAME restriction —
222
+ * both unscoped (`null`), or the same set of root tags (order/dupe-insensitive).
223
+ *
224
+ * The WS re-auth check: unlike the SSE route (a token refresh means a fresh
225
+ * reconnect that re-derives tag-scope on the initial-connect path), a WS socket
226
+ * persists across a token refresh. Its live matcher + tag-scope are FROZEN at
227
+ * initial auth and NOT re-derived per message, so a re-auth that changed the
228
+ * tag-scope — a narrowed/revoked/differently-scoped agent token — would keep
229
+ * receiving events for the ORIGINAL (possibly wider) scope. Tag-scoped tokens
230
+ * are a load-bearing per-agent isolation boundary
231
+ * (docs/contracts/tag-scoped-tokens.md), so a re-auth whose tag-scope differs in
232
+ * ANY way is refused (4403) → the client reconnects fresh and re-derives scope
233
+ * via the correct initial-connect path. Verb narrowing (same scope, extended
234
+ * expiry) passes unchanged.
235
+ */
236
+ export function sameTagScope(a: string[] | null, b: string[] | null): boolean {
237
+ if (a === null && b === null) return true;
238
+ if (a === null || b === null) return false;
239
+ const sa = [...new Set(a)].sort();
240
+ const sb = [...new Set(b)].sort();
241
+ return sa.length === sb.length && sa.every((v, i) => v === sb[i]);
242
+ }