@openparachute/vault 0.6.0-rc.1 → 0.6.0

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.
Files changed (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -6,10 +6,21 @@
6
6
  "port": 1940,
7
7
  "paths": ["/vault/default"],
8
8
  "health": "/vault/default/health",
9
- "managementUrl": "/admin/",
10
- "uiUrl": "/admin/",
9
+ "managementUrl": "admin/",
10
+ "uiUrl": "admin/",
11
+ "configUiUrl": "/vault/admin/",
12
+ "focus": "core",
13
+ "adminCapabilities": ["config", "credentials"],
11
14
  "startCmd": ["parachute-vault", "serve"],
12
15
  "scopes": {
13
16
  "defines": ["vault:read", "vault:write", "vault:admin"]
14
- }
17
+ },
18
+ "events": [
19
+ { "key": "note.created", "title": "A note was created" },
20
+ { "key": "note.updated", "title": "A note was updated" },
21
+ { "key": "note.deleted", "title": "A note was deleted" }
22
+ ],
23
+ "actions": [
24
+ { "key": "note.create", "title": "Create a note", "inputSchema": {} }
25
+ ]
15
26
  }
package/README.md CHANGED
@@ -99,7 +99,7 @@ The daemon binds `0.0.0.0:1940` (or whatever you set in `PORT`) and serves REST,
99
99
 
100
100
  `vault init` asks two explicit questions: (1) install vault as an MCP server in `~/.claude.json`? (2) also surface the access token so you can paste it into other MCP clients (Codex, Goose, OpenCode, Cursor, Zed, Cline), scripts, or `curl`? Both default yes. Pass `--mcp` / `--no-mcp` and `--token` / `--no-token` for non-interactive installs.
101
101
 
102
- If you said yes to (2), the hub-issued JWT is printed prominently at the end — it's the same token baked into `~/.claude.json` (if you also said yes to (1)). It's not stored anywhere retrievable — save it if you need it for `curl`, cron, or any other script. Lost it? Mint a fresh one with `parachute auth mint-token --scope vault:<name>:<verb>` (or rewire an MCP client with `parachute-vault mcp-install`, or use the admin SPA Tokens page). As of vault 0.6.0 (vault#282 Stage 2) vault no longer mints its own `pvt_*` tokens — minting is the hub's job.
102
+ If you said yes to (2), the hub-issued JWT is printed prominently at the end — it's the same token baked into `~/.claude.json` (if you also said yes to (1)). It's not stored anywhere retrievable — save it if you need it for `curl`, cron, or any other script. Lost it? Mint a fresh one with `parachute auth mint-token --scope vault:<name>:<verb>` (or rewire an MCP client with `parachute-vault mcp-install`, or use the admin SPA Tokens page). As of vault 0.5.0 (vault#282 Stage 2) vault no longer mints its own `pvt_*` tokens — minting is the hub's job.
103
103
 
104
104
  ### OAuth lives on the hub
105
105
 
@@ -122,7 +122,7 @@ Two ways to authenticate — pick based on the client, not the deployment:
122
122
  | **OAuth 2.1 + PKCE (browser flow, via hub)** | Claude Desktop, Parachute Daily, any third-party MCP client set up interactively | Click "Add integration", enter the vault MCP URL, a browser opens to the **hub's** consent page, sign in with hub credentials, done — no token ever touches your clipboard |
123
123
  | **Bearer token (hub JWT)** | Claude Code (auto-wired by `vault init`), CLI scripts, cron jobs, any non-interactive caller | `curl -H "Authorization: Bearer <hub-jwt>"` — mint one with `parachute-vault mcp-install` (MCP clients) or `parachute auth mint-token --scope vault:<name>:<verb>` (scripts) |
124
124
 
125
- As of 0.6.0 (vault#282 Stage 2) vault is a **pure hub resource-server**: both paths use a hub-signed JWT that vault validates against the hub's JWKS. (The OAuth path is the interactive browser handshake; the bearer path mints the same kind of JWT non-interactively.) The old vault-local `pvt_*` opaque token was dropped — vault no longer mints or accepts it. The server-wide `VAULT_AUTH_TOKEN` operator bearer remains for the no-granular-auth / cross-container path.
125
+ As of 0.5.0 (vault#282 Stage 2) vault is a **pure hub resource-server**: both paths use a hub-signed JWT that vault validates against the hub's JWKS. (The OAuth path is the interactive browser handshake; the bearer path mints the same kind of JWT non-interactively.) The old vault-local `pvt_*` opaque token was dropped — vault no longer mints or accepts it. The server-wide `VAULT_AUTH_TOKEN` operator bearer remains for the no-granular-auth / cross-container path.
126
126
 
127
127
  ### Claude Code
128
128
 
@@ -219,7 +219,7 @@ parachute-vault 2fa backup-codes # regenerate backup codes
219
219
  # Tokens — vault#282 Stage 2: vault no longer mints its own tokens. Mint a
220
220
  # hub JWT with `parachute-vault mcp-install` (MCP clients) or
221
221
  # `parachute auth mint-token --scope vault:<name>:<verb>` (scripts).
222
- parachute-vault tokens # list any vestigial pre-0.6.0 token rows (all vaults)
222
+ parachute-vault tokens # list any vestigial pre-0.5.0 token rows (all vaults)
223
223
  parachute-vault tokens revoke <token-id> # revoke a vestigial row (default vault; add --vault to target)
224
224
 
225
225
  # Obsidian
@@ -531,7 +531,7 @@ The SSG / sync pattern. Two equivalent forms — bracket-style is canonical goin
531
531
  curl -H "Authorization: Bearer $VAULT_TOKEN" \
532
532
  "http://localhost:1940/vault/default/api/notes?meta[updated_at][gte]=2026-04-01T00:00:00Z"
533
533
 
534
- # Flat form (DEPRECATED in 0.4.3; planned removal 0.6.0 per vault#288)
534
+ # Flat form (DEPRECATED in 0.4.3; planned removal in a later 0.x per vault#288)
535
535
  curl -H "Authorization: Bearer $VAULT_TOKEN" \
536
536
  "http://localhost:1940/vault/default/api/notes?date_field=updated_at&date_from=2026-04-01T00:00:00Z"
537
537
  ```
@@ -704,13 +704,13 @@ For wiring up an AI client (Claude Code, Claude Desktop, Parachute Daily), see [
704
704
 
705
705
  ### Passing the key
706
706
 
707
- As of 0.6.0 (vault#282 Stage 2) vault accepts these bearers at every authenticated endpoint:
707
+ As of 0.5.0 (vault#282 Stage 2) vault accepts these bearers at every authenticated endpoint:
708
708
 
709
709
  - **Hub-issued JWT** (`eyJ...`) — the user-credential path; what OAuth issues and what `parachute-vault mcp-install` / `parachute auth mint-token` produce. Audience-bound to `vault.<name>`, scope-narrowed (`vault:<name>:<verb>`).
710
710
  - **`VAULT_AUTH_TOKEN`** — the server-wide operator bearer (env var; full-admin against any vault on the server).
711
711
  - **`pvk_...`** — legacy global API keys from `config.yaml` / per-vault `vault.yaml` (still honored for existing deployments).
712
712
 
713
- The old vault-local `pvt_*` opaque token was **dropped at 0.6.0** — vault no longer mints or accepts it.
713
+ The old vault-local `pvt_*` opaque token was **dropped at 0.5.0** — vault no longer mints or accepts it.
714
714
 
715
715
  ```bash
716
716
  # Header (preferred)
@@ -742,7 +742,7 @@ Two permission levels carry through the JWT scope verb:
742
742
  | `read` | Query, list, find-path, vault-info only |
743
743
 
744
744
  `parachute-vault tokens list` / `tokens revoke` remain only to clean up any
745
- vestigial pre-0.6.0 rows. Legacy `pvk_...` keys from config.yaml still work at
745
+ vestigial pre-0.5.0 rows. Legacy `pvk_...` keys from config.yaml still work at
746
746
  runtime; the `vault keys` CLI commands were removed long ago.
747
747
 
748
748
  ### Public endpoints
@@ -4,6 +4,7 @@ import { SqliteStore } from "./store.js";
4
4
  import { generateMcpTools } from "./mcp.js";
5
5
  import { initSchema } from "./schema.js";
6
6
  import { decodeCursor } from "./cursor.js";
7
+ import { traverseLinks } from "./links.js";
7
8
 
8
9
  let store: SqliteStore;
9
10
  let db: Database;
@@ -972,6 +973,33 @@ describe("vault stats", async () => {
972
973
  expect(stats.topTags).toEqual([]);
973
974
  expect(stats.tagCount).toBe(0);
974
975
  expect(stats.linkCount).toBe(0);
976
+ expect(stats.contentBytes).toBe(0);
977
+ });
978
+
979
+ it("contentBytes sums ASCII content as raw byte length", async () => {
980
+ // "hello" = 5 bytes, "world!" = 6 bytes — for pure ASCII, bytes == chars.
981
+ await store.createNote("hello");
982
+ await store.createNote("world!");
983
+ const stats = await store.getVaultStats();
984
+ expect(stats.contentBytes).toBe(11);
985
+ });
986
+
987
+ it("contentBytes counts UTF-8 BYTES, not characters (multibyte)", async () => {
988
+ // The whole point of CAST(content AS BLOB): SQLite's bare LENGTH() would
989
+ // return the CHARACTER count, undercounting multibyte content.
990
+ // - "é" is U+00E9 → 2 bytes in UTF-8 (1 char)
991
+ // - "你好" is 2 CJK chars → 6 bytes (3 bytes each)
992
+ // - "😀" is 1 grapheme / 2 UTF-16 code units → 4 bytes in UTF-8
993
+ const content = "é你好😀";
994
+ const expectedBytes = Buffer.byteLength(content, "utf8"); // 2 + 6 + 4 = 12
995
+ expect(expectedBytes).toBe(12);
996
+ // And it's strictly MORE than the JS character count — proves we're not
997
+ // accidentally counting chars.
998
+ expect(expectedBytes).toBeGreaterThan([...content].length);
999
+
1000
+ await store.createNote(content);
1001
+ const stats = await store.getVaultStats();
1002
+ expect(stats.contentBytes).toBe(expectedBytes);
975
1003
  });
976
1004
 
977
1005
  it("counts total notes and tagCount", async () => {
@@ -1041,6 +1069,7 @@ describe("vault stats", async () => {
1041
1069
  expect(stats).toHaveProperty("topTags");
1042
1070
  expect(stats).toHaveProperty("tagCount");
1043
1071
  expect(stats).toHaveProperty("linkCount");
1072
+ expect(stats).toHaveProperty("contentBytes");
1044
1073
  });
1045
1074
 
1046
1075
  it("counts resolved wikilinks in linkCount", async () => {
@@ -1866,6 +1895,59 @@ describe("links", async () => {
1866
1895
  const links = await store.getLinks("a");
1867
1896
  expect(links.filter((l) => l.relationship === "mentions")).toHaveLength(1);
1868
1897
  });
1898
+
1899
+ // vault#439 — traverseLinks isTraversable predicate (wall, not sieve).
1900
+ // Topology: a -> b(blocked) -> c. A predicate that blocks `b` must make
1901
+ // `c` unreachable (the BFS can't walk THROUGH b), not merely filtered out.
1902
+ it("traverseLinks: isTraversable predicate is a wall (can't reach past a blocked hop)", async () => {
1903
+ await store.createNote("A", { id: "a" });
1904
+ await store.createNote("B", { id: "b" });
1905
+ await store.createNote("C", { id: "c" });
1906
+ await store.createLink("a", "b", "relates");
1907
+ await store.createLink("b", "c", "relates");
1908
+
1909
+ const blocked = traverseLinks(db, "a", {
1910
+ max_depth: 5,
1911
+ isTraversable: (id) => id !== "b",
1912
+ });
1913
+ const ids = blocked.map((t) => t.noteId);
1914
+ expect(ids).not.toContain("b"); // blocked hop is excluded from results
1915
+ expect(ids).not.toContain("c"); // and unreachable beyond it
1916
+ });
1917
+
1918
+ it("traverseLinks: no predicate walks the full graph (unchanged)", async () => {
1919
+ await store.createNote("A", { id: "a" });
1920
+ await store.createNote("B", { id: "b" });
1921
+ await store.createNote("C", { id: "c" });
1922
+ await store.createLink("a", "b", "relates");
1923
+ await store.createLink("b", "c", "relates");
1924
+
1925
+ const all = traverseLinks(db, "a", { max_depth: 5 });
1926
+ const ids = all.map((t) => t.noteId);
1927
+ expect(ids).toContain("b");
1928
+ expect(ids).toContain("c");
1929
+ });
1930
+
1931
+ it("traverseLinks: an allowed alternate path still reaches the far node", async () => {
1932
+ // a -> b(blocked) -> d ; a -> c(allowed) -> d. d reachable via c.
1933
+ await store.createNote("A", { id: "a" });
1934
+ await store.createNote("B", { id: "b" });
1935
+ await store.createNote("C", { id: "c" });
1936
+ await store.createNote("D", { id: "d" });
1937
+ await store.createLink("a", "b", "relates");
1938
+ await store.createLink("b", "d", "relates");
1939
+ await store.createLink("a", "c", "relates");
1940
+ await store.createLink("c", "d", "relates");
1941
+
1942
+ const res = traverseLinks(db, "a", {
1943
+ max_depth: 5,
1944
+ isTraversable: (id) => id !== "b",
1945
+ });
1946
+ const ids = res.map((t) => t.noteId);
1947
+ expect(ids).not.toContain("b");
1948
+ expect(ids).toContain("c");
1949
+ expect(ids).toContain("d"); // reachable via the allowed c-path
1950
+ });
1869
1951
  });
1870
1952
 
1871
1953
  // ---- Attachments ----
@@ -1985,6 +2067,41 @@ describe("MCP tools", async () => {
1985
2067
  expect(result[1].tags).toContain("doc");
1986
2068
  });
1987
2069
 
2070
+ // vault#316 — the create-note tool re-reads each note AFTER
2071
+ // `applySchemaDefaults` runs, so the response reflects the post-defaults
2072
+ // on-disk state (matching the update-note path). Before the fix the
2073
+ // response mapped over the pre-defaults in-memory objects, so a
2074
+ // schema-default-filled field was missing from the returned note even
2075
+ // though it had just been written to disk.
2076
+ it("create-note response reflects post-applySchemaDefaults state (vault#316)", async () => {
2077
+ await store.upsertTagSchema("task", {
2078
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
2079
+ });
2080
+ const tools = generateMcpTools(store);
2081
+ const createNote = tools.find((t) => t.name === "create-note")!;
2082
+
2083
+ // Single: default lands in the returned metadata.
2084
+ const single = await createNote.execute({
2085
+ content: "do the thing",
2086
+ path: "Inbox/task-1",
2087
+ tags: ["task"],
2088
+ }) as any;
2089
+ expect(single.metadata?.priority).toBe("high"); // first enum value
2090
+ // Disk and response agree.
2091
+ const onDisk = await store.getNoteByPath("Inbox/task-1");
2092
+ expect((onDisk!.metadata as any)?.priority).toBe("high");
2093
+
2094
+ // Batch: each entry is re-read post-defaults too.
2095
+ const batch = await createNote.execute({
2096
+ notes: [
2097
+ { content: "a", path: "Inbox/task-2", tags: ["task"] },
2098
+ { content: "b", path: "Inbox/task-3", tags: ["task"] },
2099
+ ],
2100
+ }) as any[];
2101
+ expect(batch[0].metadata?.priority).toBe("high");
2102
+ expect(batch[1].metadata?.priority).toBe("high");
2103
+ });
2104
+
1988
2105
  it("create-note accepts extension field (vault#328)", async () => {
1989
2106
  const tools = generateMcpTools(store);
1990
2107
  const createNote = tools.find((t) => t.name === "create-note")!;
@@ -2131,6 +2248,113 @@ describe("MCP tools", async () => {
2131
2248
  expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
2132
2249
  });
2133
2250
 
2251
+ // vault feedback #8 — echo hydrated links on the update response when the
2252
+ // request mutated links OR `include_links` is set, so callers don't re-query.
2253
+ it("update-note echoes hydrated links when the update mutates links", async () => {
2254
+ await store.createNote("A", { id: "a" });
2255
+ await store.createNote("B", { id: "b", path: "beta", tags: ["t"] });
2256
+ const tools = generateMcpTools(store);
2257
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2258
+ const result = await updateNote.execute({
2259
+ id: "a",
2260
+ links: { add: [{ target: "b", relationship: "mentions" }] },
2261
+ force: true,
2262
+ }) as any;
2263
+ expect(Array.isArray(result.links)).toBe(true);
2264
+ expect(result.links).toHaveLength(1);
2265
+ // Hydrated shape matches query-notes' include_links output.
2266
+ const link = result.links[0];
2267
+ expect(link.sourceId).toBe("a");
2268
+ expect(link.targetId).toBe("b");
2269
+ expect(link.relationship).toBe("mentions");
2270
+ expect(link.targetNote.path).toBe("beta");
2271
+ expect(link.targetNote.tags).toEqual(["t"]);
2272
+ });
2273
+
2274
+ it("update-note echoes links on removal (post-removal set)", async () => {
2275
+ await store.createNote("A", { id: "a" });
2276
+ await store.createNote("B", { id: "b" });
2277
+ await store.createNote("C", { id: "c" });
2278
+ await store.createLink("a", "b", "mentions");
2279
+ await store.createLink("a", "c", "mentions");
2280
+ const tools = generateMcpTools(store);
2281
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2282
+ const result = await updateNote.execute({
2283
+ id: "a",
2284
+ links: { remove: [{ target: "b", relationship: "mentions" }] },
2285
+ force: true,
2286
+ }) as any;
2287
+ expect(result.links).toHaveLength(1);
2288
+ expect(result.links[0].targetId).toBe("c");
2289
+ });
2290
+
2291
+ it("update-note with include_links echoes links even without a mutation", async () => {
2292
+ await store.createNote("A", { id: "a" });
2293
+ await store.createNote("B", { id: "b" });
2294
+ await store.createLink("a", "b", "mentions");
2295
+ const tools = generateMcpTools(store);
2296
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2297
+ const result = await updateNote.execute({
2298
+ id: "a",
2299
+ content: "updated",
2300
+ include_links: true,
2301
+ force: true,
2302
+ }) as any;
2303
+ expect(result.content).toBe("updated");
2304
+ expect(result.links).toHaveLength(1);
2305
+ expect(result.links[0].targetId).toBe("b");
2306
+ });
2307
+
2308
+ it("update-note without a link mutation or flag does NOT echo links", async () => {
2309
+ await store.createNote("A", { id: "a" });
2310
+ await store.createNote("B", { id: "b" });
2311
+ await store.createLink("a", "b", "mentions");
2312
+ const tools = generateMcpTools(store);
2313
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2314
+ const result = await updateNote.execute({ id: "a", content: "updated", force: true }) as any;
2315
+ expect(result.content).toBe("updated");
2316
+ expect(result).not.toHaveProperty("links");
2317
+ });
2318
+
2319
+ it("update-note batch echoes links per-item (only on the mutated/flagged item)", async () => {
2320
+ await store.createNote("A", { id: "a" });
2321
+ await store.createNote("B", { id: "b" });
2322
+ await store.createNote("C", { id: "c" });
2323
+ const tools = generateMcpTools(store);
2324
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2325
+ const result = await updateNote.execute({
2326
+ notes: [
2327
+ // Item 0 mutates links → echoes.
2328
+ { id: "a", links: { add: [{ target: "b", relationship: "mentions" }] }, force: true },
2329
+ // Item 1 only edits content, no flag → no links key.
2330
+ { id: "c", content: "C updated", force: true },
2331
+ ],
2332
+ }) as any[];
2333
+ expect(result).toHaveLength(2);
2334
+ expect(result[0].links).toHaveLength(1);
2335
+ expect(result[0].links[0].targetId).toBe("b");
2336
+ expect(result[1]).not.toHaveProperty("links");
2337
+ });
2338
+
2339
+ it("update-note if_missing=create with include_links echoes links (no link mutation)", async () => {
2340
+ const tools = generateMcpTools(store);
2341
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2342
+ // The created note has no links yet and the payload declares none, so the
2343
+ // echo is driven purely by the explicit `include_links` flag — closing the
2344
+ // create-on-missing × flag-only matrix gap. Hydrated `links` key is present
2345
+ // (empty array), not absent.
2346
+ const result = await updateNote.execute({
2347
+ id: "fresh-note",
2348
+ content: "brand new",
2349
+ if_missing: "create",
2350
+ include_links: true,
2351
+ }) as any;
2352
+ expect(result.created).toBe(true);
2353
+ expect(result.content).toBe("brand new");
2354
+ expect(Array.isArray(result.links)).toBe(true);
2355
+ expect(result.links).toHaveLength(0);
2356
+ });
2357
+
2134
2358
  it("update-note removes wikilink brackets when removing wikilink-type link", async () => {
2135
2359
  await store.createNote("Target", { id: "target", path: "People/Alice" });
2136
2360
  const source = await store.createNote("See [[People/Alice]] for details", { id: "source" });
@@ -4932,43 +5156,65 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
4932
5156
  expect(idxZebra).toBeGreaterThan(idxAlpha);
4933
5157
  });
4934
5158
 
4935
- it("update-tag MCP rejects an invalid cardinality", async () => {
5159
+ // ---- relationships is an opaque vocabulary map (vault#428) ----
5160
+ // Vault no longer enforces the historical { target_tag, cardinality }
5161
+ // shape. Apps (the Weaver / structural-link picker) declare a freeform
5162
+ // vocabulary; vault stores + returns it verbatim. The old typed shape is
5163
+ // still a valid value, so the loosening is a backwards-compatible superset.
5164
+
5165
+ it("update-tag MCP persists the opaque relationship-vocabulary map verbatim (vault#428)", async () => {
4936
5166
  const tools = generateMcpTools(store);
4937
5167
  const update = tools.find((t) => t.name === "update-tag")!;
4938
- await expect(
4939
- update.execute({
4940
- tag: "project",
4941
- relationships: {
4942
- owned_by: { target_tag: "person", cardinality: "bogus" },
4943
- },
4944
- }),
4945
- ).rejects.toThrow(/cardinality/);
5168
+ const vocab = {
5169
+ "works-on": { from: "person", to: "project" },
5170
+ "member-of": { from: "person", to: "organization" },
5171
+ "partner-of": { from: "person", to: "person" },
5172
+ "based-at": { from: "project", to: "place" },
5173
+ };
5174
+ await update.execute({ tag: "person", relationships: vocab });
5175
+ const r = await store.getTagRecord("person");
5176
+ expect(r?.relationships).toEqual(vocab);
4946
5177
  });
4947
5178
 
4948
- it("update-tag MCP accepts every cardinality in the named vocabulary", async () => {
5179
+ it("update-tag MCP round-trips nested arbitrary relationship values verbatim", async () => {
4949
5180
  const tools = generateMcpTools(store);
4950
5181
  const update = tools.find((t) => t.name === "update-tag")!;
4951
- for (const card of ["one", "optional", "many", "many-required"]) {
4952
- await update.execute({
4953
- tag: `tag-${card}`,
4954
- relationships: {
4955
- rel: { target_tag: "other", cardinality: card },
4956
- },
4957
- });
4958
- const r = await store.getTagRecord(`tag-${card}`);
4959
- expect(r?.relationships?.rel?.cardinality).toBe(card);
4960
- }
5182
+ const vocab = {
5183
+ rel: { from: "a", to: "b", note: "freeform", weight: 3, optional: true, tags: ["x", "y"] },
5184
+ };
5185
+ await update.execute({ tag: "thing", relationships: vocab });
5186
+ const r = await store.getTagRecord("thing");
5187
+ expect(r?.relationships).toEqual(vocab);
5188
+ });
5189
+
5190
+ it("update-tag MCP still accepts the historical typed shape (backwards-compat)", async () => {
5191
+ const tools = generateMcpTools(store);
5192
+ const update = tools.find((t) => t.name === "update-tag")!;
5193
+ const typed = { owned_by: { target_tag: "person", cardinality: "one", description: "DRI" } };
5194
+ await update.execute({ tag: "project", relationships: typed });
5195
+ const r = await store.getTagRecord("project");
5196
+ expect(r?.relationships).toEqual(typed);
5197
+ });
5198
+
5199
+ it("update-tag MCP accepts what used to be rejected (no inner-shape enforcement)", async () => {
5200
+ const tools = generateMcpTools(store);
5201
+ const update = tools.find((t) => t.name === "update-tag")!;
5202
+ // Formerly rejected: non-vocabulary cardinality + missing target_tag.
5203
+ const formerlyInvalid = {
5204
+ a: { target_tag: "person", cardinality: "bogus" },
5205
+ b: { cardinality: "one" },
5206
+ };
5207
+ await update.execute({ tag: "loose", relationships: formerlyInvalid });
5208
+ const r = await store.getTagRecord("loose");
5209
+ expect(r?.relationships).toEqual(formerlyInvalid);
4961
5210
  });
4962
5211
 
4963
- it("update-tag MCP rejects a relationship missing target_tag", async () => {
5212
+ it("update-tag MCP rejects a top-level array for relationships (must be a map)", async () => {
4964
5213
  const tools = generateMcpTools(store);
4965
5214
  const update = tools.find((t) => t.name === "update-tag")!;
4966
5215
  await expect(
4967
- update.execute({
4968
- tag: "project",
4969
- relationships: { owned_by: { cardinality: "one" } },
4970
- }),
4971
- ).rejects.toThrow(/target_tag/);
5216
+ update.execute({ tag: "project", relationships: ["not", "a", "map"] as unknown as Record<string, unknown> }),
5217
+ ).rejects.toThrow();
4972
5218
  });
4973
5219
 
4974
5220
  it("update-tag MCP sets parent_names and the hierarchy invalidates", async () => {
@@ -5609,6 +5855,13 @@ describe("vault projection (vault#271)", async () => {
5609
5855
  expect(md).toContain("#person");
5610
5856
  expect(md).toContain("vault-info");
5611
5857
  expect(md).toContain("list-tags { include_schema: true }");
5858
+ // Scripting pointer (closes the "points nowhere" gap): the brief routes an
5859
+ // agent to the HTTP API + the public guide, with the vault name baked into
5860
+ // the copy-paste mint command.
5861
+ expect(md).toContain("## Scripting & automation (beyond this session)");
5862
+ expect(md).toContain("https://parachute.computer/scripting/");
5863
+ expect(md).toContain("parachute auth mint-token --scope vault:test:read --ephemeral");
5864
+ expect(md).toContain("vault/test/api");
5612
5865
  });
5613
5866
 
5614
5867
  it("markdown brief degrades gracefully when no schemas declared", async () => {
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { initSchema } from "./schema.js";
4
+ import { createNote } from "./notes.js";
5
+ import { expandContent, type ExpandContext } from "./expand.js";
6
+ import type { Note } from "./types.js";
7
+
8
+ /**
9
+ * Security regression (vault security review — tag-scope confidentiality).
10
+ *
11
+ * `expandContent`'s optional `isVisible` predicate is the seam that keeps
12
+ * core scope-unaware while letting the server enforce tag scope during
13
+ * wikilink inlining. These tests pin the load-bearing invariant: when the
14
+ * predicate rejects a target, the wikilink is left UNRESOLVED — byte-for-byte
15
+ * identical to a genuinely-missing target, so the response can't reveal that
16
+ * the out-of-scope note exists. They MUST fail if the predicate is removed.
17
+ */
18
+
19
+ let db: Database;
20
+
21
+ beforeEach(() => {
22
+ db = new Database(":memory:");
23
+ initSchema(db);
24
+ });
25
+
26
+ function ctx(over: Partial<ExpandContext> = {}): ExpandContext {
27
+ return { db, mode: "full", expanded: new Set<string>(), ...over };
28
+ }
29
+
30
+ describe("expandContent isVisible predicate (tag-scope confidentiality)", () => {
31
+ it("inlines an in-scope target's content when no predicate is set (baseline)", () => {
32
+ createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
33
+ const out = expandContent("see [[Secret]]", ctx(), 1);
34
+ expect(out).toContain("SECRET BODY");
35
+ });
36
+
37
+ it("leaves an out-of-scope wikilink unresolved — no content inlined", () => {
38
+ createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
39
+ // Predicate: only #work notes are visible. The target is #personal.
40
+ const isVisible = (n: Note) => (n.tags ?? []).includes("work");
41
+ const out = expandContent("see [[Secret]]", ctx({ isVisible }), 1);
42
+ expect(out).not.toContain("SECRET BODY");
43
+ // The wikilink stays literal.
44
+ expect(out).toBe("see [[Secret]]");
45
+ });
46
+
47
+ it("out-of-scope target is INDISTINGUISHABLE from a missing target", () => {
48
+ // Case A: target exists but is out of scope.
49
+ createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
50
+ const isVisible = (n: Note) => (n.tags ?? []).includes("work");
51
+ const outOfScope = expandContent("see [[Secret]]", ctx({ isVisible }), 1);
52
+
53
+ // Case B: target genuinely does not exist (fresh db, same predicate).
54
+ const db2 = new Database(":memory:");
55
+ initSchema(db2);
56
+ const missing = expandContent("see [[Secret]]", { db: db2, mode: "full", expanded: new Set(), isVisible }, 1);
57
+
58
+ // Byte-identical output → existence is not leaked.
59
+ expect(outOfScope).toBe(missing);
60
+ expect(outOfScope).toBe("see [[Secret]]");
61
+ });
62
+
63
+ it("a second reference to an out-of-scope note does NOT render `(expanded above)`", () => {
64
+ // Pins that an out-of-scope note never enters the `expanded` set — else a
65
+ // repeat reference would render `(expanded above)` and leak existence via
66
+ // a different output than a missing target.
67
+ createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
68
+ const isVisible = (n: Note) => (n.tags ?? []).includes("work");
69
+ const out = expandContent("[[Secret]] and again [[Secret]]", ctx({ isVisible }), 1);
70
+ expect(out).toBe("[[Secret]] and again [[Secret]]");
71
+ expect(out).not.toContain("expanded above");
72
+ expect(out).not.toContain("SECRET BODY");
73
+ });
74
+
75
+ it("multi-hop (depth>1) does not leak out-of-scope content at any depth", () => {
76
+ // in-scope #work note → wikilinks an out-of-scope #personal note.
77
+ createNote(db, "DEEP SECRET", { path: "Deep", tags: ["personal"] });
78
+ createNote(db, "intro [[Deep]]", { path: "Mid", tags: ["work"] });
79
+ createNote(db, "top [[Mid]]", { path: "Top", tags: ["work"] });
80
+
81
+ const isVisible = (n: Note) => (n.tags ?? []).includes("work");
82
+ // Depth 3 — would walk Top → Mid → Deep without the predicate.
83
+ const out = expandContent("[[Top]]", ctx({ isVisible }), 3);
84
+ // The in-scope Mid IS inlined; the out-of-scope Deep is NOT.
85
+ expect(out).toContain("intro");
86
+ expect(out).not.toContain("DEEP SECRET");
87
+ // The Deep wikilink stays literal inside Mid's inlined content.
88
+ expect(out).toContain("[[Deep]]");
89
+ });
90
+
91
+ it("predicate is also honored in summary mode", () => {
92
+ createNote(db, "x", {
93
+ path: "Secret",
94
+ tags: ["personal"],
95
+ metadata: { summary: "SECRET SUMMARY" },
96
+ });
97
+ const isVisible = (n: Note) => (n.tags ?? []).includes("work");
98
+ const out = expandContent("see [[Secret]]", ctx({ mode: "summary", isVisible }), 1);
99
+ expect(out).not.toContain("SECRET SUMMARY");
100
+ expect(out).toBe("see [[Secret]]");
101
+ });
102
+ });
@@ -22,6 +22,22 @@ export interface ExpandContext {
22
22
  mode: ExpandMode;
23
23
  /** Note IDs already expanded in this query. Shared across all expansions. */
24
24
  expanded: Set<string>;
25
+ /**
26
+ * Optional visibility predicate (tag-scope confidentiality, vault security
27
+ * review). When set and it returns `false` for a resolved target note, the
28
+ * wikilink is left UNRESOLVED — byte-identical to the not-found/unresolved
29
+ * branch, so an out-of-scope target is indistinguishable from a missing one
30
+ * (we never reveal existence differently across the scope boundary).
31
+ *
32
+ * Core stays scope-unaware: the server constructs this predicate from its
33
+ * tag-scope machinery and injects it; core only ever *calls* it. When
34
+ * unset (every unscoped caller), expansion behaves exactly as before.
35
+ *
36
+ * Applied at every hop — multi-hop (`expand_depth > 1`) expansion runs the
37
+ * predicate on each resolved target before inlining, so a deep chain can't
38
+ * tunnel out-of-scope content through an in-scope intermediary.
39
+ */
40
+ isVisible?: (note: Note) => boolean;
25
41
  }
26
42
 
27
43
  /**
@@ -62,14 +78,26 @@ export function expandContent(
62
78
  const noteId = resolveWikilink(ctx.db, target);
63
79
  if (!noteId) return match; // unresolved or ambiguous — leave as-is
64
80
 
81
+ // Resolve the target BEFORE the dedup check so the visibility gate can run
82
+ // first — an out-of-scope target must never enter `expanded` (otherwise a
83
+ // second reference to it would render `(expanded above)` and leak its
84
+ // existence via a different output than a genuinely-missing target).
85
+ const note = noteOps.getNote(ctx.db, noteId);
86
+ if (!note) return match; // shouldn't happen, but be safe
87
+
88
+ // Tag-scope confidentiality (vault security review): if a visibility
89
+ // predicate is installed and the resolved target is out of scope, leave
90
+ // the wikilink UNRESOLVED — byte-identical to the not-found / unresolved
91
+ // branches above. The out-of-scope case must be indistinguishable from a
92
+ // missing target so the response can't leak the target's existence. Runs
93
+ // at every hop, so multi-hop expansion can't tunnel out-of-scope content.
94
+ if (ctx.isVisible && !ctx.isVisible(note)) return match;
95
+
65
96
  if (ctx.expanded.has(noteId)) {
66
97
  return `${match} (expanded above)`;
67
98
  }
68
99
  ctx.expanded.add(noteId);
69
100
 
70
- const note = noteOps.getNote(ctx.db, noteId);
71
- if (!note) return match; // shouldn't happen, but be safe
72
-
73
101
  if (ctx.mode === "summary") {
74
102
  // Summary mode doesn't recurse: depth > 1 has no additional effect.
75
103
  return renderSummary(note);
@@ -101,7 +101,7 @@ export function listIndexedFields(db: Database): IndexedField[] {
101
101
  export function getIndexedField(db: Database, field: string): IndexedField | null {
102
102
  const row = db
103
103
  .prepare("SELECT field, sqlite_type, declarer_tags FROM indexed_fields WHERE field = ?")
104
- .get(field) as IndexedFieldRow | undefined;
104
+ .get(field) as IndexedFieldRow | null;
105
105
  return row ? rowToField(row) : null;
106
106
  }
107
107