@openparachute/vault 0.5.2 → 0.5.3-rc.2
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 +89 -0
- package/core/src/links.ts +19 -1
- package/core/src/mcp.ts +60 -20
- package/core/src/types.ts +10 -0
- package/package.json +1 -1
- package/src/config.test.ts +66 -0
- package/src/config.ts +31 -10
- package/src/mcp-tools.ts +20 -1
- package/src/routes.ts +52 -24
- package/src/routing.test.ts +192 -1
- package/src/routing.ts +32 -1
- package/src/vault.test.ts +40 -0
- package/web/ui/dist/assets/{index-D8nCVT1e.js → index-DJL6Az--.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/src/routes.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Store, Note } from "../core/src/types.ts";
|
|
15
15
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
16
|
-
import { getNote, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
16
|
+
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
17
17
|
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
18
18
|
import * as linkOps from "../core/src/links.ts";
|
|
19
19
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
@@ -567,7 +567,7 @@ async function handleNotesInner(
|
|
|
567
567
|
): Promise<Response> {
|
|
568
568
|
const url = new URL(req.url);
|
|
569
569
|
const method = req.method;
|
|
570
|
-
const db =
|
|
570
|
+
const db = store.db;
|
|
571
571
|
|
|
572
572
|
// ---- Collection routes (no ID in path) ----
|
|
573
573
|
if (subpath === "") {
|
|
@@ -817,16 +817,24 @@ async function handleNotesInner(
|
|
|
817
817
|
}
|
|
818
818
|
const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
|
|
819
819
|
const relationship = parseQuery(url, "near[relationship]") ?? undefined;
|
|
820
|
-
// Tag-scope policy for `near[]` (
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
const
|
|
820
|
+
// Tag-scope policy for `near[]` (vault#439 — hop-guard, symmetric with
|
|
821
|
+
// find-path): for a tag-scoped token the BFS refuses to traverse
|
|
822
|
+
// THROUGH out-of-scope notes — scope is a wall, not a sieve. So a token
|
|
823
|
+
// scoped to ["work"] can't reach an in-scope note at depth 2 via a
|
|
824
|
+
// #personal intermediary at depth 1; that note is simply unreachable.
|
|
825
|
+
// The `filterNotesByTagScope` pass below still runs (defense in depth),
|
|
826
|
+
// but the wall makes it redundant for the `near[]` result set.
|
|
827
|
+
// Unscoped tokens (`tagScope.raw === null`) install no predicate → the
|
|
828
|
+
// FULL graph is walked exactly as before, behavior unchanged.
|
|
829
|
+
const isTraversable = tagScope.raw
|
|
830
|
+
? (id: string) =>
|
|
831
|
+
noteWithinTagScope(
|
|
832
|
+
{ id, tags: getNoteTags(db, id) } as Note,
|
|
833
|
+
tagScope.allowed,
|
|
834
|
+
tagScope.raw,
|
|
835
|
+
)
|
|
836
|
+
: undefined;
|
|
837
|
+
const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship, isTraversable });
|
|
830
838
|
const nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
|
|
831
839
|
results = results.filter((n) => nearScope.has(n.id));
|
|
832
840
|
}
|
|
@@ -1005,19 +1013,33 @@ async function handleNotesInner(
|
|
|
1005
1013
|
throw e;
|
|
1006
1014
|
}
|
|
1007
1015
|
|
|
1008
|
-
// Apply tag schema defaults
|
|
1016
|
+
// Apply tag schema defaults, then re-read the notes whose metadata was
|
|
1017
|
+
// actually default-filled so the response reflects the final on-disk
|
|
1018
|
+
// state (the `created` entries were read before `applySchemaDefaults`
|
|
1019
|
+
// ran). Mirrors the MCP create-note path (vault#316). Batched re-read
|
|
1020
|
+
// (`getNotes` = one `WHERE id IN (...)`), skipped when no defaults
|
|
1021
|
+
// applied so the common no-defaults path adds zero extra reads.
|
|
1022
|
+
const mutatedIds = new Set<string>();
|
|
1009
1023
|
for (const note of created) {
|
|
1010
1024
|
if (note.tags?.length) {
|
|
1011
|
-
await applySchemaDefaults(store, db, [note.id], note.tags)
|
|
1025
|
+
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
1026
|
+
mutatedIds.add(id);
|
|
1027
|
+
}
|
|
1012
1028
|
}
|
|
1013
1029
|
}
|
|
1030
|
+
const refreshed =
|
|
1031
|
+
mutatedIds.size === 0
|
|
1032
|
+
? created
|
|
1033
|
+
: (() => {
|
|
1034
|
+
const byId = new Map(getNotes(db, [...mutatedIds]).map((n) => [n.id, n]));
|
|
1035
|
+
return created.map((n) => byId.get(n.id) ?? n);
|
|
1036
|
+
})();
|
|
1014
1037
|
|
|
1015
1038
|
// Attach `validation_status` so HTTP create-note matches the MCP
|
|
1016
|
-
// surface (vault#287).
|
|
1017
|
-
// `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
|
|
1039
|
+
// surface (vault#287). `attachValidationStatus` returns the note
|
|
1018
1040
|
// unchanged when no tag declares fields, so vaults without any tag
|
|
1019
1041
|
// schemas see no behavior change.
|
|
1020
|
-
const final =
|
|
1042
|
+
const final = refreshed.map((n) => attachValidationStatus(store, db, n));
|
|
1021
1043
|
return json(body.notes ? final : final[0], 201);
|
|
1022
1044
|
}
|
|
1023
1045
|
|
|
@@ -1628,7 +1650,7 @@ export async function handleTags(
|
|
|
1628
1650
|
// that token's allowlist. Aggregate matches across sources for a single
|
|
1629
1651
|
// 409 envelope.
|
|
1630
1652
|
const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
|
|
1631
|
-
const db =
|
|
1653
|
+
const db = store.db;
|
|
1632
1654
|
for (const src of sources) {
|
|
1633
1655
|
const tokens = findTokensReferencingTag(db, src as string);
|
|
1634
1656
|
if (tokens.length > 0) referenced.push({ source: src as string, tokens });
|
|
@@ -1792,7 +1814,7 @@ export async function handleTags(
|
|
|
1792
1814
|
// tag would silently orphan the token's allowlist. Fail closed (409)
|
|
1793
1815
|
// and name the offending tokens so the operator can revoke or re-mint
|
|
1794
1816
|
// before retrying. patterns/tag-scoped-tokens.md §Dependencies.
|
|
1795
|
-
const referenced_by = findTokensReferencingTag(
|
|
1817
|
+
const referenced_by = findTokensReferencingTag(store.db, tagName);
|
|
1796
1818
|
if (referenced_by.length > 0) {
|
|
1797
1819
|
return json(
|
|
1798
1820
|
{
|
|
@@ -1827,7 +1849,7 @@ export async function handleFindPath(
|
|
|
1827
1849
|
const target = parseQuery(url, "target");
|
|
1828
1850
|
if (!source || !target) return json({ error: "source and target parameters are required" }, 400);
|
|
1829
1851
|
|
|
1830
|
-
const db =
|
|
1852
|
+
const db = store.db;
|
|
1831
1853
|
try {
|
|
1832
1854
|
const sourceNote = await resolveNote(store, source);
|
|
1833
1855
|
if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
|
|
@@ -1949,7 +1971,7 @@ export function handleUnresolvedWikilinks(
|
|
|
1949
1971
|
const url = new URL(req.url);
|
|
1950
1972
|
const limitStr = url.searchParams.get("limit");
|
|
1951
1973
|
const limit = limitStr ? parseInt(limitStr, 10) : 50;
|
|
1952
|
-
const db =
|
|
1974
|
+
const db = store.db;
|
|
1953
1975
|
const result = listUnresolvedWikilinks(db, limit);
|
|
1954
1976
|
|
|
1955
1977
|
// Unscoped token → return as-is (unchanged behavior).
|
|
@@ -2611,9 +2633,12 @@ export async function handleStorage(
|
|
|
2611
2633
|
// Tag schema defaults — same logic as core/src/mcp.ts applySchemaDefaults
|
|
2612
2634
|
// ---------------------------------------------------------------------------
|
|
2613
2635
|
|
|
2614
|
-
|
|
2636
|
+
// Returns the IDs of notes whose metadata was actually default-filled, so
|
|
2637
|
+
// the caller can re-read ONLY the mutated notes (and skip the re-read when
|
|
2638
|
+
// nothing changed). Mirrors the core/src/mcp.ts contract.
|
|
2639
|
+
async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
2615
2640
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
2616
|
-
if (Object.keys(schemas).length === 0) return;
|
|
2641
|
+
if (Object.keys(schemas).length === 0) return [];
|
|
2617
2642
|
|
|
2618
2643
|
const defaults: Record<string, unknown> = {};
|
|
2619
2644
|
for (const tag of tags) {
|
|
@@ -2625,8 +2650,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
|
|
|
2625
2650
|
}
|
|
2626
2651
|
}
|
|
2627
2652
|
}
|
|
2628
|
-
if (Object.keys(defaults).length === 0) return;
|
|
2653
|
+
if (Object.keys(defaults).length === 0) return [];
|
|
2629
2654
|
|
|
2655
|
+
const mutated: string[] = [];
|
|
2630
2656
|
for (const noteId of noteIds) {
|
|
2631
2657
|
const note = await store.getNote(noteId);
|
|
2632
2658
|
if (!note) continue;
|
|
@@ -2640,7 +2666,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
|
|
|
2640
2666
|
metadata: { ...existing, ...missing },
|
|
2641
2667
|
skipUpdatedAt: true,
|
|
2642
2668
|
});
|
|
2669
|
+
mutated.push(noteId);
|
|
2643
2670
|
}
|
|
2671
|
+
return mutated;
|
|
2644
2672
|
}
|
|
2645
2673
|
|
|
2646
2674
|
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
package/src/routing.test.ts
CHANGED
|
@@ -30,7 +30,7 @@ const testDir = join(
|
|
|
30
30
|
process.env.PARACHUTE_HOME = testDir;
|
|
31
31
|
|
|
32
32
|
// Dynamic import after env override so modules pick up the tmp dir.
|
|
33
|
-
const { route } = await import("./routing.ts");
|
|
33
|
+
const { route, filterVaultListForBinding } = await import("./routing.ts");
|
|
34
34
|
const {
|
|
35
35
|
readGlobalConfig,
|
|
36
36
|
writeGlobalConfig,
|
|
@@ -318,6 +318,56 @@ describe("GET /vaults/list (public discovery)", () => {
|
|
|
318
318
|
});
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// GET /vaults — authenticated metadata listing, filtered by vault binding
|
|
323
|
+
// (vault#259). Operator / admin-channel callers (vault_name === null) see the
|
|
324
|
+
// full list; a vault-bound caller sees only its own vault.
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
describe("GET /vaults (binding filter — vault#259)", () => {
|
|
328
|
+
// Pure policy helper — drives the filtering decision independent of the
|
|
329
|
+
// auth path (no current credential yields a non-null vault_name HERE, since
|
|
330
|
+
// authenticateGlobalRequest 401s hub JWTs; the helper pins the correct
|
|
331
|
+
// shape for any future vault-bound credential on this surface).
|
|
332
|
+
test("filterVaultListForBinding: null binding (operator) keeps the full list", () => {
|
|
333
|
+
const names = ["work", "boulder", "default"];
|
|
334
|
+
expect(filterVaultListForBinding(names, null)).toEqual(names);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("filterVaultListForBinding: a vault-bound caller sees only its own vault", () => {
|
|
338
|
+
const names = ["work", "boulder", "default"];
|
|
339
|
+
expect(filterVaultListForBinding(names, "work")).toEqual(["work"]);
|
|
340
|
+
// No cross-vault info-leak: boulder/default are not disclosed.
|
|
341
|
+
expect(filterVaultListForBinding(names, "work")).not.toContain("boulder");
|
|
342
|
+
expect(filterVaultListForBinding(names, "work")).not.toContain("default");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("filterVaultListForBinding: binding to a vault absent from the list yields empty", () => {
|
|
346
|
+
expect(filterVaultListForBinding(["work", "default"], "ghost")).toEqual([]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("operator token (VAULT_AUTH_TOKEN) gets the UNFILTERED full listing", async () => {
|
|
350
|
+
createVault("work");
|
|
351
|
+
createVault("boulder");
|
|
352
|
+
createVault("default");
|
|
353
|
+
const prev = process.env.VAULT_AUTH_TOKEN;
|
|
354
|
+
process.env.VAULT_AUTH_TOKEN = "op-secret-token-259";
|
|
355
|
+
try {
|
|
356
|
+
const req = new Request("http://localhost:1940/vaults", {
|
|
357
|
+
headers: { authorization: "Bearer op-secret-token-259" },
|
|
358
|
+
});
|
|
359
|
+
const res = await route(req, "/vaults");
|
|
360
|
+
expect(res.status).toBe(200);
|
|
361
|
+
const body = (await res.json()) as { vaults: { name: string }[] };
|
|
362
|
+
const names = body.vaults.map((v) => v.name);
|
|
363
|
+
expect(new Set(names)).toEqual(new Set(["work", "boulder", "default"]));
|
|
364
|
+
} finally {
|
|
365
|
+
if (prev === undefined) delete process.env.VAULT_AUTH_TOKEN;
|
|
366
|
+
else process.env.VAULT_AUTH_TOKEN = prev;
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
321
371
|
// ---------------------------------------------------------------------------
|
|
322
372
|
// /vault/<name>/admin/* — admin SPA static-file mount. Detailed tests live
|
|
323
373
|
// in admin-spa.test.ts (with a tmp dist dir); these pin the dispatch — i.e.
|
|
@@ -1640,6 +1690,147 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1640
1690
|
expect(res.status).toBe(201);
|
|
1641
1691
|
});
|
|
1642
1692
|
|
|
1693
|
+
// ----- vault#439: near[] BFS is a WALL, not a sieve --------------------
|
|
1694
|
+
// For a tag-scoped token the graph traversal must refuse to walk THROUGH
|
|
1695
|
+
// an out-of-scope note. So an in-scope note reachable ONLY via an
|
|
1696
|
+
// out-of-scope intermediary is unreachable — symmetric with find-path.
|
|
1697
|
+
// Topology: A(#work) --link--> P(#personal) --link--> B(#work).
|
|
1698
|
+
// A token scoped to ["work"] anchored at A, depth 2:
|
|
1699
|
+
// - sieve (old): B survives (reached via P, then output-filtered to keep B)
|
|
1700
|
+
// - wall (new): P is the wall; B is never reached.
|
|
1701
|
+
|
|
1702
|
+
test("vault#439: tag-scoped near[] cannot reach an in-scope note through an out-of-scope hop", async () => {
|
|
1703
|
+
createVault("journal");
|
|
1704
|
+
const store = getVaultStore("journal");
|
|
1705
|
+
const a = await store.createNote("anchor-work", { tags: ["work"] });
|
|
1706
|
+
const p = await store.createNote("bridge-personal", { tags: ["personal"] });
|
|
1707
|
+
const b = await store.createNote("far-work", { tags: ["work"] });
|
|
1708
|
+
await store.createLink(a.id, p.id, "relates");
|
|
1709
|
+
await store.createLink(p.id, b.id, "relates");
|
|
1710
|
+
const token = await mintTagScopedToken("journal", ["vault:read"], ["work"]);
|
|
1711
|
+
|
|
1712
|
+
// `route`'s second arg is the pathname only; the query rides on req.url.
|
|
1713
|
+
const pathname = "/vault/journal/api/notes";
|
|
1714
|
+
const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
|
|
1715
|
+
const res = await route(authed(token, "GET", full), pathname);
|
|
1716
|
+
expect(res.status).toBe(200);
|
|
1717
|
+
const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
|
|
1718
|
+
const list = Array.isArray(body) ? body : (body.notes ?? []);
|
|
1719
|
+
const ids = list.map((n) => n.id);
|
|
1720
|
+
// B is in-scope (#work) but only reachable via the out-of-scope #personal
|
|
1721
|
+
// bridge — the wall makes it unreachable.
|
|
1722
|
+
expect(ids).not.toContain(b.id);
|
|
1723
|
+
// P itself never leaks (it's out of scope).
|
|
1724
|
+
expect(ids).not.toContain(p.id);
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
test("vault#439: tag-scoped near[] still reaches in-scope notes via in-scope hops", async () => {
|
|
1728
|
+
createVault("journal");
|
|
1729
|
+
const store = getVaultStore("journal");
|
|
1730
|
+
const a = await store.createNote("anchor-work", { tags: ["work"] });
|
|
1731
|
+
const mid = await store.createNote("mid-work", { tags: ["work"] });
|
|
1732
|
+
const far = await store.createNote("far-work", { tags: ["work"] });
|
|
1733
|
+
await store.createLink(a.id, mid.id, "relates");
|
|
1734
|
+
await store.createLink(mid.id, far.id, "relates");
|
|
1735
|
+
const token = await mintTagScopedToken("journal", ["vault:read"], ["work"]);
|
|
1736
|
+
|
|
1737
|
+
const pathname = "/vault/journal/api/notes";
|
|
1738
|
+
const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
|
|
1739
|
+
const res = await route(authed(token, "GET", full), pathname);
|
|
1740
|
+
expect(res.status).toBe(200);
|
|
1741
|
+
const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
|
|
1742
|
+
const list = Array.isArray(body) ? body : (body.notes ?? []);
|
|
1743
|
+
const ids = list.map((n) => n.id);
|
|
1744
|
+
// All-in-scope path: both mid (depth 1) and far (depth 2) are reachable.
|
|
1745
|
+
expect(ids).toContain(mid.id);
|
|
1746
|
+
expect(ids).toContain(far.id);
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
test("vault#439: UNSCOPED token near[] still walks the full graph (behavior unchanged)", async () => {
|
|
1750
|
+
createVault("journal");
|
|
1751
|
+
const store = getVaultStore("journal");
|
|
1752
|
+
const a = await store.createNote("anchor-work", { tags: ["work"] });
|
|
1753
|
+
const p = await store.createNote("bridge-personal", { tags: ["personal"] });
|
|
1754
|
+
const b = await store.createNote("far-work", { tags: ["work"] });
|
|
1755
|
+
await store.createLink(a.id, p.id, "relates");
|
|
1756
|
+
await store.createLink(p.id, b.id, "relates");
|
|
1757
|
+
// No scopedTags → unscoped admin token; no wall installed.
|
|
1758
|
+
const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:admin"] });
|
|
1759
|
+
|
|
1760
|
+
const pathname = "/vault/journal/api/notes";
|
|
1761
|
+
const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
|
|
1762
|
+
const res = await route(authed(token, "GET", full), pathname);
|
|
1763
|
+
expect(res.status).toBe(200);
|
|
1764
|
+
const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
|
|
1765
|
+
const list = Array.isArray(body) ? body : (body.notes ?? []);
|
|
1766
|
+
const ids = list.map((n) => n.id);
|
|
1767
|
+
// Unscoped: the full neighborhood is visible, including the #personal
|
|
1768
|
+
// bridge and the note beyond it.
|
|
1769
|
+
expect(ids).toContain(p.id);
|
|
1770
|
+
expect(ids).toContain(b.id);
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
// ----- vault#404: REST-path hub-JWT tag-scoping enforcement (C0) --------
|
|
1774
|
+
// The MCP path's tag-scope enforcement is covered end-to-end; pin that a
|
|
1775
|
+
// tag-scoped HUB JWT (allowlist carried in the `permissions.scoped_tags`
|
|
1776
|
+
// claim, NOT a vestigial DB row) hitting the REST surface enforces the
|
|
1777
|
+
// same allowlist on both read and write. `mintTagScopedToken` mints a real
|
|
1778
|
+
// hub JWT, so these tests exercise the hub-JWT-sourced `scoped_tags` path.
|
|
1779
|
+
|
|
1780
|
+
test("vault#404: hub-JWT tag-scoped READ via REST enforces the allowlist (list + single)", async () => {
|
|
1781
|
+
createVault("journal");
|
|
1782
|
+
const store = getVaultStore("journal");
|
|
1783
|
+
const inScope = await store.createNote("h", { tags: ["health"] });
|
|
1784
|
+
const outOfScope = await store.createNote("w", { tags: ["work"] });
|
|
1785
|
+
const token = await mintTagScopedToken("journal", ["vault:read"], ["health"]);
|
|
1786
|
+
|
|
1787
|
+
// List: only in-scope notes.
|
|
1788
|
+
const listPath = "/vault/journal/api/notes";
|
|
1789
|
+
const listRes = await route(authed(token, "GET", listPath), listPath);
|
|
1790
|
+
expect(listRes.status).toBe(200);
|
|
1791
|
+
const listBody = (await listRes.json()) as { notes?: { id: string }[] } | { id: string }[];
|
|
1792
|
+
const list = Array.isArray(listBody) ? listBody : (listBody.notes ?? []);
|
|
1793
|
+
const ids = list.map((n) => n.id);
|
|
1794
|
+
expect(ids).toContain(inScope.id);
|
|
1795
|
+
expect(ids).not.toContain(outOfScope.id);
|
|
1796
|
+
|
|
1797
|
+
// Single in-scope → 200; single out-of-scope → 404 (no existence leak).
|
|
1798
|
+
const okPath = `/vault/journal/api/notes/${inScope.id}`;
|
|
1799
|
+
expect((await route(authed(token, "GET", okPath), okPath)).status).toBe(200);
|
|
1800
|
+
const denyPath = `/vault/journal/api/notes/${outOfScope.id}`;
|
|
1801
|
+
expect((await route(authed(token, "GET", denyPath), denyPath)).status).toBe(404);
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
test("vault#404: hub-JWT tag-scoped WRITE via REST enforces the allowlist", async () => {
|
|
1805
|
+
createVault("journal");
|
|
1806
|
+
const token = await mintTagScopedToken("journal", ["vault:read", "vault:write"], ["health"]);
|
|
1807
|
+
|
|
1808
|
+
// In-scope write → 201.
|
|
1809
|
+
const path = "/vault/journal/api/notes";
|
|
1810
|
+
const okRes = await route(
|
|
1811
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1812
|
+
method: "POST",
|
|
1813
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1814
|
+
body: JSON.stringify({ content: "ok", tags: ["health"] }),
|
|
1815
|
+
}),
|
|
1816
|
+
path,
|
|
1817
|
+
);
|
|
1818
|
+
expect(okRes.status).toBe(201);
|
|
1819
|
+
|
|
1820
|
+
// Out-of-scope write → 403 tag_scope_violation.
|
|
1821
|
+
const denyRes = await route(
|
|
1822
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1823
|
+
method: "POST",
|
|
1824
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1825
|
+
body: JSON.stringify({ content: "denied", tags: ["work"] }),
|
|
1826
|
+
}),
|
|
1827
|
+
path,
|
|
1828
|
+
);
|
|
1829
|
+
expect(denyRes.status).toBe(403);
|
|
1830
|
+
const denyBody = (await denyRes.json()) as { error_type?: string };
|
|
1831
|
+
expect(denyBody.error_type).toBe("tag_scope_violation");
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1643
1834
|
// ----- Q5: tag-delete dependency check ---------------------------------
|
|
1644
1835
|
// Deleting a tag referenced by any token's scoped_tags would silently
|
|
1645
1836
|
// orphan the token's allowlist; fail closed with 409 + referenced_by.
|
package/src/routing.ts
CHANGED
|
@@ -180,6 +180,23 @@ function handleParachuteIcon(): Response {
|
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Filter a server-wide vault-name list for a caller's per-vault binding
|
|
185
|
+
* (vault#259). `vaultName === null` is the operator / admin-channel caller
|
|
186
|
+
* (server-wide VAULT_AUTH_TOKEN or legacy cross-vault config.yaml key) — they
|
|
187
|
+
* keep the FULL list. A non-null binding reduces the list to just that vault
|
|
188
|
+
* (empty if the bound vault isn't in the listing). Pure + exported so the
|
|
189
|
+
* policy is unit-testable independent of the auth path. See the `/vaults`
|
|
190
|
+
* handler for the full rationale.
|
|
191
|
+
*/
|
|
192
|
+
export function filterVaultListForBinding(
|
|
193
|
+
names: string[],
|
|
194
|
+
vaultName: string | null,
|
|
195
|
+
): string[] {
|
|
196
|
+
if (vaultName === null) return names;
|
|
197
|
+
return names.filter((name) => name === vaultName);
|
|
198
|
+
}
|
|
199
|
+
|
|
183
200
|
export async function route(
|
|
184
201
|
req: Request,
|
|
185
202
|
path: string,
|
|
@@ -286,10 +303,24 @@ export async function route(
|
|
|
286
303
|
}
|
|
287
304
|
|
|
288
305
|
// Authenticated vault metadata list.
|
|
306
|
+
//
|
|
307
|
+
// Vault-binding filter (vault#259, info-leak follow-up): `/vaults` is a
|
|
308
|
+
// cross-vault DISCOVERY endpoint, not a single-vault operational one (unlike
|
|
309
|
+
// /mcp, which legitimately routes a vault-bound token back into its own
|
|
310
|
+
// vault). A caller bound to one vault shouldn't learn that OTHER vaults exist
|
|
311
|
+
// on this server. So when the authenticated caller carries a per-vault
|
|
312
|
+
// binding (`auth.vault_name !== null`), the listing is reduced to just that
|
|
313
|
+
// vault. Operator / admin-channel callers — the server-wide VAULT_AUTH_TOKEN
|
|
314
|
+
// and legacy cross-vault config.yaml keys — have `vault_name === null` and
|
|
315
|
+
// keep the full listing (they're explicitly the cross-vault management
|
|
316
|
+
// channel). `authenticateGlobalRequest` already 401s hub JWTs here, so the
|
|
317
|
+
// only callers that reach this point today are operator-channel
|
|
318
|
+
// (`vault_name === null`); this filter is the security-correct shape for any
|
|
319
|
+
// future vault-bound credential that becomes accepted on this surface.
|
|
289
320
|
if (path === "/vaults" && req.method === "GET") {
|
|
290
321
|
const auth = await authenticateGlobalRequest(req);
|
|
291
322
|
if ("error" in auth) return auth.error;
|
|
292
|
-
const names = listVaults();
|
|
323
|
+
const names = filterVaultListForBinding(listVaults(), auth.vault_name);
|
|
293
324
|
const vaults = names.map((name) => {
|
|
294
325
|
const config = readVaultConfig(name);
|
|
295
326
|
return {
|
package/src/vault.test.ts
CHANGED
|
@@ -2023,6 +2023,46 @@ describe("HTTP /notes", async () => {
|
|
|
2023
2023
|
expect(body.createdAt).toBe("2025-01-01T00:00:00.000Z");
|
|
2024
2024
|
});
|
|
2025
2025
|
|
|
2026
|
+
// vault#316 — the HTTP POST path re-reads each note AFTER
|
|
2027
|
+
// `applySchemaDefaults`, so the response metadata carries the just-written
|
|
2028
|
+
// defaults (mirrors the MCP create-note path). Before the fix the response
|
|
2029
|
+
// mapped over the pre-defaults in-memory objects, so default-filled
|
|
2030
|
+
// metadata was missing from `POST /api/notes` responses.
|
|
2031
|
+
test("POST /notes response reflects post-applySchemaDefaults state (vault#316)", async () => {
|
|
2032
|
+
await store.upsertTagSchema("task", {
|
|
2033
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
// Single: default lands in the returned metadata and agrees with disk.
|
|
2037
|
+
const single = await handleNotes(
|
|
2038
|
+
mkReq("POST", "/notes", { content: "do the thing", path: "Inbox/task-1", tags: ["task"] }),
|
|
2039
|
+
store,
|
|
2040
|
+
"",
|
|
2041
|
+
);
|
|
2042
|
+
expect(single.status).toBe(201);
|
|
2043
|
+
const singleBody = await single.json() as any;
|
|
2044
|
+
expect(singleBody.metadata?.priority).toBe("high"); // first enum value
|
|
2045
|
+
const onDisk = await store.getNoteByPath("Inbox/task-1");
|
|
2046
|
+
expect((onDisk!.metadata as any)?.priority).toBe("high");
|
|
2047
|
+
|
|
2048
|
+
// Batch: each entry is re-read post-defaults too, in input order.
|
|
2049
|
+
const batch = await handleNotes(
|
|
2050
|
+
mkReq("POST", "/notes", {
|
|
2051
|
+
notes: [
|
|
2052
|
+
{ content: "a", path: "Inbox/task-2", tags: ["task"] },
|
|
2053
|
+
{ content: "b", path: "Inbox/task-3", tags: ["task"] },
|
|
2054
|
+
],
|
|
2055
|
+
}),
|
|
2056
|
+
store,
|
|
2057
|
+
"",
|
|
2058
|
+
);
|
|
2059
|
+
expect(batch.status).toBe(201);
|
|
2060
|
+
const batchBody = await batch.json() as any[];
|
|
2061
|
+
expect(batchBody.map((n) => n.path)).toEqual(["Inbox/task-2", "Inbox/task-3"]);
|
|
2062
|
+
expect(batchBody[0].metadata?.priority).toBe("high");
|
|
2063
|
+
expect(batchBody[1].metadata?.priority).toBe("high");
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2026
2066
|
// ---- Extension field (vault#328) ----
|
|
2027
2067
|
|
|
2028
2068
|
test("POST /notes accepts extension and persists it", async () => {
|