@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.
- package/.parachute/module.json +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/mcp.ts +1 -1
- package/core/src/schema.ts +5 -5
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +226 -0
- package/src/module-manifest.ts +8 -0
- package/src/routes.ts +3 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +1 -1
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +24 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscribe.ts +23 -22
- package/src/subscriptions.ts +125 -37
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/vault.test.ts +1 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
|
@@ -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
|
+
}
|