@openparachute/vault 0.7.0-rc.2 → 0.7.0-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.
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Tag-scope scrub on cross-tag field-conflict errors (vault#554
3
+ * auth-and-scope review fold, refined by the wire review).
4
+ *
5
+ * Core's cross-tag field validation (`collectCrossTagFieldViolations` /
6
+ * `collectTagFieldViolations`, core/src/tag-schemas.ts) scans EVERY tag's
7
+ * schema — scope-unaware by architecture — so a tag-scoped caller updating
8
+ * its own in-scope tag used to receive violations NAMING an out-of-scope
9
+ * tag and revealing its declared type/indexed flag (proven live on both MCP
10
+ * and REST during review). The fix keeps the rejection (schema integrity is
11
+ * scope-independent) but generalizes any violation whose conflicting
12
+ * declarer is outside the caller's allowlist: no tag name, no declared
13
+ * type/flag, `other_tag` dropped (`scrubTagFieldViolationsByScope`,
14
+ * src/tag-scope.ts). In-scope declarers keep full detail; unscoped callers
15
+ * are untouched.
16
+ *
17
+ * The same information leaks through a SECOND door (wire-review
18
+ * interaction): a BOTH-indexed cross-tag type conflict deliberately
19
+ * bypasses the 422 pre-check to preserve its pre-existing `declareField` →
20
+ * 400 `invalid_indexed_field` contract, and declareField's message names
21
+ * the other declarer tag(s) + their sqlite type. `scrubIndexedFieldConflictError`
22
+ * generalizes that message for scoped callers — same 400, same error_type,
23
+ * no leak. Covered on both surfaces below.
24
+ *
25
+ * Fixture: PARACHUTE_HOME temp home + hand-built AuthResult, same pattern
26
+ * as mcp-list-tags-scope.test.ts.
27
+ */
28
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
29
+ import { mkdirSync, rmSync, existsSync } from "fs";
30
+ import { join } from "path";
31
+ import { tmpdir } from "os";
32
+ import { writeVaultConfig } from "./config.ts";
33
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
34
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
35
+ import { handleTags } from "./routes.ts";
36
+ import type { AuthResult } from "./auth.ts";
37
+ import { TagFieldConflictError } from "../core/src/tag-schemas.ts";
38
+ import { IndexedFieldError } from "../core/src/indexed-fields.ts";
39
+
40
+ let tmpHome: string;
41
+ let prevHome: string | undefined;
42
+
43
+ beforeEach(() => {
44
+ tmpHome = join(tmpdir(), `vault-tagconflict-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
+ async function updateTagTool(vaultName: string, scopedTags: string[] | null) {
77
+ const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
78
+ return tools.find((t) => t.name === "update-tag")!;
79
+ }
80
+
81
+ /** Everything an agent-facing serialization of the error could carry. */
82
+ function serializeError(err: TagFieldConflictError): string {
83
+ return JSON.stringify({ message: err.message, tag: err.tag, violations: err.violations });
84
+ }
85
+
86
+ describe("tag_field_conflict × tag scope — out-of-scope declarers scrubbed (vault#554 fold)", () => {
87
+ test("MCP: scoped caller + out-of-scope NON-indexed type conflict → still rejected (422 class), but the out-of-scope tag's name and schema appear NOWHERE in the error", async () => {
88
+ seedVault("journal");
89
+ const store = getVaultStore("journal");
90
+ // Out-of-scope declarer with a distinctive name + type (non-indexed —
91
+ // the both-indexed variant routes through the 400 path, tested below).
92
+ await store.upsertTagRecord("project-manhattan", {
93
+ fields: { x: { type: "string" } },
94
+ });
95
+ // Caller's own in-scope tag.
96
+ await store.upsertTagRecord("mine", { description: "in scope" });
97
+
98
+ const tool = await updateTagTool("journal", ["mine"]);
99
+ let caught: unknown;
100
+ try {
101
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer" } } });
102
+ } catch (e) {
103
+ caught = e;
104
+ }
105
+
106
+ // The write is STILL rejected — integrity is scope-independent.
107
+ expect(caught).toBeInstanceOf(TagFieldConflictError);
108
+ const err = caught as TagFieldConflictError;
109
+ expect(err.violations).toHaveLength(1);
110
+ expect(err.violations[0]!.field).toBe("x");
111
+ expect(err.violations[0]!.reason).toBe("type_conflict");
112
+ expect(err.message).toContain("no changes were applied");
113
+
114
+ // The scrub: no tag name, no declared type, no other_tag — anywhere.
115
+ expect(err.violations[0]!.other_tag).toBeUndefined();
116
+ const serialized = serializeError(err);
117
+ expect(serialized).not.toContain("project-manhattan");
118
+ expect(serialized).not.toContain('declares "string"');
119
+
120
+ // Nothing persisted.
121
+ const mine = await store.getTagRecord("mine");
122
+ expect(mine?.fields ?? null).toBeFalsy();
123
+ });
124
+
125
+ test("MCP: scoped caller + IN-scope conflict keeps full detail (tag name + declared type + other_tag)", async () => {
126
+ seedVault("journal");
127
+ const store = getVaultStore("journal");
128
+ await store.upsertTagRecord("mine-too", {
129
+ fields: { x: { type: "string" } },
130
+ });
131
+ await store.upsertTagRecord("mine", { description: "in scope" });
132
+
133
+ const tool = await updateTagTool("journal", ["mine", "mine-too"]);
134
+ let caught: unknown;
135
+ try {
136
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer" } } });
137
+ } catch (e) {
138
+ caught = e;
139
+ }
140
+
141
+ expect(caught).toBeInstanceOf(TagFieldConflictError);
142
+ const err = caught as TagFieldConflictError;
143
+ expect(err.violations).toHaveLength(1);
144
+ expect(err.violations[0]!.other_tag).toBe("mine-too");
145
+ expect(err.violations[0]!.message).toContain("mine-too");
146
+ expect(err.violations[0]!.message).toContain('declares "string"');
147
+ });
148
+
149
+ test("REST: scoped caller + out-of-scope NON-indexed type conflict → 422 with generalized violation, out-of-scope tag name nowhere in the body", async () => {
150
+ seedVault("journal");
151
+ const store = getVaultStore("journal");
152
+ await store.upsertTagRecord("project-manhattan", {
153
+ fields: { x: { type: "string" } },
154
+ });
155
+ await store.upsertTagRecord("mine", { description: "in scope" });
156
+
157
+ const req = new Request("http://localhost/api/tags/mine", {
158
+ method: "PUT",
159
+ body: JSON.stringify({ fields: { x: { type: "integer" } } }),
160
+ });
161
+ const res = await handleTags(req, store, "/mine", {
162
+ allowed: new Set(["mine"]),
163
+ raw: ["mine"],
164
+ });
165
+
166
+ expect(res.status).toBe(422);
167
+ const body: any = await res.json();
168
+ expect(body.error_type).toBe("tag_field_conflict");
169
+ expect(body.violations).toHaveLength(1);
170
+ expect(body.violations[0].field).toBe("x");
171
+ expect(body.violations[0].reason).toBe("type_conflict");
172
+ expect(body.violations[0].other_tag).toBeUndefined();
173
+ expect(body.message).toContain("no changes were applied");
174
+
175
+ const serialized = JSON.stringify(body);
176
+ expect(serialized).not.toContain("project-manhattan");
177
+ expect(serialized).not.toContain('declares "string"');
178
+
179
+ const mine = await store.getTagRecord("mine");
180
+ expect(mine?.fields ?? null).toBeFalsy();
181
+ });
182
+
183
+ test("REST: scoped caller + IN-scope conflict keeps full detail", async () => {
184
+ seedVault("journal");
185
+ const store = getVaultStore("journal");
186
+ await store.upsertTagRecord("mine-too", {
187
+ fields: { x: { type: "string" } },
188
+ });
189
+ await store.upsertTagRecord("mine", { description: "in scope" });
190
+
191
+ const req = new Request("http://localhost/api/tags/mine", {
192
+ method: "PUT",
193
+ body: JSON.stringify({ fields: { x: { type: "integer" } } }),
194
+ });
195
+ const res = await handleTags(req, store, "/mine", {
196
+ allowed: new Set(["mine", "mine-too"]),
197
+ raw: ["mine", "mine-too"],
198
+ });
199
+
200
+ expect(res.status).toBe(422);
201
+ const body: any = await res.json();
202
+ expect(body.violations).toHaveLength(1);
203
+ expect(body.violations[0].other_tag).toBe("mine-too");
204
+ expect(body.violations[0].message).toContain("mine-too");
205
+ expect(body.violations[0].message).toContain('declares "string"');
206
+ });
207
+
208
+ test("unscoped callers keep full detail everywhere (MCP + REST controls)", async () => {
209
+ seedVault("journal");
210
+ const store = getVaultStore("journal");
211
+ await store.upsertTagRecord("project-manhattan", {
212
+ fields: { x: { type: "string" } },
213
+ });
214
+
215
+ // MCP — unscoped session: applyTagScopeWrappers never installs, core's
216
+ // full-detail error passes through untouched.
217
+ const tool = await updateTagTool("journal", null);
218
+ let caught: unknown;
219
+ try {
220
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer" } } });
221
+ } catch (e) {
222
+ caught = e;
223
+ }
224
+ const err = caught as TagFieldConflictError;
225
+ expect(err.violations[0]!.other_tag).toBe("project-manhattan");
226
+ expect(err.violations[0]!.message).toContain("project-manhattan");
227
+
228
+ // REST — unscoped ctx (allowed === null): scrub is a no-op.
229
+ const req = new Request("http://localhost/api/tags/mine", {
230
+ method: "PUT",
231
+ body: JSON.stringify({ fields: { x: { type: "integer" } } }),
232
+ });
233
+ const res = await handleTags(req, store, "/mine");
234
+ expect(res.status).toBe(422);
235
+ const body: any = await res.json();
236
+ expect(body.violations[0].other_tag).toBe("project-manhattan");
237
+ expect(body.violations[0].message).toContain("project-manhattan");
238
+ });
239
+ });
240
+
241
+ describe("invalid_indexed_field × tag scope — the 400 door is scrubbed too (wire-review interaction)", () => {
242
+ test("REST: scoped caller + BOTH-indexed type conflict vs out-of-scope tag → 400 invalid_indexed_field (status preserved) with a generalized message", async () => {
243
+ seedVault("journal");
244
+ const store = getVaultStore("journal");
245
+ await store.upsertTagRecord("project-manhattan", {
246
+ fields: { x: { type: "string", indexed: true } },
247
+ });
248
+ await store.upsertTagRecord("mine", { description: "in scope" });
249
+
250
+ const req = new Request("http://localhost/api/tags/mine", {
251
+ method: "PUT",
252
+ body: JSON.stringify({ fields: { x: { type: "integer", indexed: true } } }),
253
+ });
254
+ const res = await handleTags(req, store, "/mine", {
255
+ allowed: new Set(["mine"]),
256
+ raw: ["mine"],
257
+ });
258
+
259
+ // Status + error_type are the PRESERVED pre-existing contract; only
260
+ // the message prose is generalized for the scoped caller.
261
+ expect(res.status).toBe(400);
262
+ const body: any = await res.json();
263
+ expect(body.error_type).toBe("invalid_indexed_field");
264
+ expect(body.error).toContain('field "x"');
265
+ expect(body.error).toContain("outside your token's tag scope");
266
+ const serialized = JSON.stringify(body);
267
+ expect(serialized).not.toContain("project-manhattan");
268
+ expect(serialized).not.toContain("TEXT"); // the existing sqlite type stays hidden too
269
+
270
+ // Still rejected — nothing persisted.
271
+ const mine = await store.getTagRecord("mine");
272
+ expect(mine?.fields ?? null).toBeFalsy();
273
+ });
274
+
275
+ test("MCP: scoped caller + BOTH-indexed type conflict vs out-of-scope tag → IndexedFieldError with a generalized message", async () => {
276
+ seedVault("journal");
277
+ const store = getVaultStore("journal");
278
+ await store.upsertTagRecord("project-manhattan", {
279
+ fields: { x: { type: "string", indexed: true } },
280
+ });
281
+ await store.upsertTagRecord("mine", { description: "in scope" });
282
+
283
+ const tool = await updateTagTool("journal", ["mine"]);
284
+ let caught: any;
285
+ try {
286
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer", indexed: true } } });
287
+ } catch (e) {
288
+ caught = e;
289
+ }
290
+
291
+ expect(caught).toBeInstanceOf(IndexedFieldError);
292
+ expect(caught.error_type).toBe("invalid_indexed_field");
293
+ expect(caught.message).toContain("outside your token's tag scope");
294
+ expect(caught.message).not.toContain("project-manhattan");
295
+ expect(caught.message).not.toContain("TEXT");
296
+ // The structured declarer context is dropped from the scrubbed error.
297
+ expect(caught.declarer_tags).toBeUndefined();
298
+ });
299
+
300
+ test("both-indexed conflict vs an IN-scope tag keeps declareField's full message on both surfaces", async () => {
301
+ seedVault("journal");
302
+ const store = getVaultStore("journal");
303
+ await store.upsertTagRecord("mine-too", {
304
+ fields: { x: { type: "string", indexed: true } },
305
+ });
306
+ await store.upsertTagRecord("mine", { description: "in scope" });
307
+
308
+ // REST
309
+ const req = new Request("http://localhost/api/tags/mine", {
310
+ method: "PUT",
311
+ body: JSON.stringify({ fields: { x: { type: "integer", indexed: true } } }),
312
+ });
313
+ const res = await handleTags(req, store, "/mine", {
314
+ allowed: new Set(["mine", "mine-too"]),
315
+ raw: ["mine", "mine-too"],
316
+ });
317
+ expect(res.status).toBe(400);
318
+ const body: any = await res.json();
319
+ expect(body.error_type).toBe("invalid_indexed_field");
320
+ expect(body.error).toContain("mine-too");
321
+
322
+ // MCP
323
+ const tool = await updateTagTool("journal", ["mine", "mine-too"]);
324
+ let caught: any;
325
+ try {
326
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer", indexed: true } } });
327
+ } catch (e) {
328
+ caught = e;
329
+ }
330
+ expect(caught).toBeInstanceOf(IndexedFieldError);
331
+ expect(caught.message).toContain("mine-too");
332
+ });
333
+ });
package/src/tag-scope.ts CHANGED
@@ -20,6 +20,8 @@
20
20
  */
21
21
 
22
22
  import type { Store, Note, HydratedLink, NoteSummary } from "../core/src/types.ts";
23
+ import type { TagFieldViolation } from "../core/src/tag-schemas.ts";
24
+ import { IndexedFieldError } from "../core/src/indexed-fields.ts";
23
25
 
24
26
  /**
25
27
  * Build the effective tag-allowlist for a token: union of `{root} ∪
@@ -167,6 +169,70 @@ export function filterHydratedLinksByTagScope(
167
169
  );
168
170
  }
169
171
 
172
+ /**
173
+ * Scrub `tag_field_conflict` violations for a tag-scoped caller
174
+ * (vault#554 auth-and-scope review fold). Core's cross-tag field
175
+ * validation (`collectCrossTagFieldViolations` / `collectTagFieldViolations`
176
+ * in core/src/tag-schemas.ts) scans EVERY tag's schema — scope-unaware by
177
+ * architecture — so a scoped caller updating its own in-scope tag can
178
+ * receive a violation whose `message` names an OUT-OF-SCOPE tag and
179
+ * reveals its declared type/indexed flag (proven live on both MCP and
180
+ * REST). Policy: the write is STILL rejected (schema integrity is
181
+ * scope-independent — do not weaken the check), but the violation entry
182
+ * for an out-of-scope conflicting declarer is generalized: no tag name,
183
+ * no declared type/flag, `other_tag` dropped. In-scope conflicting
184
+ * declarers keep full detail; solo own-field violations (no `other_tag`)
185
+ * pass through untouched; unscoped callers (`allowed === null`) keep full
186
+ * detail everywhere.
187
+ *
188
+ * Membership is `allowed.has(other_tag)` — the same convention the
189
+ * `did_you_mean` scrub and the update-tag/delete-tag wrappers use in
190
+ * src/mcp-tools.ts (no string-form root fallback: when in doubt, scrub —
191
+ * the fail-closed direction only costs detail, never correctness).
192
+ */
193
+ export function scrubTagFieldViolationsByScope(
194
+ violations: TagFieldViolation[],
195
+ allowed: Set<string> | null,
196
+ ): TagFieldViolation[] {
197
+ if (allowed === null) return violations;
198
+ return violations.map((v) => {
199
+ if (v.other_tag === undefined || allowed.has(v.other_tag)) return v;
200
+ const generalized =
201
+ v.reason === "type_conflict"
202
+ ? `field "${v.field}" type conflict: another tag (outside your token's tag scope) declares this field with a different type. Types must agree across all declarers.`
203
+ : `field "${v.field}" indexed-flag conflict: another tag (outside your token's tag scope) declares this field with a different indexed flag. Must match across all declarers.`;
204
+ const { other_tag: _dropped, ...rest } = v;
205
+ return { ...rest, message: generalized };
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Companion scrub for the OTHER door the same information can leak
211
+ * through (vault#554 auth-and-scope × wire-review interaction): a
212
+ * both-indexed cross-tag type conflict deliberately bypasses the
213
+ * `tag_field_conflict` pre-check (preserving its pre-existing
214
+ * `declareField` → 400 `invalid_indexed_field` contract — see
215
+ * `collectCrossTagFieldViolations`'s doc comment), and declareField's
216
+ * message names the other declarer tag(s) plus their sqlite type. For a
217
+ * tag-scoped caller with any out-of-scope declarer, return a REPLACEMENT
218
+ * `IndexedFieldError` with a generalized message (no tag names, no
219
+ * existing type) — same rejection, same 400 status, same `error_type`,
220
+ * just no leak. If every declarer is in scope (or the error carries no
221
+ * `declarer_tags` — the solo own-field throws), or the caller is unscoped
222
+ * (`allowed === null`), the original error is returned untouched.
223
+ */
224
+ export function scrubIndexedFieldConflictError(
225
+ err: IndexedFieldError & { field?: string; declarer_tags?: string[] },
226
+ allowed: Set<string> | null,
227
+ ): IndexedFieldError {
228
+ if (allowed === null) return err;
229
+ if (!Array.isArray(err.declarer_tags) || err.declarer_tags.length === 0) return err;
230
+ if (err.declarer_tags.every((t) => allowed.has(t))) return err;
231
+ return new IndexedFieldError(
232
+ `field "${err.field ?? "?"}" is already indexed by another tag (outside your token's tag scope) with a conflicting type. Types must agree across all declarers.`,
233
+ );
234
+ }
235
+
170
236
  /**
171
237
  * Standard 403 response shape for tag-scope rejections. Mirrors the
172
238
  * `insufficient_scope` 403 shape used elsewhere in the API so clients
@@ -440,6 +440,20 @@ describe("WS live-query — bad query + cap", () => {
440
440
  expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
441
441
  });
442
442
 
443
+ // vault#551 — literal-by-default search + `search_mode` are a
444
+ // snapshot/REST-only concept (escaping happens in `core/src/notes.ts`
445
+ // `searchNotes`, which the live matcher never calls). `search` itself
446
+ // stays categorically unsupported for live subscriptions regardless of
447
+ // mode — confirms `search_mode` doesn't accidentally open a bypass.
448
+ it("rejects an unsupported query with search_mode present too — search_mode doesn't bypass the search rejection", async () => {
449
+ const { server } = makeServer();
450
+ const res = await fetch(`http://localhost:${server.port}/vault/${VAULT}/api/subscribe?search=hi&search_mode=advanced`, {
451
+ headers: { Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Key": "x", "Sec-WebSocket-Version": "13" },
452
+ });
453
+ expect(res.status).toBe(400);
454
+ expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
455
+ });
456
+
443
457
  it("refuses over the per-vault cap (503) and self-heals when a socket closes", async () => {
444
458
  const { binding } = makeServer({ maxSubscriptions: 2 });
445
459
  const fakeWs = (): { data: SubscribeWsData; close(): void; send(): void } => ({