@openparachute/vault 0.7.6 → 0.7.7

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 (54) hide show
  1. package/README.md +16 -16
  2. package/core/src/attachment/policy.test.ts +7 -0
  3. package/core/src/attachment/policy.ts +9 -0
  4. package/core/src/attachment-tickets-tool.test.ts +15 -0
  5. package/core/src/conformance.test.ts +78 -0
  6. package/core/src/conformance.ts +34 -5
  7. package/core/src/connection-pragmas.test.ts +27 -1
  8. package/core/src/core.test.ts +88 -3
  9. package/core/src/cursor.ts +2 -0
  10. package/core/src/do-param-cap.test.ts +167 -0
  11. package/core/src/lede.test.ts +60 -0
  12. package/core/src/mcp-manifest.ts +15 -1
  13. package/core/src/mcp.ts +37 -5
  14. package/core/src/notes.ts +120 -48
  15. package/core/src/query-operators.ts +87 -5
  16. package/core/src/query-warnings.ts +11 -3
  17. package/core/src/schema.ts +32 -0
  18. package/core/src/seed-packs.ts +74 -5
  19. package/core/src/sql-in.ts +32 -2
  20. package/core/src/store.ts +16 -49
  21. package/core/src/test-preload.ts +48 -3
  22. package/core/src/types.ts +13 -1
  23. package/core/src/wikilinks.test.ts +57 -0
  24. package/core/src/wikilinks.ts +63 -29
  25. package/package.json +1 -1
  26. package/src/attachment-tickets.test.ts +62 -0
  27. package/src/attachment-tickets.ts +2 -2
  28. package/src/cli.ts +36 -8
  29. package/src/config.ts +34 -1
  30. package/src/contract-honest-queries.test.ts +33 -1
  31. package/src/contract-search.test.ts +47 -0
  32. package/src/embedding/select.ts +16 -3
  33. package/src/live-match.test.ts +8 -0
  34. package/src/live-match.ts +15 -0
  35. package/src/mcp-http.test.ts +12 -0
  36. package/src/mcp-http.ts +1 -0
  37. package/src/mcp-tools.ts +51 -17
  38. package/src/mirror-routes.test.ts +22 -31
  39. package/src/onboarding-seed.test.ts +68 -0
  40. package/src/routes.ts +88 -25
  41. package/src/routing.test.ts +24 -0
  42. package/src/routing.ts +2 -0
  43. package/src/subscriptions.ts +18 -2
  44. package/src/tag-scope-note-tags.test.ts +476 -0
  45. package/src/tag-scope.ts +73 -5
  46. package/src/test-home-isolation.test.ts +137 -0
  47. package/src/test-support/spawn.ts +12 -0
  48. package/src/transcription/download.test.ts +187 -1
  49. package/src/transcription/download.ts +149 -2
  50. package/src/transcription/install-python.test.ts +23 -2
  51. package/src/transcription/install-python.ts +13 -4
  52. package/src/vault.test.ts +39 -4
  53. package/src/version.test.ts +8 -0
  54. package/src/ws-server.ts +12 -2
@@ -0,0 +1,476 @@
1
+ /**
2
+ * vault#568 — a scoped read must not disclose out-of-scope tag NAMES via a
3
+ * note's own `.tags` array.
4
+ *
5
+ * `noteWithinTagScope` admits a note when ANY of its tags is in scope, but
6
+ * the note that came back carried its FULL tag set. A note tagged
7
+ * `["mine","project-manhattan"]` read by a `mine`-scoped token disclosed the
8
+ * NAME `project-manhattan` — the same out-of-scope-tag-name class as #560
9
+ * and the W8 `validation_status` scrub, through a plainer field.
10
+ *
11
+ * The fix (`scrubNoteTagsByScope`, src/tag-scope.ts) filters `.tags` to the
12
+ * in-scope subset using the SAME per-tag rule that admitted the note, on
13
+ * BOTH doors (REST + MCP) and on the live-subscription transport. Write
14
+ * responses are scrubbed too: they echo the stored note, so a no-op
15
+ * `update-note` / `PATCH` would otherwise be a one-call bypass.
16
+ *
17
+ * Fixture: PARACHUTE_HOME temp home + hand-built AuthResult, same pattern as
18
+ * tag-field-conflict-scope.test.ts.
19
+ *
20
+ * Naming convention used throughout: `mine` is in scope, `project-manhattan`
21
+ * is the out-of-scope co-tag whose NAME must appear nowhere in a scoped
22
+ * response. Every test carries an UNSCOPED control asserting the full tag
23
+ * set still comes back — the fix must be invisible to unscoped callers.
24
+ */
25
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
26
+ import { mkdirSync, rmSync, existsSync } from "fs";
27
+ import { join } from "path";
28
+ import { tmpdir } from "os";
29
+ import { writeVaultConfig } from "./config.ts";
30
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
31
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
32
+ import { handleNotes, type TagScopeCtx } from "./routes.ts";
33
+ import { SubscriptionManager, type SubscriptionSink } from "./subscriptions.ts";
34
+ import { buildLiveMatcher } from "./live-match.ts";
35
+ import { expandTokenTagScope } from "./tag-scope.ts";
36
+ import type { AuthResult } from "./auth.ts";
37
+ import type { Note, Store } from "../core/src/types.ts";
38
+
39
+ const OUT_OF_SCOPE = "project-manhattan";
40
+
41
+ let tmpHome: string;
42
+ let prevHome: string | undefined;
43
+
44
+ beforeEach(() => {
45
+ tmpHome = join(tmpdir(), `vault-568-${Date.now()}-${Math.random().toString(36).slice(2)}`);
46
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
47
+ prevHome = process.env.PARACHUTE_HOME;
48
+ process.env.PARACHUTE_HOME = tmpHome;
49
+ clearVaultStoreCache();
50
+ });
51
+
52
+ afterEach(() => {
53
+ clearVaultStoreCache();
54
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
55
+ else process.env.PARACHUTE_HOME = prevHome;
56
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
57
+ });
58
+
59
+ function seedVault(name: string): Store {
60
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
61
+ return getVaultStore(name);
62
+ }
63
+
64
+ function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
65
+ return {
66
+ permission: "full",
67
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
68
+ legacyDerived: false,
69
+ scoped_tags: scopedTags,
70
+ vault_name: null,
71
+ caller_jti: null,
72
+ actor: "test-user",
73
+ via: "api",
74
+ } as AuthResult;
75
+ }
76
+
77
+ /** The REST layer's tag-scope context, built the way routing.ts builds it. */
78
+ async function restScope(store: Store, scopedTags: string[] | null): Promise<TagScopeCtx> {
79
+ return { allowed: await expandTokenTagScope(store, scopedTags), raw: scopedTags };
80
+ }
81
+
82
+ async function rest(
83
+ store: Store,
84
+ scope: TagScopeCtx,
85
+ method: string,
86
+ subpath: string,
87
+ query = "",
88
+ body?: unknown,
89
+ ): Promise<any> {
90
+ const req = new Request(`http://localhost/vault/v/api/notes${subpath}${query}`, {
91
+ method,
92
+ ...(body === undefined
93
+ ? {}
94
+ : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
95
+ });
96
+ const res = await handleNotes(req, store, subpath, "v", scope);
97
+ return { status: res.status, body: await res.json() };
98
+ }
99
+
100
+ /** The co-tagged fixture every test reads: visible via `mine`, co-tagged out of scope. */
101
+ async function seedCoTagged(store: Store): Promise<Note> {
102
+ return await store.createNote("co-tagged body", {
103
+ path: "CoTagged",
104
+ tags: ["mine", OUT_OF_SCOPE],
105
+ });
106
+ }
107
+
108
+ describe("vault#568 — REST door: a note's own .tags is filtered to the in-scope subset", () => {
109
+ test("GET /api/notes?id= — scoped caller sees only in-scope tags; the out-of-scope NAME is absent from the whole payload", async () => {
110
+ const store = seedVault("v");
111
+ const note = await seedCoTagged(store);
112
+ const scope = await restScope(store, ["mine"]);
113
+
114
+ const { status, body } = await rest(store, scope, "GET", "", `?id=${note.id}`);
115
+ expect(status).toBe(200);
116
+ expect(body.tags).toEqual(["mine"]);
117
+ expect(JSON.stringify(body)).not.toContain(OUT_OF_SCOPE);
118
+ });
119
+
120
+ test("GET /api/notes?id= UNSCOPED control — full tag set survives (the fix is invisible to unscoped tokens)", async () => {
121
+ const store = seedVault("v");
122
+ const note = await seedCoTagged(store);
123
+ const scope = await restScope(store, null);
124
+
125
+ const { body } = await rest(store, scope, "GET", "", `?id=${note.id}`);
126
+ expect([...body.tags].sort()).toEqual(["mine", OUT_OF_SCOPE]);
127
+ });
128
+
129
+ test("GET /api/notes/:id — the note-level route scrubs too", async () => {
130
+ const store = seedVault("v");
131
+ const note = await seedCoTagged(store);
132
+ const scope = await restScope(store, ["mine"]);
133
+
134
+ const { status, body } = await rest(store, scope, "GET", `/${note.id}`);
135
+ expect(status).toBe(200);
136
+ expect(body.tags).toEqual(["mine"]);
137
+ expect(JSON.stringify(body)).not.toContain(OUT_OF_SCOPE);
138
+ });
139
+
140
+ test("GET /api/notes (structured list) — scrubbed in both the full and the lean (include_content=false) projection", async () => {
141
+ const store = seedVault("v");
142
+ await seedCoTagged(store);
143
+ const scope = await restScope(store, ["mine"]);
144
+
145
+ const full = await rest(store, scope, "GET", "", "?include_content=true");
146
+ expect(full.body).toHaveLength(1);
147
+ expect(full.body[0].tags).toEqual(["mine"]);
148
+
149
+ const lean = await rest(store, scope, "GET", "", "?include_content=false");
150
+ expect(lean.body[0].tags).toEqual(["mine"]);
151
+ expect(JSON.stringify(lean.body)).not.toContain(OUT_OF_SCOPE);
152
+ });
153
+
154
+ test("GET /api/notes?search= — the FTS branch scrubs on the same terms", async () => {
155
+ const store = seedVault("v");
156
+ await seedCoTagged(store);
157
+ const scope = await restScope(store, ["mine"]);
158
+
159
+ const { body } = await rest(store, scope, "GET", "", "?search=co-tagged");
160
+ expect(body).toHaveLength(1);
161
+ expect(body[0].tags).toEqual(["mine"]);
162
+ expect(JSON.stringify(body)).not.toContain(OUT_OF_SCOPE);
163
+ });
164
+
165
+ test("GET /api/notes?format=graph — nodes[].tags is scrubbed (a REST-only shape with no MCP twin)", async () => {
166
+ const store = seedVault("v");
167
+ await seedCoTagged(store);
168
+ const scope = await restScope(store, ["mine"]);
169
+
170
+ const { body } = await rest(store, scope, "GET", "", "?format=graph");
171
+ expect(body.nodes).toHaveLength(1);
172
+ expect(body.nodes[0].tags).toEqual(["mine"]);
173
+ expect(JSON.stringify(body)).not.toContain(OUT_OF_SCOPE);
174
+ });
175
+
176
+ test("include_links — a SURVIVING in-scope neighbour's summary.tags is scrubbed (the second door on the same field)", async () => {
177
+ const store = seedVault("v");
178
+ // Neighbour is IN scope via `mine`, so `filterHydratedLinksByTagScope`
179
+ // keeps the link — but the neighbour is itself co-tagged, so its
180
+ // NoteSummary.tags carried the out-of-scope name.
181
+ const neighbour = await store.createNote("neighbour", {
182
+ path: "Neighbour",
183
+ tags: ["mine", OUT_OF_SCOPE],
184
+ });
185
+ const anchor = await store.createNote("anchor [[Neighbour]]", {
186
+ path: "Anchor",
187
+ tags: ["mine"],
188
+ });
189
+ const scope = await restScope(store, ["mine"]);
190
+
191
+ const { body } = await rest(store, scope, "GET", `/${anchor.id}`, "?include_links=true");
192
+ expect(body.links.length).toBeGreaterThan(0);
193
+ const summaries = body.links.flatMap((l: any) => [l.sourceNote, l.targetNote].filter(Boolean));
194
+ expect(summaries.some((s: any) => s.id === neighbour.id)).toBe(true);
195
+ for (const s of summaries) {
196
+ if (Array.isArray(s.tags)) expect(s.tags).not.toContain(OUT_OF_SCOPE);
197
+ }
198
+ expect(JSON.stringify(body)).not.toContain(OUT_OF_SCOPE);
199
+ });
200
+
201
+ test("PATCH echo — a no-op update does NOT hand back the full tag set (the read-path scrub would otherwise be one call from bypassed)", async () => {
202
+ const store = seedVault("v");
203
+ const note = await seedCoTagged(store);
204
+ const scope = await restScope(store, ["mine"]);
205
+
206
+ const { status, body } = await rest(store, scope, "PATCH", `/${note.id}`, "", {
207
+ content: "edited",
208
+ force: true,
209
+ });
210
+ expect(status).toBe(200);
211
+ expect(body.tags).toEqual(["mine"]);
212
+ expect(JSON.stringify(body)).not.toContain(OUT_OF_SCOPE);
213
+
214
+ // ...and the write really landed: the scrub shapes the response only,
215
+ // it never drops the tag from storage.
216
+ const stored = await store.getNote(note.id);
217
+ expect([...(stored!.tags ?? [])].sort()).toEqual(["mine", OUT_OF_SCOPE]);
218
+ expect(stored!.content).toBe("edited");
219
+ });
220
+
221
+ test("POST if_exists:update echo — the response is a PRE-EXISTING note, and its co-tags stay hidden", async () => {
222
+ const store = seedVault("v");
223
+ await seedCoTagged(store);
224
+ const scope = await restScope(store, ["mine"]);
225
+
226
+ const { body } = await rest(store, scope, "POST", "", "", {
227
+ path: "CoTagged",
228
+ content: "rewritten",
229
+ tags: ["mine"],
230
+ if_exists: "update",
231
+ });
232
+ expect(body.existed).toBe(true);
233
+ expect(body.tags).toEqual(["mine"]);
234
+ expect(JSON.stringify(body)).not.toContain(OUT_OF_SCOPE);
235
+ });
236
+
237
+ test("EDGE — a note whose tags are ALL out of scope stays INVISIBLE (404), it does not come back with an empty .tags", async () => {
238
+ const store = seedVault("v");
239
+ const hidden = await store.createNote("secret", { path: "Secret", tags: [OUT_OF_SCOPE] });
240
+ await seedCoTagged(store);
241
+ const scope = await restScope(store, ["mine"]);
242
+
243
+ // Single read → 404, per the contract's "out-of-scope reads return 404,
244
+ // not 403" stance. The scrub never produces an empty-tags stub.
245
+ const single = await rest(store, scope, "GET", `/${hidden.id}`);
246
+ expect(single.status).toBe(404);
247
+
248
+ // List → the note is absent entirely, not present-with-no-tags.
249
+ const list = await rest(store, scope, "GET", "", "?include_content=true");
250
+ expect(list.body.map((n: any) => n.id)).not.toContain(hidden.id);
251
+ for (const n of list.body) expect(n.tags.length).toBeGreaterThan(0);
252
+ });
253
+ });
254
+
255
+ describe("vault#568 — MCP door: identical semantics (one contract, two doors)", () => {
256
+ function toolset(vaultName: string, scopedTags: string[] | null) {
257
+ const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags) as any);
258
+ return (name: string) => tools.find((t) => t.name === name)!;
259
+ }
260
+
261
+ test("query-notes by id — scoped session sees only in-scope tags", async () => {
262
+ const store = seedVault("v");
263
+ const note = await seedCoTagged(store);
264
+
265
+ const result: any = await toolset("v", ["mine"])("query-notes").execute({ id: note.id });
266
+ expect(result.tags).toEqual(["mine"]);
267
+ expect(JSON.stringify(result)).not.toContain(OUT_OF_SCOPE);
268
+ });
269
+
270
+ test("query-notes by id UNSCOPED control — full tag set survives", async () => {
271
+ const store = seedVault("v");
272
+ const note = await seedCoTagged(store);
273
+
274
+ const result: any = await toolset("v", null)("query-notes").execute({ id: note.id });
275
+ expect([...result.tags].sort()).toEqual(["mine", OUT_OF_SCOPE]);
276
+ });
277
+
278
+ test("query-notes list — scrubbed in the array shape", async () => {
279
+ const store = seedVault("v");
280
+ await seedCoTagged(store);
281
+
282
+ const result: any = await toolset("v", ["mine"])("query-notes").execute({});
283
+ const notes = Array.isArray(result) ? result : result.notes;
284
+ expect(notes).toHaveLength(1);
285
+ expect(notes[0].tags).toEqual(["mine"]);
286
+ expect(JSON.stringify(result)).not.toContain(OUT_OF_SCOPE);
287
+ });
288
+
289
+ test("create-note with if_exists:update — the echoed PRE-EXISTING note is scrubbed", async () => {
290
+ const store = seedVault("v");
291
+ await seedCoTagged(store);
292
+
293
+ const result: any = await toolset("v", ["mine"])("create-note").execute({
294
+ path: "CoTagged",
295
+ content: "rewritten",
296
+ tags: ["mine"],
297
+ if_exists: "update",
298
+ });
299
+ expect(result.tags).toEqual(["mine"]);
300
+ expect(JSON.stringify(result)).not.toContain(OUT_OF_SCOPE);
301
+ });
302
+
303
+ test("update-note echo — a no-op update does not leak the co-tag (bypass closed on this door too)", async () => {
304
+ const store = seedVault("v");
305
+ const note = await seedCoTagged(store);
306
+
307
+ const result: any = await toolset("v", ["mine"])("update-note").execute({
308
+ id: note.id,
309
+ content: "edited",
310
+ force: true,
311
+ });
312
+ expect(result.tags).toEqual(["mine"]);
313
+ expect(JSON.stringify(result)).not.toContain(OUT_OF_SCOPE);
314
+
315
+ const stored = await store.getNote(note.id);
316
+ expect([...(stored!.tags ?? [])].sort()).toEqual(["mine", OUT_OF_SCOPE]);
317
+ });
318
+
319
+ test("update-note UNSCOPED control — full tag set still echoed", async () => {
320
+ const store = seedVault("v");
321
+ const note = await seedCoTagged(store);
322
+
323
+ const result: any = await toolset("v", null)("update-note").execute({
324
+ id: note.id,
325
+ content: "edited",
326
+ force: true,
327
+ });
328
+ expect([...result.tags].sort()).toEqual(["mine", OUT_OF_SCOPE]);
329
+ });
330
+
331
+ test("BOTH-DOOR PARITY — REST and MCP return the same tag set for the same note under the same allowlist", async () => {
332
+ const store = seedVault("v");
333
+ const note = await store.createNote("three tags", {
334
+ path: "Three",
335
+ tags: ["mine", "ops", OUT_OF_SCOPE],
336
+ });
337
+ const scope = await restScope(store, ["mine", "ops"]);
338
+
339
+ const restBody = (await rest(store, scope, "GET", `/${note.id}`)).body;
340
+ const mcpBody: any = await toolset("v", ["mine", "ops"])("query-notes").execute({ id: note.id });
341
+
342
+ expect([...restBody.tags].sort()).toEqual(["mine", "ops"]);
343
+ expect([...mcpBody.tags].sort()).toEqual([...restBody.tags].sort());
344
+ });
345
+
346
+ test("sub-tag hierarchy — the string-form root fallback keeps `mine/sub` VISIBLE while still hiding the out-of-scope name", async () => {
347
+ const store = seedVault("v");
348
+ const note = await store.createNote("hier", {
349
+ path: "Hier",
350
+ tags: ["mine/sub", OUT_OF_SCOPE],
351
+ });
352
+ const scope = await restScope(store, ["mine"]);
353
+
354
+ // `mine/sub` has no declared `_tags/mine/sub` hierarchy — it survives via
355
+ // the SAME string-form root fallback `noteWithinTagScope` admits it by,
356
+ // so the scrub can never hide a tag the token is actually allowed to see.
357
+ const restBody = (await rest(store, scope, "GET", `/${note.id}`)).body;
358
+ expect(restBody.tags).toEqual(["mine/sub"]);
359
+
360
+ const mcpBody: any = await toolset("v", ["mine"])("query-notes").execute({ id: note.id });
361
+ expect(mcpBody.tags).toEqual(["mine/sub"]);
362
+ });
363
+ });
364
+
365
+ describe("vault#568 — live-subscription door: an event payload is a read too", () => {
366
+ /** Collects `(event, data)` tuples off a subscription. */
367
+ class CapturingSink implements SubscriptionSink {
368
+ readonly frames: Array<{ event: string; data: any }> = [];
369
+ send(event: string, data: unknown): boolean {
370
+ this.frames.push({ event, data });
371
+ return true;
372
+ }
373
+ close(): void {}
374
+ }
375
+
376
+ test("upsert fan-out — each subscriber sees only ITS OWN in-scope tags, and the shared payload is not mutated", async () => {
377
+ const store = seedVault("v");
378
+ const note = await store.createNote("watched", {
379
+ path: "Watched",
380
+ tags: ["mine", "ops", OUT_OF_SCOPE],
381
+ });
382
+
383
+ const manager = new SubscriptionManager(undefined, { resolveVault: () => "v" });
384
+ const matcher = await buildLiveMatcher(store, {});
385
+
386
+ const mineSink = new CapturingSink();
387
+ const opsSink = new CapturingSink();
388
+ const unscopedSink = new CapturingSink();
389
+ manager.register({
390
+ vaultName: "v",
391
+ matcher,
392
+ tagScopeAllowed: await expandTokenTagScope(store, ["mine"]),
393
+ tagScopeRaw: ["mine"],
394
+ sink: mineSink,
395
+ tracksFlush: false,
396
+ });
397
+ manager.register({
398
+ vaultName: "v",
399
+ matcher,
400
+ tagScopeAllowed: await expandTokenTagScope(store, ["ops"]),
401
+ tagScopeRaw: ["ops"],
402
+ sink: opsSink,
403
+ tracksFlush: false,
404
+ });
405
+ manager.register({
406
+ vaultName: "v",
407
+ matcher,
408
+ tagScopeAllowed: null,
409
+ tagScopeRaw: null,
410
+ sink: unscopedSink,
411
+ tracksFlush: false,
412
+ });
413
+
414
+ // ONE payload, three subscribers with three different allowlists — the
415
+ // exact shape a mutating scrub would cross-contaminate.
416
+ (manager as any).dispatch("updated", note, store);
417
+
418
+ expect(mineSink.frames).toHaveLength(1);
419
+ expect(mineSink.frames[0]!.event).toBe("upsert");
420
+ expect(mineSink.frames[0]!.data.note.tags).toEqual(["mine"]);
421
+
422
+ expect(opsSink.frames[0]!.data.note.tags).toEqual(["ops"]);
423
+
424
+ // Unscoped control: untouched.
425
+ expect([...unscopedSink.frames[0]!.data.note.tags].sort()).toEqual(["mine", "ops", OUT_OF_SCOPE]);
426
+
427
+ // The source payload every sub shared is still intact — proof the scrub
428
+ // copied rather than mutated. (A mutating scrub would have left `note`
429
+ // holding whichever subscriber ran last.)
430
+ expect([...(note.tags ?? [])].sort()).toEqual(["mine", "ops", OUT_OF_SCOPE]);
431
+
432
+ manager.shutdown();
433
+ });
434
+
435
+ test("upsert fan-out — the LEAN (NoteIndex) projection is scrubbed on the same terms", async () => {
436
+ const store = seedVault("v");
437
+ const note = await store.createNote("watched", { path: "Watched", tags: ["mine", OUT_OF_SCOPE] });
438
+
439
+ const manager = new SubscriptionManager(undefined, { resolveVault: () => "v" });
440
+ const sink = new CapturingSink();
441
+ manager.register({
442
+ vaultName: "v",
443
+ matcher: await buildLiveMatcher(store, {}),
444
+ tagScopeAllowed: await expandTokenTagScope(store, ["mine"]),
445
+ tagScopeRaw: ["mine"],
446
+ sink,
447
+ lean: true,
448
+ tracksFlush: false,
449
+ });
450
+
451
+ (manager as any).dispatch("updated", note, store);
452
+ expect(sink.frames[0]!.data.note.tags).toEqual(["mine"]);
453
+ expect(JSON.stringify(sink.frames[0]!.data)).not.toContain(OUT_OF_SCOPE);
454
+ manager.shutdown();
455
+ });
456
+
457
+ test("out-of-scope note still produces NO frame at all (the pre-existing gate is untouched by the scrub)", async () => {
458
+ const store = seedVault("v");
459
+ const hidden = await store.createNote("secret", { path: "Secret", tags: [OUT_OF_SCOPE] });
460
+
461
+ const manager = new SubscriptionManager(undefined, { resolveVault: () => "v" });
462
+ const sink = new CapturingSink();
463
+ manager.register({
464
+ vaultName: "v",
465
+ matcher: await buildLiveMatcher(store, {}),
466
+ tagScopeAllowed: await expandTokenTagScope(store, ["mine"]),
467
+ tagScopeRaw: ["mine"],
468
+ sink,
469
+ tracksFlush: false,
470
+ });
471
+
472
+ (manager as any).dispatch("updated", hidden, store);
473
+ expect(sink.frames).toHaveLength(0);
474
+ manager.shutdown();
475
+ });
476
+ });
package/src/tag-scope.ts CHANGED
@@ -196,6 +196,56 @@ export function buildExpandVisibility(
196
196
  return (note: Note) => noteWithinTagScope(note, allowed, rawRoots);
197
197
  }
198
198
 
199
+ /**
200
+ * Filter a note's OWN `.tags` array to the tags this token can see
201
+ * (vault#568). A tag-scoped token is admitted to a note when ANY of its tags
202
+ * is in scope (`noteWithinTagScope`) — but the note that came back carried
203
+ * its FULL tag set, so a note tagged `["mine","project-manhattan"]` read by a
204
+ * `mine`-scoped token disclosed the NAME `project-manhattan`. Same
205
+ * out-of-scope-tag-name disclosure class as #560 and the `validation_status`
206
+ * scrub above, through a different field.
207
+ *
208
+ * Policy: `.tags` becomes exactly the in-scope subset, using the SAME
209
+ * per-tag rule (`tagVisibleInScope`) that admitted the note — allowlist
210
+ * membership OR string-form root match. Non-mutating: returns the input
211
+ * untouched when nothing is filtered (and always when unscoped), otherwise a
212
+ * shallow copy with a fresh `tags` array. That matters at the live-
213
+ * subscription seam, where ONE `Note` payload fans out to many subscribers
214
+ * with different allowlists — mutating it would cross-contaminate.
215
+ *
216
+ * **The result is never empty for a note the caller can see.** A note whose
217
+ * tags are ALL out of scope fails `noteWithinTagScope` and is already
218
+ * invisible (404 on a single read, silently dropped from lists) — it never
219
+ * reaches this scrub. So the visible-subset is non-empty by construction,
220
+ * and this fix introduces no new "note with no tags" shape. The precedent
221
+ * it follows is the contract's §Semantics "out-of-scope reads return 404,
222
+ * not 403": the scope boundary is invisible, not redacted-in-place.
223
+ *
224
+ * Applies to READ responses on both doors AND to write responses, which
225
+ * echo the stored note — a scoped caller could otherwise recover the full
226
+ * tag set with a no-op `update-note`/`PATCH`.
227
+ */
228
+ export function scrubNoteTagsByScope<T extends { tags?: string[] }>(
229
+ note: T,
230
+ allowed: Set<string> | null,
231
+ rawRoots: string[] | null,
232
+ ): T {
233
+ if (rawRoots === null || !note || !Array.isArray(note.tags)) return note;
234
+ const visible = note.tags.filter((t) => tagVisibleInScope(t, allowed, rawRoots));
235
+ if (visible.length === note.tags.length) return note;
236
+ return { ...note, tags: visible };
237
+ }
238
+
239
+ /** Array form of `scrubNoteTagsByScope`. No-op when unscoped. */
240
+ export function scrubNotesTagsByScope<T extends { tags?: string[] }>(
241
+ notes: T[],
242
+ allowed: Set<string> | null,
243
+ rawRoots: string[] | null,
244
+ ): T[] {
245
+ if (rawRoots === null) return notes;
246
+ return notes.map((n) => scrubNoteTagsByScope(n, allowed, rawRoots));
247
+ }
248
+
199
249
  /**
200
250
  * Treat a hydrated link's endpoint summary as a scope-checkable note. The
201
251
  * summary carries `id` + `tags`, which is all `noteWithinTagScope` needs.
@@ -226,6 +276,13 @@ function summaryWithinTagScope(
226
276
  * fully hydrated. Dropping the whole row (vs. just nulling the summary) is
227
277
  * required because the raw row still carries the neighbor's note id.
228
278
  *
279
+ * **Also scrubs the SURVIVING summaries' `.tags` (vault#568).** Dropping
280
+ * wholly-out-of-scope neighbors is not sufficient: an IN-scope neighbor is
281
+ * itself a co-tagged note, so its `NoteSummary.tags` carries the same
282
+ * out-of-scope tag NAMES the top-level note's `.tags` does. This is the
283
+ * second door on the same field, so it gets the same `scrubNoteTagsByScope`
284
+ * treatment (non-mutating — the hydrated rows may be shared across a page).
285
+ *
229
286
  * No-op when the token is unscoped (`rawRoots === null`) — identical to the
230
287
  * pre-fix behavior.
231
288
  */
@@ -235,11 +292,22 @@ export function filterHydratedLinksByTagScope(
235
292
  rawRoots: string[] | null,
236
293
  ): HydratedLink[] {
237
294
  if (rawRoots === null) return links;
238
- return links.filter(
239
- (link) =>
240
- summaryWithinTagScope(link.sourceNote, allowed, rawRoots) &&
241
- summaryWithinTagScope(link.targetNote, allowed, rawRoots),
242
- );
295
+ return links
296
+ .filter(
297
+ (link) =>
298
+ summaryWithinTagScope(link.sourceNote, allowed, rawRoots) &&
299
+ summaryWithinTagScope(link.targetNote, allowed, rawRoots),
300
+ )
301
+ .map((link) => {
302
+ const sourceNote = link.sourceNote
303
+ ? scrubNoteTagsByScope(link.sourceNote, allowed, rawRoots)
304
+ : link.sourceNote;
305
+ const targetNote = link.targetNote
306
+ ? scrubNoteTagsByScope(link.targetNote, allowed, rawRoots)
307
+ : link.targetNote;
308
+ if (sourceNote === link.sourceNote && targetNote === link.targetNote) return link;
309
+ return { ...link, sourceNote, targetNote };
310
+ });
243
311
  }
244
312
 
245
313
  /**