@openparachute/vault 0.6.4-rc.4 → 0.6.4-rc.6
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 +126 -0
- package/core/src/mcp.ts +8 -4
- package/core/src/notes.ts +37 -0
- package/core/src/store.ts +47 -18
- package/package.json +1 -1
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/module-config.ts +46 -80
- package/src/routes.ts +28 -9
- package/src/routing.test.ts +42 -25
- package/src/scale.bench.test.ts +82 -0
- package/src/test-support/spawn.ts +85 -0
- package/src/vault.test.ts +145 -0
package/core/src/core.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { generateMcpTools } from "./mcp.js";
|
|
|
5
5
|
import { initSchema } from "./schema.js";
|
|
6
6
|
import { decodeCursor } from "./cursor.js";
|
|
7
7
|
import { traverseLinks } from "./links.js";
|
|
8
|
+
import * as indexedFieldOps from "./indexed-fields.js";
|
|
8
9
|
|
|
9
10
|
let store: SqliteStore;
|
|
10
11
|
let db: Database;
|
|
@@ -2169,6 +2170,50 @@ describe("MCP tools", async () => {
|
|
|
2169
2170
|
expect(result.extension).toBe("csv");
|
|
2170
2171
|
});
|
|
2171
2172
|
|
|
2173
|
+
// ---- Metadata key-deletion via null tombstone (vault#478 / #479) ---------
|
|
2174
|
+
|
|
2175
|
+
it("update-note deletes a metadata key when its value is null (RFC 7386)", async () => {
|
|
2176
|
+
const note = await store.createNote("m", {
|
|
2177
|
+
metadata: { keep: "yes", drop: "old", other: 1 },
|
|
2178
|
+
});
|
|
2179
|
+
const tools = generateMcpTools(store);
|
|
2180
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2181
|
+
await updateNote.execute({ id: note.id, metadata: { drop: null }, force: true });
|
|
2182
|
+
|
|
2183
|
+
const fresh = await store.getNote(note.id);
|
|
2184
|
+
// The deleted key is GONE — not a literal JSON null.
|
|
2185
|
+
expect(fresh!.metadata).not.toHaveProperty("drop");
|
|
2186
|
+
// Other keys are untouched.
|
|
2187
|
+
expect(fresh!.metadata).toMatchObject({ keep: "yes", other: 1 });
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
it("update-note metadata key-rename in one call (new key set, old key deleted)", async () => {
|
|
2191
|
+
const note = await store.createNote("m", {
|
|
2192
|
+
metadata: { "old-key": "v", stable: true },
|
|
2193
|
+
});
|
|
2194
|
+
const tools = generateMcpTools(store);
|
|
2195
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2196
|
+
await updateNote.execute({
|
|
2197
|
+
id: note.id,
|
|
2198
|
+
metadata: { new_key: "v", "old-key": null },
|
|
2199
|
+
force: true,
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
const fresh = await store.getNote(note.id);
|
|
2203
|
+
expect(fresh!.metadata).not.toHaveProperty("old-key");
|
|
2204
|
+
expect(fresh!.metadata).toMatchObject({ new_key: "v", stable: true });
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
it("deleting an absent metadata key is a no-op (round-trips cleanly)", async () => {
|
|
2208
|
+
const note = await store.createNote("m", { metadata: { a: 1 } });
|
|
2209
|
+
const tools = generateMcpTools(store);
|
|
2210
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2211
|
+
await updateNote.execute({ id: note.id, metadata: { ghost: null }, force: true });
|
|
2212
|
+
const fresh = await store.getNote(note.id);
|
|
2213
|
+
expect(fresh!.metadata).toEqual({ a: 1 });
|
|
2214
|
+
expect(fresh!.metadata).not.toHaveProperty("ghost");
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2172
2217
|
it("query-notes filters by extension (vault#328)", async () => {
|
|
2173
2218
|
await store.createNote("md note", { path: "a" });
|
|
2174
2219
|
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
@@ -5301,6 +5346,87 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
|
5301
5346
|
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
5302
5347
|
expect(await store.getTagRecord("voice")).toBeNull();
|
|
5303
5348
|
});
|
|
5349
|
+
|
|
5350
|
+
// ---- Indexed-field atomicity (vault#478) ----------------------------------
|
|
5351
|
+
|
|
5352
|
+
it("upsertTagRecord with a bad indexed-field name throws IndexedFieldError and leaves the schema unchanged", async () => {
|
|
5353
|
+
// kebab-case field name violates [A-Za-z0-9_] / no-leading-digit. The
|
|
5354
|
+
// declaration must NOT persist, and no orphan/lying index may be created.
|
|
5355
|
+
await expect(
|
|
5356
|
+
store.upsertTagRecord("meeting", {
|
|
5357
|
+
description: "meetings",
|
|
5358
|
+
fields: { "meeting-type": { type: "string", indexed: true } },
|
|
5359
|
+
}),
|
|
5360
|
+
).rejects.toThrow(indexedFieldOps.IndexedFieldError);
|
|
5361
|
+
|
|
5362
|
+
// Schema is untouched — the tag row may not even exist, and definitely
|
|
5363
|
+
// doesn't claim the poisoned field.
|
|
5364
|
+
const record = await store.getTagRecord("meeting");
|
|
5365
|
+
expect(record?.fields?.["meeting-type"]).toBeUndefined();
|
|
5366
|
+
|
|
5367
|
+
// No backing index row was created for the bad field.
|
|
5368
|
+
expect(indexedFieldOps.getIndexedField(db, "meeting-type")).toBeNull();
|
|
5369
|
+
// And the generated column doesn't exist either.
|
|
5370
|
+
const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
|
|
5371
|
+
(c) => c.name,
|
|
5372
|
+
);
|
|
5373
|
+
expect(cols).not.toContain("meta_meeting-type");
|
|
5374
|
+
});
|
|
5375
|
+
|
|
5376
|
+
it("upsertTagRecord with an unindexable type throws and leaves the schema unchanged", async () => {
|
|
5377
|
+
await expect(
|
|
5378
|
+
store.upsertTagRecord("meeting", {
|
|
5379
|
+
fields: { attendees: { type: "json", indexed: true } },
|
|
5380
|
+
}),
|
|
5381
|
+
).rejects.toThrow(indexedFieldOps.IndexedFieldError);
|
|
5382
|
+
const record = await store.getTagRecord("meeting");
|
|
5383
|
+
expect(record?.fields?.attendees).toBeUndefined();
|
|
5384
|
+
expect(indexedFieldOps.getIndexedField(db, "attendees")).toBeNull();
|
|
5385
|
+
});
|
|
5386
|
+
|
|
5387
|
+
it("a bad indexed field does NOT corrupt a prior valid declaration on the same tag", async () => {
|
|
5388
|
+
// First, a clean valid indexed field.
|
|
5389
|
+
await store.upsertTagRecord("meeting", {
|
|
5390
|
+
fields: { held_on: { type: "string", indexed: true } },
|
|
5391
|
+
});
|
|
5392
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
|
|
5393
|
+
|
|
5394
|
+
// Now an update that adds a poisoned field. The whole write must roll
|
|
5395
|
+
// back — the prior valid field survives untouched.
|
|
5396
|
+
await expect(
|
|
5397
|
+
store.upsertTagRecord("meeting", {
|
|
5398
|
+
fields: {
|
|
5399
|
+
held_on: { type: "string", indexed: true },
|
|
5400
|
+
"bad-field": { type: "string", indexed: true },
|
|
5401
|
+
},
|
|
5402
|
+
}),
|
|
5403
|
+
).rejects.toThrow(indexedFieldOps.IndexedFieldError);
|
|
5404
|
+
|
|
5405
|
+
const record = await store.getTagRecord("meeting");
|
|
5406
|
+
expect(record?.fields?.held_on?.indexed).toBe(true);
|
|
5407
|
+
expect(record?.fields?.["bad-field"]).toBeUndefined();
|
|
5408
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
|
|
5409
|
+
expect(indexedFieldOps.getIndexedField(db, "bad-field")).toBeNull();
|
|
5410
|
+
});
|
|
5411
|
+
|
|
5412
|
+
it("upsertTagRecord with fields:null releases indexed fields (transaction commits the release path)", async () => {
|
|
5413
|
+
// Declare an indexed field, then clear all fields — the release path runs
|
|
5414
|
+
// inside the same transaction as the persist. It must commit cleanly:
|
|
5415
|
+
// the row, generated column, and index all drop.
|
|
5416
|
+
await store.upsertTagRecord("meeting", {
|
|
5417
|
+
fields: { held_on: { type: "string", indexed: true } },
|
|
5418
|
+
});
|
|
5419
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
|
|
5420
|
+
|
|
5421
|
+
await store.upsertTagRecord("meeting", { fields: null });
|
|
5422
|
+
const record = await store.getTagRecord("meeting");
|
|
5423
|
+
expect(record?.fields).toBeUndefined();
|
|
5424
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")).toBeNull();
|
|
5425
|
+
const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
|
|
5426
|
+
(c) => c.name,
|
|
5427
|
+
);
|
|
5428
|
+
expect(cols).not.toContain("meta_held_on");
|
|
5429
|
+
});
|
|
5304
5430
|
});
|
|
5305
5431
|
|
|
5306
5432
|
// ---------------------------------------------------------------------------
|
package/core/src/mcp.ts
CHANGED
|
@@ -830,7 +830,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
830
830
|
},
|
|
831
831
|
path: { type: "string", description: "New path" },
|
|
832
832
|
extension: { type: "string", description: "Change the note's file extension (vault#328). Allowed but caller-owned — you're responsible for content validity if you switch a non-empty note's extension. Lowercase alphanumeric, 1–16 chars; \"parachute\" prefix reserved." },
|
|
833
|
-
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
|
|
833
|
+
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale). A value of `null` deletes that key (RFC 7386 merge-patch) — e.g. `{\"new_key\": \"v\", \"old_key\": null}` renames in one call. Omitting a key preserves its existing value." },
|
|
834
834
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
835
835
|
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
836
836
|
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
|
|
@@ -1194,9 +1194,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1194
1194
|
updates.extension = validateExtension(item.extension);
|
|
1195
1195
|
}
|
|
1196
1196
|
if (item.metadata !== undefined) {
|
|
1197
|
-
// Merge metadata (
|
|
1198
|
-
|
|
1199
|
-
|
|
1197
|
+
// Merge metadata (RFC 7386: keys are merged, incoming `null`
|
|
1198
|
+
// removes the key rather than persisting a literal null —
|
|
1199
|
+
// vault#478/#479). Mirrors the REST PATCH path.
|
|
1200
|
+
updates.metadata = noteOps.mergeMetadata(
|
|
1201
|
+
note.metadata as Record<string, unknown> | null | undefined,
|
|
1202
|
+
item.metadata as Record<string, unknown>,
|
|
1203
|
+
);
|
|
1200
1204
|
}
|
|
1201
1205
|
if (item.created_at !== undefined) updates.created_at = item.created_at;
|
|
1202
1206
|
if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
|
package/core/src/notes.ts
CHANGED
|
@@ -1831,6 +1831,43 @@ export function filterMetadata(obj: any, includeMetadata: boolean | string[] | u
|
|
|
1831
1831
|
return { ...obj, metadata: Object.keys(filtered).length > 0 ? filtered : undefined };
|
|
1832
1832
|
}
|
|
1833
1833
|
|
|
1834
|
+
/**
|
|
1835
|
+
* Shallow-merge an incoming metadata patch onto a note's existing metadata,
|
|
1836
|
+
* with RFC 7386 (JSON Merge Patch) null-as-delete semantics — the canonical
|
|
1837
|
+
* merge for every metadata write surface (REST `PATCH /api/notes`, MCP
|
|
1838
|
+
* `update-note`, batch).
|
|
1839
|
+
*
|
|
1840
|
+
* An incoming value of `null` is a DELETE tombstone: the key is removed from
|
|
1841
|
+
* the result rather than persisted as a literal JSON `null`. This is the only
|
|
1842
|
+
* way to remove a metadata key through the API — under a plain
|
|
1843
|
+
* `{ ...existing, ...incoming }` merge, omission can't delete (it preserves the
|
|
1844
|
+
* prior value) and `null` used to persist literally, leaving no removal path.
|
|
1845
|
+
* Now key renames are pure-API: `{ new_key: "v", "old-key": null }` adds the
|
|
1846
|
+
* new key and drops the old one in one PATCH. See vault#478 / #479.
|
|
1847
|
+
*
|
|
1848
|
+
* Shallow by design — top-level keys only, matching the existing wholesale
|
|
1849
|
+
* top-level merge. A nested object value replaces its key wholesale (we do not
|
|
1850
|
+
* recurse), so an incoming `null` removes the whole key regardless of depth.
|
|
1851
|
+
* Compat: storing a literal null is no longer possible via this path; the
|
|
1852
|
+
* boulder migration confirmed zero callers relied on that (vault#478). A caller
|
|
1853
|
+
* that genuinely needs an absent-vs-null distinction should model it with a
|
|
1854
|
+
* sentinel string, not a JSON null.
|
|
1855
|
+
*/
|
|
1856
|
+
export function mergeMetadata(
|
|
1857
|
+
existing: Record<string, unknown> | null | undefined,
|
|
1858
|
+
incoming: Record<string, unknown>,
|
|
1859
|
+
): Record<string, unknown> {
|
|
1860
|
+
const result: Record<string, unknown> = { ...(existing ?? {}) };
|
|
1861
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
1862
|
+
if (value === null) {
|
|
1863
|
+
delete result[key];
|
|
1864
|
+
} else {
|
|
1865
|
+
result[key] = value;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return result;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1834
1871
|
// ---- Vault stats (aggregate situational awareness) ----
|
|
1835
1872
|
|
|
1836
1873
|
/**
|
package/core/src/store.ts
CHANGED
|
@@ -631,36 +631,65 @@ export class BunSqliteStore implements Store {
|
|
|
631
631
|
const priorRecord =
|
|
632
632
|
patch.fields !== undefined ? tagSchemaOps.getTagRecord(this.db, tag) : null;
|
|
633
633
|
|
|
634
|
-
const
|
|
635
|
-
|
|
634
|
+
const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
|
|
635
|
+
new Set(
|
|
636
|
+
Object.entries(fields ?? {})
|
|
637
|
+
.filter(([, v]) => v.indexed === true)
|
|
638
|
+
.map(([k]) => k),
|
|
639
|
+
);
|
|
640
|
+
const nextFields = patch.fields; // object | null | undefined
|
|
641
|
+
const priorIndexed = indexedSet(priorRecord?.fields);
|
|
642
|
+
const nextIndexed = indexedSet(nextFields);
|
|
643
|
+
|
|
644
|
+
// PRE-VALIDATE every newly-indexed field BEFORE any persistence. A bad
|
|
645
|
+
// field name (or unmappable type) must fail closed — the schema record
|
|
646
|
+
// must NOT be written when the backing index can't be created. Pre-checking
|
|
647
|
+
// here, before the transaction even opens, turns the failure into a clean
|
|
648
|
+
// caller error (IndexedFieldError → 400) and leaves the schema untouched.
|
|
649
|
+
// Without this, the prior code persisted the field declaration, THEN threw
|
|
650
|
+
// on declareField — a 500 plus a tag claiming an index the engine can't
|
|
651
|
+
// build (the "lying schema" loop). See vault#478.
|
|
636
652
|
if (patch.fields !== undefined) {
|
|
637
|
-
const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
|
|
638
|
-
new Set(
|
|
639
|
-
Object.entries(fields ?? {})
|
|
640
|
-
.filter(([, v]) => v.indexed === true)
|
|
641
|
-
.map(([k]) => k),
|
|
642
|
-
);
|
|
643
|
-
const nextFields = patch.fields; // object | null
|
|
644
|
-
const priorIndexed = indexedSet(priorRecord?.fields);
|
|
645
|
-
const nextIndexed = indexedSet(nextFields);
|
|
646
653
|
for (const fieldName of nextIndexed) {
|
|
647
654
|
const spec = nextFields![fieldName]!;
|
|
648
655
|
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
649
|
-
// Unmappable type for indexing is a caller error; surface it rather
|
|
650
|
-
// than silently skipping. MCP/REST validate up-front for a cleaner
|
|
651
|
-
// message, but this is the backstop at the chokepoint.
|
|
652
656
|
if (!mapped) {
|
|
653
657
|
throw new indexedFieldOps.IndexedFieldError(
|
|
654
658
|
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
655
659
|
);
|
|
656
660
|
}
|
|
657
|
-
|
|
661
|
+
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
662
|
+
indexedFieldOps.validateFieldName(fieldName);
|
|
658
663
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Persist the record + reconcile the indexed-field lifecycle atomically.
|
|
667
|
+
// If declareField throws inside the transaction (e.g. a cross-tag type
|
|
668
|
+
// mismatch only detectable once the existing declarer set is consulted),
|
|
669
|
+
// the whole write rolls back — the schema never ends up claiming an index
|
|
670
|
+
// that doesn't exist. vault#478 transactional fix.
|
|
671
|
+
let result: tagSchemaOps.TagRecord;
|
|
672
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
673
|
+
try {
|
|
674
|
+
result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
|
|
675
|
+
|
|
676
|
+
if (patch.fields !== undefined) {
|
|
677
|
+
for (const fieldName of nextIndexed) {
|
|
678
|
+
const spec = nextFields![fieldName]!;
|
|
679
|
+
// Type already validated above; non-null assertion is safe here.
|
|
680
|
+
const mapped = indexedFieldOps.mapFieldType(spec.type)!;
|
|
681
|
+
indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
|
|
682
|
+
}
|
|
683
|
+
for (const fieldName of priorIndexed) {
|
|
684
|
+
if (!nextIndexed.has(fieldName)) {
|
|
685
|
+
indexedFieldOps.releaseField(this.db, fieldName, tag);
|
|
686
|
+
}
|
|
662
687
|
}
|
|
663
688
|
}
|
|
689
|
+
this.db.exec("COMMIT");
|
|
690
|
+
} catch (err) {
|
|
691
|
+
this.db.exec("ROLLBACK");
|
|
692
|
+
throw err;
|
|
664
693
|
}
|
|
665
694
|
|
|
666
695
|
if (patch.parent_names !== undefined) {
|
package/package.json
CHANGED
|
@@ -15,30 +15,21 @@ import { resolve } from "path";
|
|
|
15
15
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
|
16
16
|
import { tmpdir } from "os";
|
|
17
17
|
import { join } from "path";
|
|
18
|
+
import { runSubprocess } from "./test-support/spawn.ts";
|
|
18
19
|
|
|
19
20
|
const CLI = resolve(import.meta.dir, "cli.ts");
|
|
20
21
|
|
|
22
|
+
// Async spawn — the daemon-busy probe inside the CLI fetches the test
|
|
23
|
+
// process's stub server, so the parent event loop must keep servicing
|
|
24
|
+
// requests while the child runs. `Bun.spawnSync` blocks the event loop and
|
|
25
|
+
// the in-test server can't answer, which makes every probe time out into
|
|
26
|
+
// the "not listening" branch (vault#324). `runSubprocess` is the shared
|
|
27
|
+
// safe primitive (vault#325 Part 1) — see src/test-support/spawn.ts.
|
|
21
28
|
async function runCli(
|
|
22
29
|
args: string[],
|
|
23
30
|
env: Record<string, string>,
|
|
24
31
|
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
25
|
-
|
|
26
|
-
// process's stub server, so the parent event loop must keep servicing
|
|
27
|
-
// requests while the child runs. Bun.spawnSync blocks the event loop
|
|
28
|
-
// and the in-test server can't answer, which makes every probe time
|
|
29
|
-
// out into the "not listening" branch.
|
|
30
|
-
const proc = Bun.spawn({
|
|
31
|
-
cmd: ["bun", CLI, ...args],
|
|
32
|
-
stdout: "pipe",
|
|
33
|
-
stderr: "pipe",
|
|
34
|
-
env: { ...process.env, ...env },
|
|
35
|
-
});
|
|
36
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
37
|
-
new Response(proc.stdout).text(),
|
|
38
|
-
new Response(proc.stderr).text(),
|
|
39
|
-
proc.exited,
|
|
40
|
-
]);
|
|
41
|
-
return { exitCode: exitCode ?? -1, stdout, stderr };
|
|
32
|
+
return runSubprocess({ cmd: ["bun", CLI, ...args], env });
|
|
42
33
|
}
|
|
43
34
|
|
|
44
35
|
let home: string;
|
package/src/module-config.ts
CHANGED
|
@@ -14,31 +14,26 @@
|
|
|
14
14
|
*
|
|
15
15
|
* PUT /.parachute/config is Phase 3 — not implemented here.
|
|
16
16
|
*
|
|
17
|
+
* Scope boundary (vault#478): `GET /.parachute/config` is gated by
|
|
18
|
+
* `vault:<name>:admin` — admin over *your* vault only — so it describes and
|
|
19
|
+
* returns ONLY per-vault config, never daemon-GLOBAL settings.
|
|
20
|
+
*
|
|
17
21
|
* Fields currently described:
|
|
18
22
|
* - audio_retention: per-vault enum, backed by VaultConfig.audio_retention.
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* arbitrary scribe; the discovery layer is the gate.
|
|
31
|
-
* - scribeBearer: writeOnly — sourced from SCRIBE_AUTH_TOKEN env var.
|
|
32
|
-
* Hub install generates one at first boot
|
|
33
|
-
* (see scribe-env.ts:ensureScribeBearer); manual
|
|
34
|
-
* rotation is via `parachute-vault config set`.
|
|
35
|
-
* - scribe_url / scribe_token (deprecated): kept under their legacy names
|
|
36
|
-
* through one release for the hub admin SPA's prior
|
|
37
|
-
* render path; new code should read autoTranscribe.*.
|
|
23
|
+
* - autoTranscribe.enabled: per-vault toggle (vault#353). Reports the value
|
|
24
|
+
* THIS vault will use, resolving per-vault override
|
|
25
|
+
* (VaultConfig.auto_transcribe.enabled) → server
|
|
26
|
+
* default (GlobalConfig.auto_transcribe.enabled) →
|
|
27
|
+
* true. The inherited fallback is the per-vault
|
|
28
|
+
* truth, not a leak of the global setting.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately NOT exposed here (daemon-global → operator-only surface):
|
|
31
|
+
* - port: GlobalConfig.port (deployment-wide listen port).
|
|
32
|
+
* - autoTranscribe.scribeUrl / scribe_url: discovery-resolved per-process.
|
|
33
|
+
* - autoTranscribe.scribeBearer / scribe_token: shared SCRIBE_AUTH_TOKEN.
|
|
38
34
|
*/
|
|
39
35
|
|
|
40
36
|
import type { VaultConfig, GlobalConfig } from "./config.ts";
|
|
41
|
-
import { resolveScribeUrl } from "./scribe-discovery.ts";
|
|
42
37
|
|
|
43
38
|
export interface ModuleConfigSchema {
|
|
44
39
|
$schema: string;
|
|
@@ -75,83 +70,54 @@ export function buildConfigSchema(): ModuleConfigSchema {
|
|
|
75
70
|
default: true,
|
|
76
71
|
title: "Enable auto-transcription",
|
|
77
72
|
description:
|
|
78
|
-
"
|
|
79
|
-
},
|
|
80
|
-
scribeUrl: {
|
|
81
|
-
type: "string",
|
|
82
|
-
format: "uri",
|
|
83
|
-
readOnly: true,
|
|
84
|
-
title: "Scribe URL",
|
|
85
|
-
description:
|
|
86
|
-
"URL of the scribe service. Auto-populated from `~/.parachute/services.json` at vault startup (or from the SCRIBE_URL env var when set). Read-only — operators can't point at an arbitrary scribe.",
|
|
87
|
-
},
|
|
88
|
-
scribeBearer: {
|
|
89
|
-
type: "string",
|
|
90
|
-
writeOnly: true,
|
|
91
|
-
title: "Scribe auth bearer",
|
|
92
|
-
description:
|
|
93
|
-
"Shared bearer for the vault→scribe loopback contract. Hub install generates one at first boot. Write-only — never returned by GET.",
|
|
73
|
+
"Per-vault toggle. Default on — audio uploads transcribe automatically when scribe is reachable. Set to false to disable for THIS vault. Resolves per-vault override → server default → on, so leaving it unset inherits the deployment default.",
|
|
94
74
|
},
|
|
95
75
|
},
|
|
96
76
|
},
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
description:
|
|
104
|
-
"Legacy alias for `autoTranscribe.scribeUrl`. Will be removed in a future release.",
|
|
105
|
-
readOnly: true,
|
|
106
|
-
deprecated: true,
|
|
107
|
-
},
|
|
108
|
-
scribe_token: {
|
|
109
|
-
type: "string",
|
|
110
|
-
title: "Scribe auth token (deprecated alias)",
|
|
111
|
-
description:
|
|
112
|
-
"Legacy alias for `autoTranscribe.scribeBearer`. Will be removed in a future release.",
|
|
113
|
-
writeOnly: true,
|
|
114
|
-
deprecated: true,
|
|
115
|
-
},
|
|
116
|
-
port: {
|
|
117
|
-
type: "integer",
|
|
118
|
-
minimum: 1,
|
|
119
|
-
maximum: 65535,
|
|
120
|
-
title: "HTTP port",
|
|
121
|
-
description: "Port the vault server listens on. Set at init time; changing requires a restart.",
|
|
122
|
-
readOnly: true,
|
|
123
|
-
},
|
|
77
|
+
// NOTE (vault#478): daemon-GLOBAL fields (the listen `port`, the
|
|
78
|
+
// discovery-resolved scribe URL, the shared scribe bearer) are
|
|
79
|
+
// deliberately NOT described here. This endpoint is gated by
|
|
80
|
+
// `vault:<name>:admin` — admin over *your* vault only — so it neither
|
|
81
|
+
// describes nor returns deployment-wide settings. Those live behind the
|
|
82
|
+
// operator-only surface (CLI / global config).
|
|
124
83
|
},
|
|
125
84
|
};
|
|
126
85
|
}
|
|
127
86
|
|
|
128
87
|
/**
|
|
129
|
-
* Effective config values
|
|
130
|
-
*
|
|
131
|
-
*
|
|
88
|
+
* Effective config values for ONE vault. The shared scribe bearer is
|
|
89
|
+
* daemon-global and never returned (see the scope boundary below).
|
|
90
|
+
*
|
|
91
|
+
* Scope boundary (vault#478): the `GET /vault/<name>/.parachute/config`
|
|
92
|
+
* endpoint is gated by `vault:<name>:admin` — "admin over *your* vault only".
|
|
93
|
+
* It must therefore return ONLY per-vault config, never daemon-GLOBAL settings
|
|
94
|
+
* (the listen `port`, the discovery-resolved scribe URL, the server-wide
|
|
95
|
+
* auto-transcribe default). Those describe the whole deployment, not this
|
|
96
|
+
* vault, and leaking them across the per-vault admin boundary matters once a
|
|
97
|
+
* shared multi-vault daemon hands admin-on-one-vault to a beta signup. They
|
|
98
|
+
* live behind the operator-only surface (the CLI / global config), not here.
|
|
99
|
+
*
|
|
100
|
+
* `autoTranscribe.enabled` IS per-vault: it reports the value THIS vault will
|
|
101
|
+
* actually use, resolving per-vault override → global default → true (mirrors
|
|
102
|
+
* `shouldAutoTranscribe`). That's a per-vault effective value, not the raw
|
|
103
|
+
* daemon-global toggle — reporting it doesn't leak the global setting (an
|
|
104
|
+
* unset vault simply inherits, which is the per-vault truth).
|
|
132
105
|
*/
|
|
133
106
|
export function buildConfigValues(
|
|
134
107
|
vaultConfig: VaultConfig,
|
|
135
108
|
globalConfig: GlobalConfig,
|
|
136
|
-
env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
|
|
137
109
|
): Record<string, unknown> {
|
|
138
|
-
// Resolve scribe URL through the discovery layer so the GET shape reflects
|
|
139
|
-
// what the worker will actually use (services.json > SCRIBE_URL > unset).
|
|
140
|
-
// Pass env through so the test harness's override is honored.
|
|
141
|
-
const scribeUrl = resolveScribeUrl(env as NodeJS.ProcessEnv) ?? "";
|
|
142
110
|
return {
|
|
143
111
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
144
112
|
autoTranscribe: {
|
|
145
|
-
//
|
|
146
|
-
// the
|
|
147
|
-
//
|
|
148
|
-
enabled:
|
|
149
|
-
|
|
113
|
+
// Per-vault effective value: this vault's own override wins; otherwise it
|
|
114
|
+
// inherits the server default; otherwise true. Same ladder as
|
|
115
|
+
// shouldAutoTranscribe, so the admin SPA shows what this vault will do.
|
|
116
|
+
enabled:
|
|
117
|
+
vaultConfig.auto_transcribe?.enabled
|
|
118
|
+
?? globalConfig.auto_transcribe?.enabled
|
|
119
|
+
?? true,
|
|
150
120
|
},
|
|
151
|
-
// Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
|
|
152
|
-
// pre-vault#353 shape don't regress.
|
|
153
|
-
scribe_url: scribeUrl,
|
|
154
|
-
port: globalConfig.port,
|
|
155
121
|
};
|
|
156
122
|
}
|
|
157
123
|
|
package/src/routes.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
15
|
import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
16
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
17
|
-
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
17
|
+
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
18
18
|
import {
|
|
19
19
|
parseContentRange,
|
|
20
20
|
applyContentRange,
|
|
@@ -26,6 +26,7 @@ import type { ValidationWarning } from "../core/src/schema-defaults.ts";
|
|
|
26
26
|
import { logStrictBypass } from "./scopes.ts";
|
|
27
27
|
import * as linkOps from "../core/src/links.ts";
|
|
28
28
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
29
|
+
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
29
30
|
import {
|
|
30
31
|
buildExpandVisibility,
|
|
31
32
|
filterHydratedLinksByTagScope,
|
|
@@ -1652,8 +1653,11 @@ async function handleNotesInner(
|
|
|
1652
1653
|
updates.extension = validateExtension(body.extension);
|
|
1653
1654
|
}
|
|
1654
1655
|
if (body.metadata !== undefined) {
|
|
1655
|
-
|
|
1656
|
-
updates.metadata =
|
|
1656
|
+
// RFC 7386 merge: incoming `null` removes the key (vault#478/#479).
|
|
1657
|
+
updates.metadata = mergeMetadata(
|
|
1658
|
+
note.metadata as Record<string, unknown> | null | undefined,
|
|
1659
|
+
body.metadata as Record<string, unknown>,
|
|
1660
|
+
);
|
|
1657
1661
|
}
|
|
1658
1662
|
if (body.created_at !== undefined || body.createdAt !== undefined) {
|
|
1659
1663
|
updates.created_at = body.created_at ?? body.createdAt;
|
|
@@ -2093,12 +2097,27 @@ export async function handleTags(
|
|
|
2093
2097
|
fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
|
|
2094
2098
|
}
|
|
2095
2099
|
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2100
|
+
// A bad indexed-field name (or an unindexable type, or a cross-tag type
|
|
2101
|
+
// mismatch) is a CLIENT error — return 400, not the catch-all 500. The
|
|
2102
|
+
// store pre-validates and is transactional, so the schema is left
|
|
2103
|
+
// unchanged on failure (no orphan/lying index). vault#478.
|
|
2104
|
+
let result;
|
|
2105
|
+
try {
|
|
2106
|
+
result = await store.upsertTagRecord(tagName, {
|
|
2107
|
+
...(body.description !== undefined ? { description: body.description } : {}),
|
|
2108
|
+
...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
|
|
2109
|
+
...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
|
|
2110
|
+
...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
|
|
2111
|
+
});
|
|
2112
|
+
} catch (err) {
|
|
2113
|
+
if (err instanceof IndexedFieldError) {
|
|
2114
|
+
return json(
|
|
2115
|
+
{ error: err.message, error_type: "invalid_indexed_field" },
|
|
2116
|
+
400,
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
throw err;
|
|
2120
|
+
}
|
|
2102
2121
|
return json(result);
|
|
2103
2122
|
}
|
|
2104
2123
|
|
package/src/routing.test.ts
CHANGED
|
@@ -1262,12 +1262,17 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
|
|
|
1262
1262
|
expect(body.type).toBe("object");
|
|
1263
1263
|
expect(body.properties.audio_retention?.type).toBe("string");
|
|
1264
1264
|
expect(body.properties.audio_retention?.enum).toEqual(["keep", "until_transcribed", "never"]);
|
|
1265
|
-
expect(body.properties.
|
|
1266
|
-
|
|
1267
|
-
|
|
1265
|
+
expect(body.properties.autoTranscribe?.type).toBe("object");
|
|
1266
|
+
// vault#478 scope boundary: daemon-GLOBAL fields are NOT described by the
|
|
1267
|
+
// per-vault admin config schema. The per-vault admin surface is "admin
|
|
1268
|
+
// over *your* vault only" — port / scribe URL / scribe bearer are
|
|
1269
|
+
// deployment-wide and live behind the operator-only surface.
|
|
1270
|
+
expect(body.properties).not.toHaveProperty("scribe_url");
|
|
1271
|
+
expect(body.properties).not.toHaveProperty("scribe_token");
|
|
1272
|
+
expect(body.properties).not.toHaveProperty("port");
|
|
1268
1273
|
});
|
|
1269
1274
|
|
|
1270
|
-
test("config returns
|
|
1275
|
+
test("config returns ONLY per-vault values, never daemon-global ones (scope boundary, vault#478)", async () => {
|
|
1271
1276
|
createVault("journal");
|
|
1272
1277
|
const token = await createAdminToken("journal");
|
|
1273
1278
|
const path = "/vault/journal/.parachute/config";
|
|
@@ -1284,13 +1289,22 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
|
|
|
1284
1289
|
);
|
|
1285
1290
|
expect(res.status).toBe(200);
|
|
1286
1291
|
const body = (await res.json()) as Record<string, unknown>;
|
|
1292
|
+
// Per-vault config is present.
|
|
1287
1293
|
expect(body.audio_retention).toBe("keep"); // default when unset
|
|
1288
|
-
expect(body
|
|
1289
|
-
|
|
1294
|
+
expect(body).toHaveProperty("autoTranscribe");
|
|
1295
|
+
// Daemon-GLOBAL values must NOT cross the per-vault admin boundary.
|
|
1296
|
+
expect(body).not.toHaveProperty("port");
|
|
1297
|
+
expect(body).not.toHaveProperty("scribe_url");
|
|
1298
|
+
// The discovery-resolved scribe URL is daemon-global → must not leak,
|
|
1299
|
+
// even nested under autoTranscribe.
|
|
1300
|
+
const at = body.autoTranscribe as Record<string, unknown>;
|
|
1301
|
+
expect(at).not.toHaveProperty("scribeUrl");
|
|
1290
1302
|
// writeOnly field must not appear in GET.
|
|
1291
1303
|
expect(body).not.toHaveProperty("scribe_token");
|
|
1292
|
-
// Defense in depth:
|
|
1293
|
-
|
|
1304
|
+
// Defense in depth: no daemon-global value (URL or secret) anywhere.
|
|
1305
|
+
const raw = JSON.stringify(body);
|
|
1306
|
+
expect(raw).not.toContain("super-secret-should-never-appear");
|
|
1307
|
+
expect(raw).not.toContain("https://scribe.example/v1");
|
|
1294
1308
|
} finally {
|
|
1295
1309
|
if (origScribeToken === undefined) delete process.env.SCRIBE_TOKEN;
|
|
1296
1310
|
else process.env.SCRIBE_TOKEN = origScribeToken;
|
|
@@ -1318,24 +1332,27 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
|
|
|
1318
1332
|
expect(body.audio_retention).toBe("until_transcribed");
|
|
1319
1333
|
});
|
|
1320
1334
|
|
|
1321
|
-
test("
|
|
1322
|
-
|
|
1335
|
+
test("autoTranscribe.enabled reports the per-vault override (vault#478)", async () => {
|
|
1336
|
+
// Server default ON; this vault explicitly opts OUT — the per-vault value
|
|
1337
|
+
// must win, proving the field is reported per-vault (not the raw global).
|
|
1338
|
+
writeGlobalConfig({ port: 1940, auto_transcribe: { enabled: true } });
|
|
1339
|
+
writeVaultConfig({
|
|
1340
|
+
name: "journal",
|
|
1341
|
+
api_keys: [],
|
|
1342
|
+
created_at: new Date().toISOString(),
|
|
1343
|
+
auto_transcribe: { enabled: false },
|
|
1344
|
+
});
|
|
1323
1345
|
const token = await createAdminToken("journal");
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
const body = (await res.json()) as { scribe_url: string };
|
|
1335
|
-
expect(body.scribe_url).toBe("");
|
|
1336
|
-
} finally {
|
|
1337
|
-
if (orig !== undefined) process.env.SCRIBE_URL = orig;
|
|
1338
|
-
}
|
|
1346
|
+
const path = "/vault/journal/.parachute/config";
|
|
1347
|
+
const res = await route(
|
|
1348
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1349
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1350
|
+
}),
|
|
1351
|
+
path,
|
|
1352
|
+
);
|
|
1353
|
+
const body = (await res.json()) as { autoTranscribe: { enabled: boolean } };
|
|
1354
|
+
expect(body.autoTranscribe.enabled).toBe(false);
|
|
1355
|
+
writeGlobalConfig({ port: 1940 });
|
|
1339
1356
|
});
|
|
1340
1357
|
|
|
1341
1358
|
test("unknown vault returns 404 before reaching the config handlers", async () => {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in scale smoke (vault#325 Part 3).
|
|
3
|
+
*
|
|
4
|
+
* Guards the synthetic scale harness against bit-rot WITHOUT slowing the
|
|
5
|
+
* default `bun test` gate. The real benchmark lives in
|
|
6
|
+
* `scripts/scale-bench.ts` (10k / 50k / 100k); this is a fast, tiny-size
|
|
7
|
+
* functional smoke of the same hot-path code so a green CI run still
|
|
8
|
+
* proves the harness paths execute end-to-end.
|
|
9
|
+
*
|
|
10
|
+
* SKIPPED BY DEFAULT. To run it:
|
|
11
|
+
*
|
|
12
|
+
* VAULT_SCALE_BENCH=1 bun test ./src/scale.bench.test.ts
|
|
13
|
+
*
|
|
14
|
+
* For real timings + the documented ceiling, run the standalone harness:
|
|
15
|
+
*
|
|
16
|
+
* bun scripts/scale-bench.ts # 10000 50000 100000
|
|
17
|
+
*
|
|
18
|
+
* See docs/SCALE.md.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, test, expect } from "bun:test";
|
|
22
|
+
import { Database } from "bun:sqlite";
|
|
23
|
+
import { BunSqliteStore } from "../core/src/store.ts";
|
|
24
|
+
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
25
|
+
import { exportVaultToDir } from "../core/src/portable-md.ts";
|
|
26
|
+
import type { BulkNoteInput } from "../core/src/notes.ts";
|
|
27
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
28
|
+
import { tmpdir } from "os";
|
|
29
|
+
import { join } from "path";
|
|
30
|
+
|
|
31
|
+
const ENABLED = process.env.VAULT_SCALE_BENCH === "1";
|
|
32
|
+
|
|
33
|
+
// `describe.skipIf` keeps the suite registered (so it shows in the test list)
|
|
34
|
+
// but contributes zero runtime to the default gate. Size is intentionally
|
|
35
|
+
// tiny — this is a functional smoke, not a timing run.
|
|
36
|
+
describe.skipIf(!ENABLED)("scale harness smoke (opt-in: VAULT_SCALE_BENCH=1)", () => {
|
|
37
|
+
test("seed + hot-path queries + export run at small N", async () => {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), "vault-scale-smoke-"));
|
|
39
|
+
const db = new Database(join(dir, "smoke.db"));
|
|
40
|
+
const store = new BunSqliteStore(db);
|
|
41
|
+
try {
|
|
42
|
+
const N = 500;
|
|
43
|
+
|
|
44
|
+
// Declare the indexed field via the update-tag tool (the owner of the
|
|
45
|
+
// indexed_fields lifecycle — mirrors scripts/scale-bench.ts).
|
|
46
|
+
const tools = generateMcpTools(store);
|
|
47
|
+
const updateTag = tools.find((t) => t.name === "update-tag");
|
|
48
|
+
expect(updateTag).toBeDefined();
|
|
49
|
+
await updateTag!.execute({
|
|
50
|
+
tag: "work",
|
|
51
|
+
fields: { status: { type: "string", indexed: true } },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const inputs: BulkNoteInput[] = Array.from({ length: N }, (_, i) => ({
|
|
55
|
+
id: `note-${i}`,
|
|
56
|
+
path: `notes/note-${i}`,
|
|
57
|
+
content: `# Note ${i}\n\nconsectetur body ${i}`,
|
|
58
|
+
tags: i % 3 === 0 ? ["work", "work/meeting"] : ["health"],
|
|
59
|
+
metadata: { status: i % 2 === 0 ? "active" : "done", seq: i },
|
|
60
|
+
}));
|
|
61
|
+
await store.createNotes(inputs);
|
|
62
|
+
|
|
63
|
+
// Hot paths — same shapes the standalone harness times.
|
|
64
|
+
expect((await store.queryNotes({ tags: ["health"], limit: 50 })).length).toBeGreaterThan(0);
|
|
65
|
+
expect(
|
|
66
|
+
(await store.queryNotes({ metadata: { status: { eq: "active" } }, limit: 50 })).length,
|
|
67
|
+
).toBeGreaterThan(0);
|
|
68
|
+
expect((await store.queryNotes({ orderBy: "status", limit: 50 })).length).toBe(50);
|
|
69
|
+
expect((await store.searchNotes("consectetur", { limit: 50 })).length).toBeGreaterThan(0);
|
|
70
|
+
|
|
71
|
+
const stats = await exportVaultToDir(store, {
|
|
72
|
+
outDir: join(dir, "export"),
|
|
73
|
+
vaultName: "smoke",
|
|
74
|
+
exportedAt: "2026-01-01T00:00:00Z",
|
|
75
|
+
});
|
|
76
|
+
expect(stats.notes).toBe(N);
|
|
77
|
+
} finally {
|
|
78
|
+
db.close();
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -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/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 () => {
|
|
@@ -4012,6 +4105,36 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
4012
4105
|
expect(body.metadata).toEqual({ a: 1, b: 2 });
|
|
4013
4106
|
});
|
|
4014
4107
|
|
|
4108
|
+
test("PATCH metadata null DELETES the key (RFC 7386), not a literal null (vault#478/#479)", async () => {
|
|
4109
|
+
await store.createNote("doc", { id: "x", metadata: { keep: "yes", drop: "old", n: 3 } });
|
|
4110
|
+
const res = await handleNotes(
|
|
4111
|
+
mkReq("PATCH", "/notes/x", { metadata: { drop: null }, force: true }),
|
|
4112
|
+
store,
|
|
4113
|
+
"/x",
|
|
4114
|
+
);
|
|
4115
|
+
expect(res.status).toBe(200);
|
|
4116
|
+
const body = await res.json() as any;
|
|
4117
|
+
// Key removed entirely — must NOT survive as a literal JSON null.
|
|
4118
|
+
expect(body.metadata).not.toHaveProperty("drop");
|
|
4119
|
+
expect(body.metadata).toEqual({ keep: "yes", n: 3 });
|
|
4120
|
+
// Persisted state matches the response (round-trips).
|
|
4121
|
+
const fresh = await store.getNote("x");
|
|
4122
|
+
expect(fresh!.metadata).not.toHaveProperty("drop");
|
|
4123
|
+
expect(fresh!.metadata).toEqual({ keep: "yes", n: 3 });
|
|
4124
|
+
});
|
|
4125
|
+
|
|
4126
|
+
test("PATCH metadata key-rename in one call: set new, null-delete old (vault#478)", async () => {
|
|
4127
|
+
await store.createNote("doc", { id: "x", metadata: { "old-key": "v", stable: true } });
|
|
4128
|
+
const res = await handleNotes(
|
|
4129
|
+
mkReq("PATCH", "/notes/x", { metadata: { new_key: "v", "old-key": null }, force: true }),
|
|
4130
|
+
store,
|
|
4131
|
+
"/x",
|
|
4132
|
+
);
|
|
4133
|
+
const body = await res.json() as any;
|
|
4134
|
+
expect(body.metadata).not.toHaveProperty("old-key");
|
|
4135
|
+
expect(body.metadata).toEqual({ new_key: "v", stable: true });
|
|
4136
|
+
});
|
|
4137
|
+
|
|
4015
4138
|
test("PATCH adds/removes tags", async () => {
|
|
4016
4139
|
await store.createNote("x", { id: "x", tags: ["old"] });
|
|
4017
4140
|
const res = await handleNotes(
|
|
@@ -4789,6 +4912,28 @@ describe("HTTP /tags", async () => {
|
|
|
4789
4912
|
.filter((n) => n.startsWith("meta_"));
|
|
4790
4913
|
}
|
|
4791
4914
|
|
|
4915
|
+
test("PUT /tags/:name with a bad indexed-field name returns 400 + leaves schema unchanged (vault#478)", async () => {
|
|
4916
|
+
// kebab-case indexed field violates [A-Za-z0-9_]. Pre-fix this persisted
|
|
4917
|
+
// the declaration then 500'd on index creation, leaving a tag claiming an
|
|
4918
|
+
// index the engine couldn't build (the "lying schema" loop).
|
|
4919
|
+
const res = await handleTags(
|
|
4920
|
+
mkReq("PUT", "/tags/meeting", { fields: { "meeting-type": { type: "string", indexed: true } } }),
|
|
4921
|
+
store,
|
|
4922
|
+
"/meeting",
|
|
4923
|
+
);
|
|
4924
|
+
expect(res.status).toBe(400);
|
|
4925
|
+
const body = await res.json() as any;
|
|
4926
|
+
expect(body.error_type).toBe("invalid_indexed_field");
|
|
4927
|
+
expect(body.error).toMatch(/invalid field name/);
|
|
4928
|
+
|
|
4929
|
+
// Schema is untouched — no poisoned field declared.
|
|
4930
|
+
const record = await store.getTagRecord("meeting");
|
|
4931
|
+
expect(record?.fields?.["meeting-type"]).toBeUndefined();
|
|
4932
|
+
// No orphan/lying index: neither the generated column nor an indexed_fields row.
|
|
4933
|
+
expect(notesMetaCols()).not.toContain("meta_meeting-type");
|
|
4934
|
+
expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("meeting-type");
|
|
4935
|
+
});
|
|
4936
|
+
|
|
4792
4937
|
test("PUT /tags/:name {fields:null} drops the orphaned generated column", async () => {
|
|
4793
4938
|
// Declare an indexed field via REST PUT — column materializes.
|
|
4794
4939
|
await handleTags(
|