@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.
- package/core/src/contract-typed-index.test.ts +92 -3
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +161 -61
- package/core/src/notes.ts +93 -9
- package/core/src/query-warnings.ts +35 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +2 -1
- package/core/src/tag-schemas.ts +186 -4
- package/core/src/types.ts +13 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/contract-search.test.ts +263 -31
- package/src/mcp-http.ts +122 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +48 -4
- package/src/routes.ts +378 -93
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-scope.ts +66 -0
- package/src/ws-subscribe.test.ts +14 -0
|
@@ -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
|
package/src/ws-subscribe.test.ts
CHANGED
|
@@ -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 } => ({
|