@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.
- package/core/src/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +10 -6
- package/core/src/mcp.ts +129 -44
- package/core/src/notes.ts +51 -2
- package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
- package/core/src/schema.ts +28 -1
- package/core/src/store.ts +180 -57
- package/core/src/txn.test.ts +33 -1
- package/core/src/txn.ts +80 -7
- package/core/src/types.ts +11 -0
- package/core/src/vault-projection.ts +8 -1
- package/core/src/wikilinks.ts +873 -44
- package/package.json +2 -2
- package/src/aggregate-routes.test.ts +76 -0
- package/src/config.ts +8 -2
- package/src/mcp-http.test.ts +51 -1
- package/src/mcp-tools.ts +106 -5
- package/src/mirror-routes.test.ts +47 -0
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +280 -66
- package/src/routing.test.ts +67 -0
- package/src/tag-scope-query-tag.test.ts +374 -0
- package/src/tag-scope.ts +116 -0
- package/src/test-support/vault-714-find-path.json +58 -0
- package/src/test-support/vault-714-graph.json +27 -0
- package/src/test-support/vault-714-has_links.json +96 -0
- package/src/test-support/vault-714-include_broken_links.json +118 -0
- package/src/test-support/vault-714-include_link_count.json +348 -0
- package/src/test-support/vault-714-include_links.json +86 -0
- package/src/test-support/vault-714-near.json +78 -0
- package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
- package/src/vault.test.ts +928 -1
- package/src/write-warnings-scope.test.ts +344 -0
- package/src/ws-server.ts +16 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
...
|
|
258
|
+
...scopedOpts,
|
|
246
259
|
limit: Number.MAX_SAFE_INTEGER,
|
|
247
260
|
offset: undefined,
|
|
248
261
|
});
|
package/src/ws-subscribe.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|