@openparachute/vault 0.6.5-rc.12 → 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.
@@ -12,6 +12,7 @@
12
12
  "focus": "core",
13
13
  "adminCapabilities": ["config", "credentials"],
14
14
  "startCmd": ["parachute-vault", "serve"],
15
+ "websocket": true,
15
16
  "scopes": {
16
17
  "defines": ["vault:read", "vault:write", "vault:admin"]
17
18
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.5-rc.12",
3
+ "version": "0.6.5-rc.13",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Live-query frame PARITY + the pure WS helpers (WS-hibernation migration
3
+ * Phase 3, self-host door).
4
+ *
5
+ * The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
6
+ * (mirrored byte-for-byte from the cloud door), the self-host WS payload and the
7
+ * self-host SSE `data:` body carry a byte-IDENTICAL serialization of the inner
8
+ * payload (`notes` / `note` / `id`) — the ONLY difference is the `type`
9
+ * discriminator the WS message folds in (plus the snapshot `done` chunk flag).
10
+ * Because the corpus AND the `sseFrame`/`wsFrame` formatters are byte-shaped
11
+ * identical to cloud's, this transitively proves: self-host WS bytes === corpus
12
+ * === self-host SSE bytes === cloud WS/SSE bytes.
13
+ *
14
+ * Plus unit coverage of the pure helpers that back the Bun.serve integration:
15
+ * snapshot chunking, first-message parsing, verb-rank, and the query rejects.
16
+ */
17
+ import { describe, it, expect } from "bun:test";
18
+ import { sseFrame, wsFrame, SseSink, WsSink, snapshotFrame } from "./subscriptions.ts";
19
+ import {
20
+ buildSnapshotFrames,
21
+ parseClientMessage,
22
+ vaultVerbRank,
23
+ sameTagScope,
24
+ validateWsSubscribeQuery,
25
+ subscriptionCapResponse,
26
+ urlFromQuery,
27
+ SNAPSHOT_CHUNK_MAX_NOTES,
28
+ WS_CLOSE,
29
+ } from "./ws-subscribe.ts";
30
+ import { unsupportedSubscriptionReason } from "./live-match.ts";
31
+ import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
32
+
33
+ /** Strip the SSE `data:` body out of a single-event SSE frame. */
34
+ function sseDataBody(frame: string): string {
35
+ const line = frame.split("\n").find((l) => l.startsWith("data:"))!;
36
+ return line.slice("data:".length).replace(/^ /, "");
37
+ }
38
+
39
+ describe("live-frame parity — self-host WS bytes === corpus === SSE data body", () => {
40
+ for (const c of FRAME_CORPUS) {
41
+ it(`${c.name}: identical inner payload across transports`, () => {
42
+ const sse = sseFrame(c.event, c.data);
43
+ const ws = wsFrame(c.event, c.data);
44
+
45
+ // SSE frame shape (byte-identical to the pre-WS-migration contract).
46
+ expect(sse).toBe(`event: ${c.event}\ndata: ${JSON.stringify(c.data)}\n\n`);
47
+ // SSE data body parses back to exactly the payload.
48
+ expect(JSON.parse(sseDataBody(sse))).toEqual(c.data);
49
+
50
+ // WS message is `{ type, ...data }` — the event name folds into `type`.
51
+ const parsedWs = JSON.parse(ws) as Record<string, unknown>;
52
+ expect(parsedWs.type).toBe(c.event);
53
+ const { type, ...rest } = parsedWs;
54
+ void type;
55
+ expect(rest).toEqual(c.data);
56
+
57
+ // BYTE parity of the inner payload: whatever value the event carries
58
+ // (`notes` / `note` / `id`) serializes IDENTICALLY in both frames AND
59
+ // equals the corpus serialization (the cross-door invariant).
60
+ const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
61
+ const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
62
+ expect(sse.includes(innerBytes)).toBe(true);
63
+ expect(ws.includes(innerBytes)).toBe(true);
64
+ });
65
+ }
66
+
67
+ it("snapshotFrame() is the SSE snapshot frame for a note set", () => {
68
+ expect(snapshotFrame([NOTE_A as any])).toBe(sseFrame("snapshot", { notes: [NOTE_A] }));
69
+ });
70
+ });
71
+
72
+ describe("sink classes render through the ONE formatter each", () => {
73
+ it("WsSink.send emits exactly wsFrame(event, data)", () => {
74
+ const sent: string[] = [];
75
+ const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
76
+ for (const c of FRAME_CORPUS) {
77
+ sink.send(c.event, c.data);
78
+ expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
79
+ }
80
+ });
81
+
82
+ it("SseSink.send emits exactly sseFrame(event, data); comment() emits `:\\n\\n`", () => {
83
+ const pushed: string[] = [];
84
+ const sink = new SseSink((f) => (pushed.push(f), true), () => {});
85
+ sink.send("upsert", { note: NOTE_A });
86
+ expect(pushed.at(-1)).toBe(sseFrame("upsert", { note: NOTE_A }));
87
+ sink.comment();
88
+ expect(pushed.at(-1)).toBe(":\n\n");
89
+ });
90
+ });
91
+
92
+ describe("buildSnapshotFrames — chunking + done flag", () => {
93
+ it("empty set → exactly one done:true frame", () => {
94
+ const frames = buildSnapshotFrames([]);
95
+ expect(frames.length).toBe(1);
96
+ expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
97
+ });
98
+
99
+ it("small set → one done:true frame carrying every note", () => {
100
+ const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
101
+ expect(frames.length).toBe(1);
102
+ const f = JSON.parse(frames[0]!);
103
+ expect(f.done).toBe(true);
104
+ expect(f.notes.length).toBe(2);
105
+ });
106
+
107
+ it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
108
+ const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
109
+ const frames = buildSnapshotFrames(many as any);
110
+ expect(frames.length).toBeGreaterThan(1);
111
+ const parsed = frames.map((f) => JSON.parse(f));
112
+ expect(parsed.filter((p) => p.done).length).toBe(1);
113
+ expect(parsed[parsed.length - 1]!.done).toBe(true);
114
+ parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
115
+ const concat = parsed.flatMap((p) => p.notes);
116
+ expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
117
+ });
118
+
119
+ it("each frame stays under the WS message ceiling with large notes", () => {
120
+ const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
121
+ const frames = buildSnapshotFrames(big as any);
122
+ expect(frames.length).toBeGreaterThan(1);
123
+ for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
124
+ expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
125
+ });
126
+ });
127
+
128
+ describe("parseClientMessage", () => {
129
+ it("valid auth", () => {
130
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
131
+ });
132
+ it("auth without a token → malformed", () => {
133
+ expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
134
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
135
+ });
136
+ it("non-json / non-object → malformed", () => {
137
+ expect(parseClientMessage("not json").kind).toBe("malformed");
138
+ expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
139
+ });
140
+ it("other typed message → other", () => {
141
+ expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
142
+ });
143
+ it("decodes a Uint8Array frame", () => {
144
+ const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
145
+ expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
146
+ });
147
+ });
148
+
149
+ describe("vaultVerbRank — narrow-or-equal ordering", () => {
150
+ it("ranks read < write < admin, -1 for none/other-vault", () => {
151
+ expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
152
+ expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
153
+ expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
154
+ expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
155
+ expect(vaultVerbRank([], "v")).toBe(-1);
156
+ });
157
+ it("a widen is strictly greater (the 4403 trigger)", () => {
158
+ expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
159
+ expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
164
+ it("both unscoped (null) → same", () => {
165
+ expect(sameTagScope(null, null)).toBe(true);
166
+ });
167
+ it("null vs scoped → differ (a drop/gain of scoping)", () => {
168
+ expect(sameTagScope(null, ["work/eng"])).toBe(false);
169
+ expect(sameTagScope(["work/eng"], null)).toBe(false);
170
+ });
171
+ it("same set (order/dupe-insensitive) → same", () => {
172
+ expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
173
+ expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
174
+ });
175
+ it("different membership → differ", () => {
176
+ expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
177
+ expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
178
+ });
179
+ });
180
+
181
+ describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
182
+ const reject = async (q: string) => {
183
+ const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
184
+ expect("error" in v).toBe(true);
185
+ const body = await (v as { error: Response }).error.json();
186
+ expect((v as { error: Response }).error.status).toBe(400);
187
+ expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
188
+ };
189
+ it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
190
+ await reject("search=hi");
191
+ await reject("near[note_id]=abc");
192
+ await reject("cursor=xyz");
193
+ await reject("has_links=true");
194
+ });
195
+ it("accepts a supported query", () => {
196
+ const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
197
+ expect("queryOpts" in v).toBe(true);
198
+ });
199
+ it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
200
+ // The belt-and-suspenders layer both doors + the SSE route route through:
201
+ // a date filter isn't expressible as a flat URL param (removed 0.6.4), but
202
+ // if one ever reaches queryOpts it is rejected identically.
203
+ expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
204
+ expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
205
+ expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
206
+ expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
207
+ expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
208
+ });
209
+ });
210
+
211
+ describe("misc pure helpers", () => {
212
+ it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
213
+ const res = subscriptionCapResponse();
214
+ expect(res.status).toBe(503);
215
+ expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
216
+ });
217
+ it("urlFromQuery reconstructs searchParams from a raw query string", () => {
218
+ expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
219
+ expect(urlFromQuery("").search).toBe("");
220
+ });
221
+ it("WS_CLOSE carries the four application close codes", () => {
222
+ expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
223
+ 4400, 4401, 4403, 4408,
224
+ ]);
225
+ });
226
+ });
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
48
48
  readonly paths: readonly string[];
49
49
  readonly health: string;
50
50
  readonly stripPrefix?: boolean;
51
+ /**
52
+ * When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
53
+ * binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
54
+ * vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
55
+ * self-registered services.json row too (`ServiceEntry.websocket`); the hub
56
+ * honors either source. See `parachute-hub/src/ws-bridge.ts`.
57
+ */
58
+ readonly websocket?: boolean;
51
59
  }
52
60
 
53
61
  /**
@@ -382,6 +382,25 @@ describe("self-register", () => {
382
382
  });
383
383
  });
384
384
 
385
+ test("websocket capability flows from manifest to row when set (hub ws-bridge gate)", () => {
386
+ withParachuteHome((home) => {
387
+ const { log, warn } = captureLogs();
388
+ selfRegister({
389
+ version: "0.4.8-rc.3",
390
+ log,
391
+ warn,
392
+ readManifest: () => ({ ...TEST_MANIFEST, websocket: true }),
393
+ resolvePackageRoot: () => "/fake/install/dir",
394
+ listVaults: () => [],
395
+ readGlobalConfig: () => ({ port: 1940 }),
396
+ });
397
+
398
+ const raw = readFileSync(join(home, "services.json"), "utf8");
399
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
400
+ expect(parsed.services[0]!.websocket).toBe(true);
401
+ });
402
+ });
403
+
385
404
  test("changed=true when installDir differs from prior row", () => {
386
405
  withParachuteHome(() => {
387
406
  const { log, warn } = captureLogs();
@@ -191,6 +191,7 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
191
191
  displayName?: string;
192
192
  tagline?: string;
193
193
  stripPrefix?: boolean;
194
+ websocket?: boolean;
194
195
  } = {
195
196
  name: manifest.manifestName,
196
197
  port,
@@ -202,6 +203,9 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
202
203
  if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
203
204
  if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
204
205
  if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
206
+ // Carry the live-query WS capability onto the row so the hub ws-bridge admits
207
+ // upgrades without having to read module.json (the row is its first source).
208
+ if (manifest.websocket !== undefined) entry.websocket = manifest.websocket;
205
209
 
206
210
  // Detect whether the existing row already matches (no-op idempotency
207
211
  // signal). We don't gate the write on this — `upsertService` itself is
@@ -232,7 +236,8 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
232
236
  priorRow.health !== health ||
233
237
  (priorRow as { displayName?: string }).displayName !== manifest.displayName ||
234
238
  (priorRow as { tagline?: string }).tagline !== manifest.tagline ||
235
- (priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix;
239
+ (priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix ||
240
+ (priorRow as { websocket?: boolean }).websocket !== manifest.websocket;
236
241
 
237
242
  try {
238
243
  upsertImpl(entry);
package/src/server.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  } from "./mirror-config.ts";
62
62
  import { GLOBAL_CONFIG_PATH } from "./config.ts";
63
63
  import { selfRegister } from "./self-register.ts";
64
+ import { createSubscribeWsBinding, isWebSocketUpgrade } from "./ws-server.ts";
64
65
  import { warnLegacyGlobalApiKeys } from "./auth.ts";
65
66
  import pkg from "../package.json" with { type: "json" };
66
67
 
@@ -490,10 +491,18 @@ const hostname = resolveBindHostname();
490
491
  }
491
492
  }
492
493
 
494
+ // Live-query WebSocket binding (WS-hibernation migration Phase 3). Same
495
+ // subscription contract as the SSE route, WebSocket transport. Handlers +
496
+ // upgrade decision live in ws-server.ts; the per-vault WS cap is closure state
497
+ // there. Advertised via `"websocket": true` in `.parachute/module.json` so the
498
+ // hub ws-bridge admits the upgrade.
499
+ const subscribeWs = createSubscribeWsBinding();
500
+
493
501
  const server = Bun.serve({
494
502
  port,
495
503
  hostname,
496
504
  idleTimeout: 120, // seconds — webhook triggers can take a while
505
+ websocket: subscribeWs.handlers,
497
506
  async fetch(req, server) {
498
507
  const url = new URL(req.url);
499
508
  const path = url.pathname;
@@ -517,6 +526,21 @@ const server = Bun.serve({
517
526
  return new Response(null, { status: 204, headers: corsHeaders });
518
527
  }
519
528
 
529
+ // Live-query WebSocket upgrade — detected BEFORE the fetch pipeline because
530
+ // WS upgrades don't traverse `route()`. Non-upgrade requests (incl. the SSE
531
+ // fallback, which has no Upgrade header) fall straight through unchanged.
532
+ if (isWebSocketUpgrade(req)) {
533
+ const verdict = subscribeWs.tryUpgrade(req, server, path);
534
+ if (verdict.kind === "upgraded") return undefined; // Bun owns the socket now
535
+ if (verdict.kind === "response") {
536
+ for (const [k, v] of Object.entries(corsHeaders)) {
537
+ verdict.response.headers.set(k, v);
538
+ }
539
+ return verdict.response;
540
+ }
541
+ // "pass" → not a subscribe WS upgrade; fall through to the pipeline.
542
+ }
543
+
520
544
  try {
521
545
  const start = Date.now();
522
546
  const response = await route(req, path);
@@ -15,6 +15,14 @@ export interface ServiceEntry {
15
15
  paths: string[];
16
16
  health: string;
17
17
  version: string;
18
+ /**
19
+ * When `true`, the hub's ws-bridge forwards `Upgrade: websocket` requests on
20
+ * this module's mounts to it (deny-by-default). Carried from the module's
21
+ * `.parachute/module.json` `websocket` field by self-registration; the hub
22
+ * honors either source. Optional + free-riding — extra fields survive the
23
+ * read/merge via the `...e` spread in `validateEntry` / `upsertService`.
24
+ */
25
+ websocket?: boolean;
18
26
  }
19
27
 
20
28
  export interface ServicesManifest {
package/src/subscribe.ts CHANGED
@@ -24,8 +24,8 @@ import { buildLiveMatcher, unsupportedSubscriptionReason } from "./live-match.ts
24
24
  import {
25
25
  snapshotFrame,
26
26
  subscriptionManager,
27
+ SseSink,
27
28
  type SubscriptionManager,
28
- type SubscriptionSink,
29
29
  } from "./subscriptions.ts";
30
30
 
31
31
  /** Keepalive interval — `:` comment every ~25s to defeat idle-proxy timeouts. */
@@ -160,27 +160,28 @@ export async function handleSubscribe(
160
160
  }
161
161
  };
162
162
 
163
- const sink: SubscriptionSink = {
164
- write(frame: string): boolean {
165
- if (cancelled) return false;
166
- queue.push(frame);
167
- flushQueue();
168
- return true;
169
- },
170
- close(): void {
171
- if (cancelled) return;
172
- cancelled = true;
173
- if (keepalive) {
174
- clearInterval(keepalive);
175
- keepalive = null;
176
- }
177
- try {
178
- controllerRef?.close();
179
- } catch {
180
- /* already closed */
181
- }
182
- },
163
+ // SSE sink over the stream queue. `send(event, data)` and the `:` keepalive
164
+ // comment both route through this same push → the emitted bytes are identical
165
+ // to the pre-WS-migration SSE contract (surface-client parses them unchanged).
166
+ const push = (frame: string): boolean => {
167
+ if (cancelled) return false;
168
+ queue.push(frame);
169
+ flushQueue();
170
+ return true;
183
171
  };
172
+ const sink = new SseSink(push, () => {
173
+ if (cancelled) return;
174
+ cancelled = true;
175
+ if (keepalive) {
176
+ clearInterval(keepalive);
177
+ keepalive = null;
178
+ }
179
+ try {
180
+ controllerRef?.close();
181
+ } catch {
182
+ /* already closed */
183
+ }
184
+ });
184
185
 
185
186
  const stream = new ReadableStream<Uint8Array>({
186
187
  start(controller) {
@@ -219,7 +220,7 @@ export async function handleSubscribe(
219
220
  // 3. Keepalive comments.
220
221
  keepalive = setInterval(() => {
221
222
  if (cancelled) return;
222
- sink.write(":\n\n");
223
+ sink.comment(); // `:\n\n` — byte-identical keepalive comment.
223
224
  }, KEEPALIVE_MS);
224
225
  },
225
226
  pull() {