@openparachute/vault 0.6.3 → 0.6.4-rc.10
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/README.md +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/core.test.ts +126 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +235 -9
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +280 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +51 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/cli.ts +345 -9
- package/src/config.ts +11 -0
- package/src/first-boot-create.test.ts +155 -0
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +202 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +195 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +209 -41
- package/src/routing.test.ts +115 -25
- package/src/routing.ts +64 -2
- package/src/scale.bench.test.ts +82 -0
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/server.ts +58 -38
- package/src/subscribe.test.ts +23 -2
- package/src/test-support/spawn.ts +85 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
- package/src/vault-remove.test.ts +4 -5
- package/src/vault.test.ts +188 -17
package/src/subscribe.test.ts
CHANGED
|
@@ -416,9 +416,12 @@ describe("handleSubscribe — rejected query shapes", () => {
|
|
|
416
416
|
expect(body.error).toContain("has_links");
|
|
417
417
|
});
|
|
418
418
|
|
|
419
|
-
it("date filter → 400 (M1) —
|
|
419
|
+
it("date filter → 400 (M1) — bracket date_filter on created_at", async () => {
|
|
420
|
+
// The flat `date_from` param was removed in 0.6.4 (vault#288) and is now
|
|
421
|
+
// ignored, so it no longer reaches the M1 date guard. Bracket-style is the
|
|
422
|
+
// only query-string date filter; assert it's still rejected for live subs.
|
|
420
423
|
const res = await handleSubscribe(
|
|
421
|
-
subscribeReq("
|
|
424
|
+
subscribeReq("meta%5Bcreated_at%5D%5Bgte%5D=2026-01-01"),
|
|
422
425
|
store,
|
|
423
426
|
VAULT,
|
|
424
427
|
unscopedScope(),
|
|
@@ -430,6 +433,24 @@ describe("handleSubscribe — rejected query shapes", () => {
|
|
|
430
433
|
expect(body.error).toContain("date");
|
|
431
434
|
});
|
|
432
435
|
|
|
436
|
+
it("flat date_from is ignored → subscription created (vault#288 removal)", async () => {
|
|
437
|
+
// Documents the breaking change: the removed flat param no longer reaches
|
|
438
|
+
// the date guard, so a sub that ONLY carries it is now a valid (unfiltered)
|
|
439
|
+
// subscription rather than a 400.
|
|
440
|
+
const res = await handleSubscribe(
|
|
441
|
+
subscribeReq("date_from=2026-01-01"),
|
|
442
|
+
store,
|
|
443
|
+
VAULT,
|
|
444
|
+
unscopedScope(),
|
|
445
|
+
manager,
|
|
446
|
+
);
|
|
447
|
+
expect(res.status).toBe(200);
|
|
448
|
+
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
|
449
|
+
const r = sseReader(res);
|
|
450
|
+
await r.pump();
|
|
451
|
+
await r.close();
|
|
452
|
+
});
|
|
453
|
+
|
|
433
454
|
it("date filter → 400 (M1) — bracket date_filter on updated_at", async () => {
|
|
434
455
|
const res = await handleSubscribe(
|
|
435
456
|
subscribeReq("meta%5Bupdated_at%5D%5Bgte%5D=2026-01-01"),
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared subprocess helper for tests (vault#325 Part 1).
|
|
3
|
+
*
|
|
4
|
+
* THE RULE — never `Bun.spawnSync` in a test that keeps an in-process
|
|
5
|
+
* `Bun.serve` listening for the child to probe.
|
|
6
|
+
*
|
|
7
|
+
* `Bun.spawnSync` blocks the parent's event loop until the child exits.
|
|
8
|
+
* If the child makes an HTTP request back to a server the *same test
|
|
9
|
+
* process* is hosting (e.g. the CLI's daemon-busy pre-flight fetching a
|
|
10
|
+
* stub `/health`), the parent can't answer — the child's request times
|
|
11
|
+
* out into the "nothing listening" branch and the assertion silently
|
|
12
|
+
* tests the wrong path (the guard never fires, or fails for a non-obvious
|
|
13
|
+
* reason). This bit the daemon-busy import guard; the fix landed in
|
|
14
|
+
* vault#324 (`src/import-daemon-busy.test.ts`) and this helper is the
|
|
15
|
+
* institutional-memory follow-up so future subprocess tests reach for the
|
|
16
|
+
* safe primitive by default.
|
|
17
|
+
*
|
|
18
|
+
* `runSubprocess` uses async `Bun.spawn` + `await proc.exited`, so the
|
|
19
|
+
* parent's event loop keeps servicing requests while the child runs.
|
|
20
|
+
*
|
|
21
|
+
* NOTE on what's *fine*: `Bun.spawnSync` is safe in tests that don't run
|
|
22
|
+
* an in-test server the child talks to over HTTP — e.g. shelling out to
|
|
23
|
+
* `git` to build a fixture repo (`mirror-*.test.ts`), or a `Bun.serve`
|
|
24
|
+
* that merely *holds a port* so the child's `lsof`/availability probe
|
|
25
|
+
* sees it occupied (`doctor.test.ts`). The deadlock is specifically
|
|
26
|
+
* "child fetches the parent test process's server." When in doubt, use
|
|
27
|
+
* this helper — it's never wrong, only sometimes unnecessary.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface RunSubprocessResult {
|
|
31
|
+
exitCode: number;
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RunSubprocessOptions {
|
|
37
|
+
cmd: string[];
|
|
38
|
+
cwd?: string;
|
|
39
|
+
/** Extra env merged over `process.env`. `undefined` values delete the key. */
|
|
40
|
+
env?: Record<string, string | undefined>;
|
|
41
|
+
/** Optional bytes piped to the child's stdin. */
|
|
42
|
+
stdin?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run a subprocess WITHOUT blocking the event loop.
|
|
47
|
+
*
|
|
48
|
+
* Drop-in for the `Bun.spawnSync({ cmd, env, stdout, stderr })` pattern
|
|
49
|
+
* that recurs across the CLI integration tests, but built on async
|
|
50
|
+
* `Bun.spawn` so an in-process `Bun.serve` the child probes can still
|
|
51
|
+
* answer. Returns once the child has exited, with decoded stdout/stderr.
|
|
52
|
+
*/
|
|
53
|
+
export async function runSubprocess(
|
|
54
|
+
opts: RunSubprocessOptions,
|
|
55
|
+
): Promise<RunSubprocessResult> {
|
|
56
|
+
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
57
|
+
if (opts.env) {
|
|
58
|
+
for (const [k, v] of Object.entries(opts.env)) {
|
|
59
|
+
if (v === undefined) delete env[k];
|
|
60
|
+
else env[k] = v;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const proc = Bun.spawn({
|
|
65
|
+
cmd: opts.cmd,
|
|
66
|
+
cwd: opts.cwd,
|
|
67
|
+
env,
|
|
68
|
+
stdin: opts.stdin === undefined ? undefined : "pipe",
|
|
69
|
+
stdout: "pipe",
|
|
70
|
+
stderr: "pipe",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (opts.stdin !== undefined) {
|
|
74
|
+
proc.stdin!.write(opts.stdin);
|
|
75
|
+
await proc.stdin!.end();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
79
|
+
new Response(proc.stdout).text(),
|
|
80
|
+
new Response(proc.stderr).text(),
|
|
81
|
+
proc.exited,
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
return { exitCode: exitCode ?? -1, stdout, stderr };
|
|
85
|
+
}
|
package/src/triggers-api.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type TriggerInput,
|
|
31
31
|
} from "../core/src/triggers-store.ts";
|
|
32
32
|
import { registerVaultTrigger } from "./triggers.ts";
|
|
33
|
+
import { SUPPORTED_OPS } from "../core/src/query-operators.ts";
|
|
33
34
|
import { hasScopeForVault, SCOPE_ADMIN } from "./scopes.ts";
|
|
34
35
|
import type { AuthResult } from "./auth.ts";
|
|
35
36
|
|
|
@@ -135,6 +136,29 @@ function validateInput(body: unknown):
|
|
|
135
136
|
if (typeof b.when !== "object" || b.when === null || Array.isArray(b.when)) {
|
|
136
137
|
return { ok: false, message: "`when` is required and must be an object" };
|
|
137
138
|
}
|
|
139
|
+
// Value-matched metadata predicate (vault#299 Part B): when present, each
|
|
140
|
+
// field must map to an operator-object whose keys are all supported
|
|
141
|
+
// operators. Fail at registration so a typo'd operator (`{ state: { equals:
|
|
142
|
+
// ... } }`) is a 400, not a silently-never-firing trigger.
|
|
143
|
+
const whenMeta = (b.when as Record<string, unknown>).metadata;
|
|
144
|
+
if (whenMeta !== undefined) {
|
|
145
|
+
if (typeof whenMeta !== "object" || whenMeta === null || Array.isArray(whenMeta)) {
|
|
146
|
+
return { ok: false, message: "`when.metadata` must be an object mapping field → operator-object" };
|
|
147
|
+
}
|
|
148
|
+
for (const [field, opObj] of Object.entries(whenMeta as Record<string, unknown>)) {
|
|
149
|
+
if (typeof opObj !== "object" || opObj === null || Array.isArray(opObj)) {
|
|
150
|
+
return { ok: false, message: `\`when.metadata.${field}\` must be an operator-object, e.g. { eq: "published" }` };
|
|
151
|
+
}
|
|
152
|
+
for (const op of Object.keys(opObj as Record<string, unknown>)) {
|
|
153
|
+
if (!SUPPORTED_OPS.includes(op as (typeof SUPPORTED_OPS)[number])) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
message: `\`when.metadata.${field}\` has unknown operator "${op}". Supported: ${SUPPORTED_OPS.join(", ")}.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
138
162
|
|
|
139
163
|
if (typeof b.action !== "object" || b.action === null || Array.isArray(b.action)) {
|
|
140
164
|
return { ok: false, message: "`action` is required and must be an object" };
|
package/src/triggers.test.ts
CHANGED
|
@@ -95,6 +95,39 @@ describe("buildPredicate", () => {
|
|
|
95
95
|
expect(pred(makeNote({ tags: ["reader"] }))).toBe(false);
|
|
96
96
|
expect(pred(makeNote({ tags: ["reader", "important"] }))).toBe(true);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
// Value-matched metadata grammar (vault#299 Part B).
|
|
100
|
+
it("metadata eq: fires only when the field equals the value", () => {
|
|
101
|
+
const pred = buildPredicate({ metadata: { state: { eq: "published" } } }, "publish_hook");
|
|
102
|
+
expect(pred(makeNote({ metadata: { state: "published" } }))).toBe(true);
|
|
103
|
+
expect(pred(makeNote({ metadata: { state: "drafted" } }))).toBe(false);
|
|
104
|
+
expect(pred(makeNote({ metadata: {} }))).toBe(false); // absent → no fire
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("metadata in: fires on membership", () => {
|
|
108
|
+
const pred = buildPredicate({ metadata: { state: { in: ["produced", "published"] } } }, "h");
|
|
109
|
+
expect(pred(makeNote({ metadata: { state: "produced" } }))).toBe(true);
|
|
110
|
+
expect(pred(makeNote({ metadata: { state: "idea" } }))).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("metadata combines with tag + presence filters (all must hold)", () => {
|
|
114
|
+
const pred = buildPredicate(
|
|
115
|
+
{ tags: ["piece"], metadata: { state: { eq: "published" } }, missing_metadata: ["notified_at"] },
|
|
116
|
+
"h",
|
|
117
|
+
);
|
|
118
|
+
expect(pred(makeNote({ tags: ["piece"], metadata: { state: "published" } }))).toBe(true);
|
|
119
|
+
// right state, wrong tag.
|
|
120
|
+
expect(pred(makeNote({ tags: ["other"], metadata: { state: "published" } }))).toBe(false);
|
|
121
|
+
// right tag + state but already notified.
|
|
122
|
+
expect(
|
|
123
|
+
pred(makeNote({ tags: ["piece"], metadata: { state: "published", notified_at: "x" } })),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("a malformed operator never throws from the predicate (treated as no-match)", () => {
|
|
128
|
+
const pred = buildPredicate({ metadata: { state: { bogus: "x" } as any } }, "h");
|
|
129
|
+
expect(pred(makeNote({ metadata: { state: "published" } }))).toBe(false);
|
|
130
|
+
});
|
|
98
131
|
});
|
|
99
132
|
|
|
100
133
|
describe("registerTriggers — dispatch modes", async () => {
|
package/src/triggers.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
|
30
30
|
import crypto from "node:crypto";
|
|
31
31
|
import type { Note, Store, Attachment } from "../core/src/types.ts";
|
|
32
32
|
import type { HookRegistry, HookEvent, NoteHookPayload } from "../core/src/hooks.ts";
|
|
33
|
+
import { matchesOperator } from "../core/src/query-operators.ts";
|
|
33
34
|
import type { TriggerConfig, TriggerWhen } from "./config.ts";
|
|
34
35
|
import type { StoredTrigger } from "../core/src/triggers-store.ts";
|
|
35
36
|
import { getVaultNameForStore } from "./vault-store.ts";
|
|
@@ -106,6 +107,22 @@ export function buildPredicate(when: TriggerWhen, triggerName: string): (note: N
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
// Value-matched metadata filter (vault#299 Part B). Each field's
|
|
111
|
+
// operator-object is evaluated against the note's live metadata with the
|
|
112
|
+
// SAME engine query-notes uses. ALL fields must match (AND). A malformed
|
|
113
|
+
// operator throws at registration-time validation, not here — by the time
|
|
114
|
+
// the predicate runs the shape is known-good, but we guard defensively so
|
|
115
|
+
// a bad config can never crash the hook dispatch loop (treat as no-match).
|
|
116
|
+
if (when.metadata) {
|
|
117
|
+
for (const [field, opObj] of Object.entries(when.metadata)) {
|
|
118
|
+
try {
|
|
119
|
+
if (!matchesOperator(field, meta?.[field], opObj)) return false;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
return true;
|
|
110
127
|
};
|
|
111
128
|
}
|
package/src/vault-remove.test.ts
CHANGED
|
@@ -141,13 +141,12 @@ describe("vault remove — last-vault auto_create marker", () => {
|
|
|
141
141
|
// resurrection of a freshly-credentialed "default".
|
|
142
142
|
expect(bootAutoCreateAllowed(config)).toBe(false);
|
|
143
143
|
|
|
144
|
-
// services.json was still refreshed: with zero vaults the row
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// registration shape (and the hub still sees the module as installed).
|
|
144
|
+
// services.json was still refreshed: with zero vaults the row carries
|
|
145
|
+
// paths: [] (#478) — the row stays present so the hub sees the module as
|
|
146
|
+
// installed, but advertises no /vault/<name> path (no phantom default).
|
|
148
147
|
const vault = readServices(home).find((s) => s.name === "parachute-vault");
|
|
149
148
|
expect(vault).toBeDefined();
|
|
150
|
-
expect(vault!.paths).toEqual([
|
|
149
|
+
expect(vault!.paths).toEqual([]);
|
|
151
150
|
});
|
|
152
151
|
|
|
153
152
|
test("removing a NON-last vault does not write the marker", () => {
|
package/src/vault.test.ts
CHANGED
|
@@ -1393,6 +1393,99 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1393
1393
|
closeAllStores();
|
|
1394
1394
|
});
|
|
1395
1395
|
|
|
1396
|
+
// -- Q6 through the MCP TRANSPORT (vault#325 Part 2) --------------------
|
|
1397
|
+
//
|
|
1398
|
+
// The two tests above call `tool.execute()` directly — that exercises the
|
|
1399
|
+
// tag-scope WRAPPER but bypasses `handleScopedMcp`: the JSON-RPC transport,
|
|
1400
|
+
// the `hasScopeForVault` tool-visibility gate, and the tools/call dispatch.
|
|
1401
|
+
// The HTTP-layer twin lives in `routing.test.ts` (Q6 read-path). What was
|
|
1402
|
+
// missing — and what this pins — is the orphan-sub-tag fail-open driven
|
|
1403
|
+
// through the actual MCP `tools/call` path a real client hits. Same fixture
|
|
1404
|
+
// as the HTTP test (`#health/food` with no `_tags/health/food` schema,
|
|
1405
|
+
// token allowlisted for `health`), different transport.
|
|
1406
|
+
|
|
1407
|
+
test("MCP query-notes (tools/call) sees orphan sub-tag via string-form root (vault#325 Part 2)", async () => {
|
|
1408
|
+
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
1409
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1410
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1411
|
+
|
|
1412
|
+
const vaultName = `mcp-orphan-query-${Date.now()}`;
|
|
1413
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1414
|
+
const store = getVaultStore(vaultName);
|
|
1415
|
+
// No `_tags/health/food` schema — this is the orphan case. The hierarchy
|
|
1416
|
+
// is implicit, so authorization must fall back to the string-form root.
|
|
1417
|
+
const orphan = await store.createNote("orphan", { tags: ["health/food"] });
|
|
1418
|
+
|
|
1419
|
+
const auth = {
|
|
1420
|
+
permission: "read" as const,
|
|
1421
|
+
scopes: ["vault:read"],
|
|
1422
|
+
legacyDerived: false,
|
|
1423
|
+
scoped_tags: ["health"],
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
// The URL is inert here — handleScopedMcp hands it to the SDK
|
|
1427
|
+
// transport, which only reads the body; the `vaultName` route wiring
|
|
1428
|
+
// is exercised by routing.test.ts, not this transport-level test. The
|
|
1429
|
+
// `accept` header is load-bearing: `enableJsonResponse` returns JSON
|
|
1430
|
+
// only when text/event-stream is also acceptable, else it streams SSE.
|
|
1431
|
+
const callReq = (id: number, name: string, args: Record<string, unknown>) =>
|
|
1432
|
+
new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
|
|
1433
|
+
method: "POST",
|
|
1434
|
+
headers: {
|
|
1435
|
+
"content-type": "application/json",
|
|
1436
|
+
accept: "application/json, text/event-stream",
|
|
1437
|
+
},
|
|
1438
|
+
body: JSON.stringify({
|
|
1439
|
+
jsonrpc: "2.0",
|
|
1440
|
+
id,
|
|
1441
|
+
method: "tools/call",
|
|
1442
|
+
params: { name, arguments: args },
|
|
1443
|
+
}),
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
// Fetch the orphan by id through the MCP transport with a token scoped
|
|
1447
|
+
// to ["health"]. The orphan is tagged `health/food` (no schema) → the
|
|
1448
|
+
// string-form fallback resolves the root `health` → in allowlist → the
|
|
1449
|
+
// note must come back rather than 404/forbidden.
|
|
1450
|
+
const res = await handleScopedMcp(callReq(1, "query-notes", { id: orphan.id }), vaultName, auth as any);
|
|
1451
|
+
expect(res.status).toBe(200);
|
|
1452
|
+
const body = (await res.json()) as any;
|
|
1453
|
+
// tools/call returns content[].text with the JSON-stringified tool result.
|
|
1454
|
+
expect(body.result?.isError).toBeFalsy();
|
|
1455
|
+
const text: string = body.result.content[0].text;
|
|
1456
|
+
const parsed = JSON.parse(text);
|
|
1457
|
+
expect(parsed.id).toBe(orphan.id);
|
|
1458
|
+
expect(parsed.tags).toContain("health/food");
|
|
1459
|
+
|
|
1460
|
+
// Control: a token scoped to a DIFFERENT root must NOT see the orphan
|
|
1461
|
+
// through the same transport — proves the green result above is the
|
|
1462
|
+
// fail-open fallback firing, not scoping being inert.
|
|
1463
|
+
const denied = {
|
|
1464
|
+
permission: "read" as const,
|
|
1465
|
+
scopes: ["vault:read"],
|
|
1466
|
+
legacyDerived: false,
|
|
1467
|
+
scoped_tags: ["work"],
|
|
1468
|
+
};
|
|
1469
|
+
const resDenied = await handleScopedMcp(
|
|
1470
|
+
callReq(2, "query-notes", { id: orphan.id }),
|
|
1471
|
+
vaultName,
|
|
1472
|
+
denied as any,
|
|
1473
|
+
);
|
|
1474
|
+
expect(resDenied.status).toBe(200);
|
|
1475
|
+
const deniedBody = (await resDenied.json()) as any;
|
|
1476
|
+
const deniedText: string = deniedBody.result.content[0].text;
|
|
1477
|
+
const deniedParsed = JSON.parse(deniedText);
|
|
1478
|
+
// Out-of-scope single-note fetch fails closed: the wrapper replaces the
|
|
1479
|
+
// note body with `{ error: "Note not found" }` (no content/tags leak).
|
|
1480
|
+
// This proves the `health`-scoped green result above is the fail-open
|
|
1481
|
+
// string-form fallback firing, not the wrapper being a no-op.
|
|
1482
|
+
expect(deniedParsed.error).toBe("Note not found");
|
|
1483
|
+
expect(deniedParsed.content).toBeUndefined();
|
|
1484
|
+
expect(deniedParsed.tags).toBeUndefined();
|
|
1485
|
+
|
|
1486
|
+
closeAllStores();
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1396
1489
|
// -- Q5: MCP delete-tag dependency check -------------------------------
|
|
1397
1490
|
|
|
1398
1491
|
test("MCP delete-tag returns tag_in_use_by_tokens when a vestigial tag-scoped token row references the tag", async () => {
|
|
@@ -1769,13 +1862,15 @@ describe("HTTP /notes", async () => {
|
|
|
1769
1862
|
expect(body.map((n) => n.content)).toEqual(["plain"]);
|
|
1770
1863
|
});
|
|
1771
1864
|
|
|
1772
|
-
// ---- updated_at filter via
|
|
1865
|
+
// ---- updated_at filter via bracket date (vault#285 friction point 1.5) ----
|
|
1773
1866
|
//
|
|
1774
|
-
// HTTP plumbing routes `
|
|
1775
|
-
//
|
|
1776
|
-
//
|
|
1777
|
-
//
|
|
1778
|
-
|
|
1867
|
+
// HTTP plumbing routes `meta[updated_at][gte]=…` straight to the core
|
|
1868
|
+
// `dateFilter` resolver, which recognizes `updated_at` as a real column.
|
|
1869
|
+
// Smoke-tests the end-to-end HTTP path; the engine-side semantics are
|
|
1870
|
+
// exercised in core.test.ts. (The flat `date_field=updated_at&date_from=…`
|
|
1871
|
+
// shape was removed in 0.6.4 — vault#288 — bracket-style is the only
|
|
1872
|
+
// query-string date filter.)
|
|
1873
|
+
test("GET /notes?meta[updated_at][gte]=… filters by last-write time", async () => {
|
|
1779
1874
|
const a = await store.createNote("untouched", { id: "ua", path: "ua" });
|
|
1780
1875
|
const b = await store.createNote("modified", { id: "ub", path: "ub" });
|
|
1781
1876
|
// Bump b's updated_at into the test window, leave a's at its createdAt.
|
|
@@ -1785,7 +1880,7 @@ describe("HTTP /notes", async () => {
|
|
|
1785
1880
|
.run("2026-04-25T00:00:00.000Z", b.id);
|
|
1786
1881
|
|
|
1787
1882
|
const res = await handleNotes(
|
|
1788
|
-
mkReq("GET", "/notes?
|
|
1883
|
+
mkReq("GET", "/notes?meta[updated_at][gte]=2026-04-01&include_content=true"),
|
|
1789
1884
|
store,
|
|
1790
1885
|
"",
|
|
1791
1886
|
);
|
|
@@ -2787,7 +2882,7 @@ describe("HTTP /notes", async () => {
|
|
|
2787
2882
|
});
|
|
2788
2883
|
|
|
2789
2884
|
// ---- Bridge: created_at / updated_at via brackets route to dateFilter ----
|
|
2790
|
-
test("`meta[created_at][gte]=…` routes to dateFilter
|
|
2885
|
+
test("`meta[created_at][gte]=…` routes to dateFilter", async () => {
|
|
2791
2886
|
await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
2792
2887
|
await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2793
2888
|
|
|
@@ -2797,14 +2892,37 @@ describe("HTTP /notes", async () => {
|
|
|
2797
2892
|
"",
|
|
2798
2893
|
);
|
|
2799
2894
|
const bracketBody = await bracketRes.json() as any[];
|
|
2800
|
-
|
|
2895
|
+
expect(bracketBody.map((n) => n.content)).toEqual(["new"]);
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
// ---- Removed: flat date params are now ignored (vault#288, breaking) ----
|
|
2899
|
+
// The flat `date_field` / `date_from` / `date_to` query params were
|
|
2900
|
+
// removed in 0.6.4. A request that passes ONLY the flat shape is no
|
|
2901
|
+
// longer date-filtered — it comes back unfiltered. Use bracket-style
|
|
2902
|
+
// (`meta[created_at][gte]=…`) instead. (The MCP `date_from`/`date_to`
|
|
2903
|
+
// shorthand is a separate, supported path and is unaffected.)
|
|
2904
|
+
test("flat date params (date_field/date_from/date_to) are ignored", async () => {
|
|
2905
|
+
await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
2906
|
+
await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2907
|
+
|
|
2908
|
+
const targetedRes = await handleNotes(
|
|
2801
2909
|
mkReq("GET", "/notes?date_field=created_at&date_from=2026-04-01&include_content=true"),
|
|
2802
2910
|
store,
|
|
2803
2911
|
"",
|
|
2804
2912
|
);
|
|
2805
|
-
const
|
|
2806
|
-
expect(
|
|
2807
|
-
|
|
2913
|
+
const targetedBody = await targetedRes.json() as any[];
|
|
2914
|
+
expect(targetedRes.status).toBe(200);
|
|
2915
|
+
// Both notes returned — the flat param did not filter.
|
|
2916
|
+
expect(targetedBody.map((n) => n.content).sort()).toEqual(["new", "old"]);
|
|
2917
|
+
|
|
2918
|
+
const bareRes = await handleNotes(
|
|
2919
|
+
mkReq("GET", "/notes?date_from=2026-04-01&include_content=true"),
|
|
2920
|
+
store,
|
|
2921
|
+
"",
|
|
2922
|
+
);
|
|
2923
|
+
const bareBody = await bareRes.json() as any[];
|
|
2924
|
+
expect(bareRes.status).toBe(200);
|
|
2925
|
+
expect(bareBody.map((n) => n.content).sort()).toEqual(["new", "old"]);
|
|
2808
2926
|
});
|
|
2809
2927
|
|
|
2810
2928
|
test("`meta[updated_at][gte]=…` routes to dateFilter on n.updated_at", async () => {
|
|
@@ -2954,13 +3072,14 @@ describe("HTTP /notes", async () => {
|
|
|
2954
3072
|
expect(body.error).toContain("not_in");
|
|
2955
3073
|
});
|
|
2956
3074
|
|
|
2957
|
-
// ----
|
|
2958
|
-
test("
|
|
3075
|
+
// ---- Bracket applies; co-passed flat params are ignored (vault#288) ----
|
|
3076
|
+
test("bracket date filter applies even when removed flat params are also passed", async () => {
|
|
2959
3077
|
await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
2960
3078
|
await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2961
|
-
// Bracket says "from 2026-04-01"; flat
|
|
2962
|
-
// flat
|
|
2963
|
-
//
|
|
3079
|
+
// Bracket says "from 2026-04-01"; the (removed) flat params say "from
|
|
3080
|
+
// 2020-01-01". The flat params are now inert, so only the bracket
|
|
3081
|
+
// filter applies — back comes only the post-April note. (Pre-0.6.4
|
|
3082
|
+
// this exercised bracket-wins precedence; flat is now simply ignored.)
|
|
2964
3083
|
const res = await handleNotes(
|
|
2965
3084
|
mkReq(
|
|
2966
3085
|
"GET",
|
|
@@ -4012,6 +4131,36 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
4012
4131
|
expect(body.metadata).toEqual({ a: 1, b: 2 });
|
|
4013
4132
|
});
|
|
4014
4133
|
|
|
4134
|
+
test("PATCH metadata null DELETES the key (RFC 7386), not a literal null (vault#478/#479)", async () => {
|
|
4135
|
+
await store.createNote("doc", { id: "x", metadata: { keep: "yes", drop: "old", n: 3 } });
|
|
4136
|
+
const res = await handleNotes(
|
|
4137
|
+
mkReq("PATCH", "/notes/x", { metadata: { drop: null }, force: true }),
|
|
4138
|
+
store,
|
|
4139
|
+
"/x",
|
|
4140
|
+
);
|
|
4141
|
+
expect(res.status).toBe(200);
|
|
4142
|
+
const body = await res.json() as any;
|
|
4143
|
+
// Key removed entirely — must NOT survive as a literal JSON null.
|
|
4144
|
+
expect(body.metadata).not.toHaveProperty("drop");
|
|
4145
|
+
expect(body.metadata).toEqual({ keep: "yes", n: 3 });
|
|
4146
|
+
// Persisted state matches the response (round-trips).
|
|
4147
|
+
const fresh = await store.getNote("x");
|
|
4148
|
+
expect(fresh!.metadata).not.toHaveProperty("drop");
|
|
4149
|
+
expect(fresh!.metadata).toEqual({ keep: "yes", n: 3 });
|
|
4150
|
+
});
|
|
4151
|
+
|
|
4152
|
+
test("PATCH metadata key-rename in one call: set new, null-delete old (vault#478)", async () => {
|
|
4153
|
+
await store.createNote("doc", { id: "x", metadata: { "old-key": "v", stable: true } });
|
|
4154
|
+
const res = await handleNotes(
|
|
4155
|
+
mkReq("PATCH", "/notes/x", { metadata: { new_key: "v", "old-key": null }, force: true }),
|
|
4156
|
+
store,
|
|
4157
|
+
"/x",
|
|
4158
|
+
);
|
|
4159
|
+
const body = await res.json() as any;
|
|
4160
|
+
expect(body.metadata).not.toHaveProperty("old-key");
|
|
4161
|
+
expect(body.metadata).toEqual({ new_key: "v", stable: true });
|
|
4162
|
+
});
|
|
4163
|
+
|
|
4015
4164
|
test("PATCH adds/removes tags", async () => {
|
|
4016
4165
|
await store.createNote("x", { id: "x", tags: ["old"] });
|
|
4017
4166
|
const res = await handleNotes(
|
|
@@ -4789,6 +4938,28 @@ describe("HTTP /tags", async () => {
|
|
|
4789
4938
|
.filter((n) => n.startsWith("meta_"));
|
|
4790
4939
|
}
|
|
4791
4940
|
|
|
4941
|
+
test("PUT /tags/:name with a bad indexed-field name returns 400 + leaves schema unchanged (vault#478)", async () => {
|
|
4942
|
+
// kebab-case indexed field violates [A-Za-z0-9_]. Pre-fix this persisted
|
|
4943
|
+
// the declaration then 500'd on index creation, leaving a tag claiming an
|
|
4944
|
+
// index the engine couldn't build (the "lying schema" loop).
|
|
4945
|
+
const res = await handleTags(
|
|
4946
|
+
mkReq("PUT", "/tags/meeting", { fields: { "meeting-type": { type: "string", indexed: true } } }),
|
|
4947
|
+
store,
|
|
4948
|
+
"/meeting",
|
|
4949
|
+
);
|
|
4950
|
+
expect(res.status).toBe(400);
|
|
4951
|
+
const body = await res.json() as any;
|
|
4952
|
+
expect(body.error_type).toBe("invalid_indexed_field");
|
|
4953
|
+
expect(body.error).toMatch(/invalid field name/);
|
|
4954
|
+
|
|
4955
|
+
// Schema is untouched — no poisoned field declared.
|
|
4956
|
+
const record = await store.getTagRecord("meeting");
|
|
4957
|
+
expect(record?.fields?.["meeting-type"]).toBeUndefined();
|
|
4958
|
+
// No orphan/lying index: neither the generated column nor an indexed_fields row.
|
|
4959
|
+
expect(notesMetaCols()).not.toContain("meta_meeting-type");
|
|
4960
|
+
expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("meeting-type");
|
|
4961
|
+
});
|
|
4962
|
+
|
|
4792
4963
|
test("PUT /tags/:name {fields:null} drops the orphaned generated column", async () => {
|
|
4793
4964
|
// Declare an indexed field via REST PUT — column materializes.
|
|
4794
4965
|
await handleTags(
|