@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.4

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 (38) hide show
  1. package/core/src/core.test.ts +546 -0
  2. package/core/src/cursor.ts +1 -0
  3. package/core/src/link-count.test.ts +29 -0
  4. package/core/src/links.ts +43 -0
  5. package/core/src/mcp-manifest.ts +10 -6
  6. package/core/src/mcp.ts +129 -44
  7. package/core/src/notes.ts +51 -2
  8. package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
  9. package/core/src/schema.ts +28 -1
  10. package/core/src/store.ts +180 -57
  11. package/core/src/txn.test.ts +33 -1
  12. package/core/src/txn.ts +80 -7
  13. package/core/src/types.ts +11 -0
  14. package/core/src/vault-projection.ts +8 -1
  15. package/core/src/wikilinks.ts +873 -44
  16. package/package.json +2 -2
  17. package/src/aggregate-routes.test.ts +76 -0
  18. package/src/config.ts +8 -2
  19. package/src/mcp-http.test.ts +51 -1
  20. package/src/mcp-tools.ts +106 -5
  21. package/src/mirror-routes.test.ts +47 -0
  22. package/src/release-plan.test.ts +90 -1
  23. package/src/routes.ts +280 -66
  24. package/src/routing.test.ts +67 -0
  25. package/src/tag-scope-query-tag.test.ts +374 -0
  26. package/src/tag-scope.ts +116 -0
  27. package/src/test-support/vault-714-find-path.json +58 -0
  28. package/src/test-support/vault-714-graph.json +27 -0
  29. package/src/test-support/vault-714-has_links.json +96 -0
  30. package/src/test-support/vault-714-include_broken_links.json +118 -0
  31. package/src/test-support/vault-714-include_link_count.json +348 -0
  32. package/src/test-support/vault-714-include_links.json +86 -0
  33. package/src/test-support/vault-714-near.json +78 -0
  34. package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
  35. package/src/vault.test.ts +928 -1
  36. package/src/write-warnings-scope.test.ts +344 -0
  37. package/src/ws-server.ts +16 -3
  38. package/src/ws-subscribe.test.ts +76 -2
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Tag-scope confidentiality of the WRITE-time link warnings (`ambiguous_link`
3
+ * / `unresolved_link`) echoed by `create-note` / `update-note` on BOTH doors.
4
+ *
5
+ * vault#707 closed this oracle on the READ side: `include_ambiguous_links` /
6
+ * `has_ambiguous_links` re-resolve each ambiguous row against an injected
7
+ * `(noteId) => boolean` visibility closure, so a scoped reader gets exactly
8
+ * what an unscoped reader would get on a vault containing only the notes it
9
+ * can see. The write-time `warnings` array carries the SAME vault-wide
10
+ * `candidate_count` and was never narrowed — so a `work`-scoped WRITER who
11
+ * creates a note containing `[[Dup]]` learns from `candidate_count: 2` that a
12
+ * second `Dup` exists in a scope it cannot see.
13
+ *
14
+ * Contract pinned here (identical to vault#707's):
15
+ * - >=2 visible candidates → `ambiguous_link`, `candidate_count` = the
16
+ * VISIBLE count.
17
+ * - exactly 1 visible candidate → not ambiguous in the writer's sub-vault;
18
+ * no `ambiguous_link` warning at all.
19
+ * - 0 visible candidates → what an unscoped writer on the sub-vault would
20
+ * see: an `unresolved_link` (broken) warning, NOT `ambiguous_link`.
21
+ * - unscoped → byte-identical to before (the control pins below).
22
+ */
23
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
24
+ import { Database } from "bun:sqlite";
25
+ import { mkdirSync, rmSync, existsSync } from "fs";
26
+ import { join } from "path";
27
+ import { tmpdir } from "os";
28
+ import { writeVaultConfig } from "./config.ts";
29
+ import { BunStore, getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
30
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
31
+ import { handleNotes } from "./routes.ts";
32
+ import type { TagScopeCtx } from "./routes.ts";
33
+ import { expandTokenTagScope } from "./tag-scope.ts";
34
+ import type { AuthResult } from "./auth.ts";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // MCP door — the scoped tool generator, same harness vault#707's pins use.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ let tmpHome: string;
41
+ let prevHome: string | undefined;
42
+
43
+ beforeEach(() => {
44
+ tmpHome = join(tmpdir(), `vault-write-warn-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
45
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
46
+ prevHome = process.env.PARACHUTE_HOME;
47
+ process.env.PARACHUTE_HOME = tmpHome;
48
+ clearVaultStoreCache();
49
+ });
50
+
51
+ afterEach(() => {
52
+ clearVaultStoreCache();
53
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
54
+ else process.env.PARACHUTE_HOME = prevHome;
55
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
56
+ });
57
+
58
+ function seedVault(name: string): void {
59
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
60
+ getVaultStore(name);
61
+ }
62
+
63
+ function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
64
+ return {
65
+ permission: "full",
66
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
67
+ legacyDerived: false,
68
+ scoped_tags: scopedTags,
69
+ vault_name: null,
70
+ caller_jti: null,
71
+ actor: "test-user",
72
+ via: "api",
73
+ };
74
+ }
75
+
76
+ function toolsFor(vaultName: string, scopedTags: string[] | null) {
77
+ return generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
78
+ }
79
+
80
+ describe("MCP write-time link warnings under tag scope (vault#707 twin)", () => {
81
+ /** `wdup/Dup` in scope; the other Dup(s) carry `otherTags`. */
82
+ async function dupFixture(otherTags: string[][], includeVisible = true) {
83
+ seedVault("journal");
84
+ const store = getVaultStore("journal");
85
+ if (includeVisible) {
86
+ await store.createNote("work dup", { path: "wdup/Dup", tags: ["work"] });
87
+ }
88
+ const hidden = [];
89
+ for (let i = 0; i < otherTags.length; i++) {
90
+ hidden.push(await store.createNote("other dup", { path: `p${i + 1}/Dup`, tags: otherTags[i]! }));
91
+ }
92
+ return { store, hidden };
93
+ }
94
+
95
+ test("scoped create-note does NOT report a vault-wide candidate_count for a partly hidden collision", async () => {
96
+ const { hidden } = await dupFixture([["personal"]]);
97
+ const createNote = toolsFor("journal", ["work"]).find((t) => t.name === "create-note")!;
98
+
99
+ const result = (await createNote.execute({
100
+ content: "src [[Dup]]",
101
+ path: "work-src",
102
+ tags: ["work"],
103
+ })) as any;
104
+
105
+ // Only ONE Dup is visible to `work`, so `[[Dup]]` is not ambiguous at all
106
+ // in this writer's sub-vault.
107
+ const ambiguous = (result.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
108
+ expect(ambiguous).toEqual([]);
109
+ const serialized = JSON.stringify(result);
110
+ expect(serialized).not.toContain("p1/Dup");
111
+ expect(serialized).not.toContain(hidden[0]!.id);
112
+ });
113
+
114
+ test("scoped update-note does NOT report a vault-wide candidate_count either", async () => {
115
+ const { store, hidden } = await dupFixture([["personal"]]);
116
+ const note = await store.createNote("plain", { path: "work-src", tags: ["work"] });
117
+ const updateNote = toolsFor("journal", ["work"]).find((t) => t.name === "update-note")!;
118
+
119
+ const result = (await updateNote.execute({ id: note.id, content: "src [[Dup]]", force: true })) as any;
120
+
121
+ const ambiguous = (result.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
122
+ expect(ambiguous).toEqual([]);
123
+ const serialized = JSON.stringify(result);
124
+ expect(serialized).not.toContain("p1/Dup");
125
+ expect(serialized).not.toContain(hidden[0]!.id);
126
+ });
127
+
128
+ test("scoped create-note with ALL candidates hidden sees a BROKEN link, not an ambiguous one", async () => {
129
+ const { hidden } = await dupFixture([["personal"], ["personal"]], /* includeVisible */ false);
130
+ const createNote = toolsFor("journal", ["work"]).find((t) => t.name === "create-note")!;
131
+
132
+ const result = (await createNote.execute({
133
+ content: "src [[Dup]]",
134
+ path: "work-src",
135
+ tags: ["work"],
136
+ })) as any;
137
+
138
+ const codes = (result.warnings ?? []).map((w: any) => w.code);
139
+ expect(codes).toContain("unresolved_link");
140
+ expect(codes).not.toContain("ambiguous_link");
141
+ const serialized = JSON.stringify(result);
142
+ expect(serialized).not.toContain("candidate_count");
143
+ expect(serialized).not.toContain(hidden[0]!.id);
144
+ expect(serialized).not.toContain(hidden[1]!.id);
145
+ });
146
+
147
+ test("POSITIVE CONTROL: a partly-visible 3-way collision reports the VISIBLE count (2)", async () => {
148
+ seedVault("journal");
149
+ const store = getVaultStore("journal");
150
+ await store.createNote("w1", { path: "w1/Dup", tags: ["work"] });
151
+ await store.createNote("w2", { path: "w2/Dup", tags: ["work"] });
152
+ await store.createNote("p1", { path: "p1/Dup", tags: ["personal"] });
153
+ const createNote = toolsFor("journal", ["work"]).find((t) => t.name === "create-note")!;
154
+
155
+ const result = (await createNote.execute({
156
+ content: "src [[Dup]]",
157
+ path: "work-src",
158
+ tags: ["work"],
159
+ })) as any;
160
+
161
+ const ambiguous = (result.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
162
+ expect(ambiguous).toHaveLength(1);
163
+ expect(ambiguous[0].candidate_count).toBe(2);
164
+ });
165
+
166
+ test("UNSCOPED CONTROL: create-note still reports the full vault-wide count (regression)", async () => {
167
+ await dupFixture([["personal"]]);
168
+ const createNote = toolsFor("journal", null).find((t) => t.name === "create-note")!;
169
+
170
+ const result = (await createNote.execute({
171
+ content: "src [[Dup]]",
172
+ path: "work-src",
173
+ tags: ["work"],
174
+ })) as any;
175
+
176
+ const ambiguous = (result.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
177
+ expect(ambiguous).toHaveLength(1);
178
+ expect(ambiguous[0].candidate_count).toBe(2);
179
+ expect(ambiguous[0].target).toBe("Dup");
180
+ });
181
+
182
+ test("UNSCOPED CONTROL: update-note still reports the full vault-wide count (regression)", async () => {
183
+ const { store } = await dupFixture([["personal"]]);
184
+ const note = await store.createNote("plain", { path: "work-src", tags: ["work"] });
185
+ const updateNote = toolsFor("journal", null).find((t) => t.name === "update-note")!;
186
+
187
+ const result = (await updateNote.execute({ id: note.id, content: "src [[Dup]]", force: true })) as any;
188
+ const ambiguous = (result.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
189
+ expect(ambiguous).toHaveLength(1);
190
+ expect(ambiguous[0].candidate_count).toBe(2);
191
+ });
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // REST door — handleNotes with a real tag-scope ctx, same harness the
196
+ // "HTTP tag-scope confidentiality" block in vault.test.ts uses.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ const BASE = "http://localhost:1940";
200
+
201
+ function mkReq(method: string, path: string, body?: unknown): Request {
202
+ const init: RequestInit = { method };
203
+ if (body !== undefined) {
204
+ init.body = JSON.stringify(body);
205
+ init.headers = { "Content-Type": "application/json" };
206
+ }
207
+ return new Request(`${BASE}${path}`, init);
208
+ }
209
+
210
+ describe("REST write-time link warnings under tag scope (vault#707 twin)", () => {
211
+ let db: Database;
212
+ let store: BunStore;
213
+ let restTmp: string;
214
+
215
+ beforeEach(() => {
216
+ restTmp = join(tmpdir(), `vault-write-warn-rest-${Date.now()}-${Math.random().toString(36).slice(2)}`);
217
+ mkdirSync(restTmp, { recursive: true });
218
+ db = new Database(join(restTmp, "test.db"));
219
+ store = new BunStore(db);
220
+ });
221
+
222
+ afterEach(() => {
223
+ db.close();
224
+ rmSync(restTmp, { recursive: true, force: true });
225
+ });
226
+
227
+ async function scopeCtx(roots: string[]): Promise<TagScopeCtx> {
228
+ return { allowed: await expandTokenTagScope(store, roots), raw: roots };
229
+ }
230
+ const NO_SCOPE: TagScopeCtx = { allowed: null, raw: null };
231
+
232
+ test("scoped POST /notes does NOT report a vault-wide candidate_count", async () => {
233
+ await store.createNote("work dup", { path: "wdup/Dup", tags: ["work"] });
234
+ const hidden = await store.createNote("other dup", { path: "p1/Dup", tags: ["personal"] });
235
+
236
+ const res = await handleNotes(
237
+ mkReq("POST", "/notes", { content: "src [[Dup]]", path: "work-src", tags: ["work"] }),
238
+ store,
239
+ "",
240
+ "v",
241
+ await scopeCtx(["work"]),
242
+ );
243
+ const body = await res.json() as any;
244
+ const ambiguous = (body.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
245
+ expect(ambiguous).toEqual([]);
246
+ const serialized = JSON.stringify(body);
247
+ expect(serialized).not.toContain("p1/Dup");
248
+ expect(serialized).not.toContain(hidden.id);
249
+ });
250
+
251
+ test("scoped PATCH /notes does NOT report a vault-wide candidate_count", async () => {
252
+ await store.createNote("work dup", { path: "wdup/Dup", tags: ["work"] });
253
+ const hidden = await store.createNote("other dup", { path: "p1/Dup", tags: ["personal"] });
254
+ const note = await store.createNote("plain", { path: "work-src", tags: ["work"] });
255
+
256
+ const res = await handleNotes(
257
+ mkReq("PATCH", `/notes/${note.id}`, { content: "src [[Dup]]", if_updated_at: note.updatedAt }),
258
+ store,
259
+ `/${note.id}`,
260
+ "v",
261
+ await scopeCtx(["work"]),
262
+ );
263
+ const body = await res.json() as any;
264
+ const ambiguous = (body.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
265
+ expect(ambiguous).toEqual([]);
266
+ const serialized = JSON.stringify(body);
267
+ expect(serialized).not.toContain("p1/Dup");
268
+ expect(serialized).not.toContain(hidden.id);
269
+ });
270
+
271
+ test("scoped POST /notes with ALL candidates hidden sees a BROKEN link, not an ambiguous one", async () => {
272
+ const h1 = await store.createNote("p1", { path: "p1/Dup", tags: ["personal"] });
273
+ const h2 = await store.createNote("p2", { path: "p2/Dup", tags: ["personal"] });
274
+
275
+ const res = await handleNotes(
276
+ mkReq("POST", "/notes", { content: "src [[Dup]]", path: "work-src", tags: ["work"] }),
277
+ store,
278
+ "",
279
+ "v",
280
+ await scopeCtx(["work"]),
281
+ );
282
+ const body = await res.json() as any;
283
+ const codes = (body.warnings ?? []).map((w: any) => w.code);
284
+ expect(codes).toContain("unresolved_link");
285
+ expect(codes).not.toContain("ambiguous_link");
286
+ const serialized = JSON.stringify(body);
287
+ expect(serialized).not.toContain("candidate_count");
288
+ expect(serialized).not.toContain(h1.id);
289
+ expect(serialized).not.toContain(h2.id);
290
+ });
291
+
292
+ test("POSITIVE CONTROL: a partly-visible 3-way collision reports the VISIBLE count (2)", async () => {
293
+ await store.createNote("w1", { path: "w1/Dup", tags: ["work"] });
294
+ await store.createNote("w2", { path: "w2/Dup", tags: ["work"] });
295
+ await store.createNote("p1", { path: "p1/Dup", tags: ["personal"] });
296
+
297
+ const res = await handleNotes(
298
+ mkReq("POST", "/notes", { content: "src [[Dup]]", path: "work-src", tags: ["work"] }),
299
+ store,
300
+ "",
301
+ "v",
302
+ await scopeCtx(["work"]),
303
+ );
304
+ const body = await res.json() as any;
305
+ const ambiguous = (body.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
306
+ expect(ambiguous).toHaveLength(1);
307
+ expect(ambiguous[0].candidate_count).toBe(2);
308
+ });
309
+
310
+ test("UNSCOPED CONTROL: POST /notes still reports the full vault-wide count (regression)", async () => {
311
+ await store.createNote("work dup", { path: "wdup/Dup", tags: ["work"] });
312
+ await store.createNote("other dup", { path: "p1/Dup", tags: ["personal"] });
313
+
314
+ const res = await handleNotes(
315
+ mkReq("POST", "/notes", { content: "src [[Dup]]", path: "work-src", tags: ["work"] }),
316
+ store,
317
+ "",
318
+ "v",
319
+ NO_SCOPE,
320
+ );
321
+ const body = await res.json() as any;
322
+ const ambiguous = (body.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
323
+ expect(ambiguous).toHaveLength(1);
324
+ expect(ambiguous[0].candidate_count).toBe(2);
325
+ });
326
+
327
+ test("UNSCOPED CONTROL: PATCH /notes still reports the full vault-wide count (regression)", async () => {
328
+ await store.createNote("work dup", { path: "wdup/Dup", tags: ["work"] });
329
+ await store.createNote("other dup", { path: "p1/Dup", tags: ["personal"] });
330
+ const note = await store.createNote("plain", { path: "work-src", tags: ["work"] });
331
+
332
+ const res = await handleNotes(
333
+ mkReq("PATCH", `/notes/${note.id}`, { content: "src [[Dup]]", if_updated_at: note.updatedAt }),
334
+ store,
335
+ `/${note.id}`,
336
+ "v",
337
+ NO_SCOPE,
338
+ );
339
+ const body = await res.json() as any;
340
+ const ambiguous = (body.warnings ?? []).filter((w: any) => w.code === "ambiguous_link");
341
+ expect(ambiguous).toHaveLength(1);
342
+ expect(ambiguous[0].candidate_count).toBe(2);
343
+ });
344
+ });
package/src/ws-server.ts CHANGED
@@ -37,7 +37,12 @@ import { readVaultConfig } from "./config.ts";
37
37
  import { getVaultStore } from "./vault-store.ts";
38
38
  import { authenticateVaultRequest, type AuthResult } from "./auth.ts";
39
39
  import { hasScopeForVault } from "./scopes.ts";
40
- import { expandTokenTagScope, filterNotesByTagScope, scrubNotesTagsByScope } from "./tag-scope.ts";
40
+ import {
41
+ expandTokenTagScope,
42
+ filterNotesByTagScope,
43
+ scopeQueryTags,
44
+ scrubNotesTagsByScope,
45
+ } from "./tag-scope.ts";
41
46
  import { buildLiveMatcher } from "./live-match.ts";
42
47
  import {
43
48
  subscriptionManager,
@@ -239,10 +244,18 @@ export function createSubscribeWsBinding(deps: SubscribeWsDeps = {}): {
239
244
  let snapshotNotes;
240
245
  try {
241
246
  tagScopeAllowed = await expandTokenTagScope(store, auth.scoped_tags);
242
- matcher = await buildLiveMatcher(store, validated.queryOpts);
247
+ // Neutralise out-of-scope tags NAMED IN THE SUBSCRIPTION QUERY
248
+ // (vault#675) — subscribing to `?tag=<out-of-scope>` and watching for
249
+ // frames is the same existence oracle as the one-shot query, just
250
+ // spread over time. Scoped ONCE here, after auth (the upgrade-time
251
+ // parse in `validateWsSubscribeQuery` runs before the socket has a
252
+ // token), and the SAME opts feed both the matcher and the snapshot —
253
+ // so live/snapshot predicate parity holds by construction.
254
+ const scopedOpts = scopeQueryTags(validated.queryOpts, tagScopeAllowed, auth.scoped_tags);
255
+ matcher = await buildLiveMatcher(store, scopedOpts);
243
256
  // Snapshot = the COMPLETE scoped matching set — strip paging so snapshot ⊇ live.
244
257
  const raw = await store.queryNotes({
245
- ...validated.queryOpts,
258
+ ...scopedOpts,
246
259
  limit: Number.MAX_SAFE_INTEGER,
247
260
  offset: undefined,
248
261
  });
@@ -342,7 +342,12 @@ describe("WS live-query — tag-scope isolation (per-agent boundary)", () => {
342
342
  await store.createNote("eng note", { tags: ["shared", "work/eng"] });
343
343
  await store.createNote("sales note", { tags: ["shared", "work/sales"] });
344
344
  const { server } = makeServer();
345
- const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=shared`);
345
+ // Unfiltered subscription: `shared` is OUT of this token's scope, and
346
+ // since vault#675 a scoped token can no longer subscribe BY an
347
+ // out-of-scope tag (it matches nothing, exactly like a nonexistent
348
+ // tag). This test is about the RESULT filter, so it drops the tag
349
+ // filter rather than probing across the scope boundary.
350
+ const h = connect(server.port, `/vault/${VAULT}/api/subscribe`);
346
351
  await h.ready();
347
352
  h.send({ type: "auth", token: "scoped-eng" }); // scoped to work/eng
348
353
 
@@ -383,7 +388,10 @@ describe("WS live-query — tag-scope isolation (per-agent boundary)", () => {
383
388
 
384
389
  it("re-auth with the SAME tag-scope stays ready (normal refresh)", async () => {
385
390
  const { server } = makeServer();
386
- const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=shared`);
391
+ // Unfiltered, for the same reason as the isolation test above: since
392
+ // vault#675 a scoped token subscribing BY the out-of-scope `shared`
393
+ // matches nothing, which would mask the live delivery this asserts.
394
+ const h = connect(server.port, `/vault/${VAULT}/api/subscribe`);
387
395
  await h.ready();
388
396
  h.send({ type: "auth", token: "scoped-eng" });
389
397
  await h.readSnapshot();
@@ -400,6 +408,72 @@ describe("WS live-query — tag-scope isolation (per-agent boundary)", () => {
400
408
  });
401
409
  });
402
410
 
411
+ describe("WS live-query — vault#675: subscribing BY an out-of-scope tag is not an oracle", () => {
412
+ /**
413
+ * The one-shot query oracle (REST/MCP, covered in
414
+ * `tag-scope-query-tag.test.ts`) has a live twin: subscribe to
415
+ * `?tag=<guessed name>` and watch whether frames arrive. A note tagged
416
+ * `["shared","work/eng"]` is admitted to a `work/eng` token via `work/eng`,
417
+ * so pre-fix it landed in the snapshot of a `?tag=shared` subscription and
418
+ * confirmed that `shared` exists. Post-fix an out-of-scope subscription tag
419
+ * matches nothing — the same treatment a nonexistent tag gets.
420
+ */
421
+ it("out-of-scope subscription tag → empty snapshot AND no live frames, while an in-scope control socket sees the write", async () => {
422
+ await store.createNote("eng note", { tags: ["shared", "work/eng"] });
423
+ const { server } = makeServer();
424
+
425
+ // The probe: names a tag outside its allowlist.
426
+ const probe = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=shared`);
427
+ await probe.ready();
428
+ probe.send({ type: "auth", token: "scoped-eng" });
429
+ const snap = await probe.readSnapshot();
430
+ expect(snap.notes).toEqual([]); // pre-fix: ["eng note"] — the oracle
431
+
432
+ // The control: same token, same corpus, no cross-scope tag named.
433
+ const control = connect(server.port, `/vault/${VAULT}/api/subscribe`);
434
+ await control.ready();
435
+ control.send({ type: "auth", token: "scoped-eng" });
436
+ expect((await control.readSnapshot()).notes.map((n: any) => n.content)).toEqual(["eng note"]);
437
+
438
+ // One write, both sockets live. The control receiving it is what makes
439
+ // the probe's silence evidence rather than a race.
440
+ await store.createNote("eng live", { tags: ["shared", "work/eng"] });
441
+ const m = await control.nextMessage();
442
+ expect(m.type).toBe("upsert");
443
+ expect(m.note.content).toBe("eng live");
444
+ await expect(probe.nextMessage(200)).rejects.toThrow(/timeout/);
445
+
446
+ probe.close();
447
+ control.close();
448
+ });
449
+
450
+ it("UNSCOPED control — an unscoped subscription to the same tag is unaffected", async () => {
451
+ await store.createNote("eng note", { tags: ["shared", "work/eng"] });
452
+ const { server } = makeServer();
453
+ const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=shared`);
454
+ await h.ready();
455
+ h.send({ type: "auth", token: "good" }); // no scoped_tags
456
+
457
+ expect((await h.readSnapshot()).notes.map((n: any) => n.content)).toEqual(["eng note"]);
458
+ h.close();
459
+ });
460
+
461
+ it("an IN-SCOPE subscription tag still matches — snapshot and live", async () => {
462
+ await store.createNote("eng note", { tags: ["shared", "work/eng"] });
463
+ const { server } = makeServer();
464
+ const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=work/eng`);
465
+ await h.ready();
466
+ h.send({ type: "auth", token: "scoped-eng" });
467
+
468
+ expect((await h.readSnapshot()).notes.map((n: any) => n.content)).toEqual(["eng note"]);
469
+ await store.createNote("eng live", { tags: ["work/eng"] });
470
+ const m = await h.nextMessage();
471
+ expect(m.type).toBe("upsert");
472
+ expect(m.note.content).toBe("eng live");
473
+ h.close();
474
+ });
475
+ });
476
+
403
477
  describe("WS live-query — re-auth (narrow-or-equal)", () => {
404
478
  it("a narrowing re-auth keeps the socket open", async () => {
405
479
  const { server } = makeServer();