@openparachute/vault 0.6.0-rc.1 → 0.6.1

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 (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -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,
@@ -44,6 +44,7 @@ const {
44
44
  const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
45
45
  const { vaultDbPath } = await import("./config.ts");
46
46
  const { resetJwksCache, resetRevocationCache } = await import("./hub-jwt.ts");
47
+ const { invalidateUsageCache } = await import("./usage.ts");
47
48
 
48
49
  // ---------------------------------------------------------------------------
49
50
  // Hub-JWT mint fixture (vault#282 Stage 2 — vault is a pure hub
@@ -100,8 +101,8 @@ function createVault(name: string, description?: string): void {
100
101
 
101
102
  /**
102
103
  * Seed a vestigial row directly into a vault's `tokens` table (raw INSERT).
103
- * Post-0.6.0 (vault#282 Stage 2) vault no longer mints these, but the table
104
- * survives + `/auth/status` probes it for leftover pre-0.6.0 rows. This is how
104
+ * Post-0.5.0 (vault#282 Stage 2) vault no longer mints these, but the table
105
+ * survives + `/auth/status` probes it for leftover pre-0.5.0 rows. This is how
105
106
  * we exercise the `hasTokens=true` branch now that there's no mint path.
106
107
  */
107
108
  function seedVestigialTokenRow(vaultName: string): void {
@@ -114,7 +115,7 @@ function seedVestigialTokenRow(vaultName: string): void {
114
115
  /**
115
116
  * Seed a vestigial tag-scoped row (raw INSERT, `scoped_tags` JSON populated).
116
117
  * The tag-delete / -rename / -merge fail-closed guard (`findTokensReferencingTag`)
117
- * reads this column. Post-0.6.0 hub-JWT tag scopes live in the JWT claim, not
118
+ * reads this column. Post-0.5.0 hub-JWT tag scopes live in the JWT claim, not
118
119
  * the DB, so the guard now protects only these vestigial rows — these tests
119
120
  * pin that the DB-row guard still fires.
120
121
  */
@@ -135,6 +136,12 @@ function seedTagScopedRow(vaultName: string, scopedTags: string[], label = "test
135
136
 
136
137
  function reset(): void {
137
138
  clearVaultStoreCache();
139
+ // The usage dir-walk cache is module-level (process-wide) and survives the
140
+ // testDir wipe; its 60s TTL would otherwise leak a prior test's `journal`
141
+ // entry into the next test and flip a "first read" to cached:true. Clear
142
+ // the vault names these tests reuse so each test starts cache-cold.
143
+ invalidateUsageCache("journal");
144
+ invalidateUsageCache("other");
138
145
  if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
139
146
  mkdirSync(testDir, { recursive: true });
140
147
  mkdirSync(join(testDir, "vault", "data"), { recursive: true });
@@ -142,7 +149,12 @@ function reset(): void {
142
149
  // Default every test to the fixture hub origin so the hub-JWT mint path
143
150
  // resolves JWKS + validates `iss`. Describes that assert the loopback
144
151
  // default (OAuth discovery metadata) override this in their own beforeEach.
145
- if (hubFixtureOrigin) process.env.PARACHUTE_HUB_ORIGIN = hubFixtureOrigin;
152
+ if (hubFixtureOrigin) {
153
+ process.env.PARACHUTE_HUB_ORIGIN = hubFixtureOrigin;
154
+ // Post-vault#464 the JWKS fetch origin resolves separately (loopback by
155
+ // default); point it at the fixture so the mint path's JWKS fetch resolves.
156
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = hubFixtureOrigin;
157
+ }
146
158
  resetJwksCache();
147
159
  resetRevocationCache();
148
160
  }
@@ -176,6 +188,7 @@ afterAll(() => {
176
188
  clearVaultStoreCache();
177
189
  hubServer?.stop(true);
178
190
  delete process.env.PARACHUTE_HUB_ORIGIN;
191
+ delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
179
192
  if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
180
193
  });
181
194
 
@@ -311,6 +324,56 @@ describe("GET /vaults/list (public discovery)", () => {
311
324
  });
312
325
  });
313
326
 
327
+ // ---------------------------------------------------------------------------
328
+ // GET /vaults — authenticated metadata listing, filtered by vault binding
329
+ // (vault#259). Operator / admin-channel callers (vault_name === null) see the
330
+ // full list; a vault-bound caller sees only its own vault.
331
+ // ---------------------------------------------------------------------------
332
+
333
+ describe("GET /vaults (binding filter — vault#259)", () => {
334
+ // Pure policy helper — drives the filtering decision independent of the
335
+ // auth path (no current credential yields a non-null vault_name HERE, since
336
+ // authenticateGlobalRequest 401s hub JWTs; the helper pins the correct
337
+ // shape for any future vault-bound credential on this surface).
338
+ test("filterVaultListForBinding: null binding (operator) keeps the full list", () => {
339
+ const names = ["work", "boulder", "default"];
340
+ expect(filterVaultListForBinding(names, null)).toEqual(names);
341
+ });
342
+
343
+ test("filterVaultListForBinding: a vault-bound caller sees only its own vault", () => {
344
+ const names = ["work", "boulder", "default"];
345
+ expect(filterVaultListForBinding(names, "work")).toEqual(["work"]);
346
+ // No cross-vault info-leak: boulder/default are not disclosed.
347
+ expect(filterVaultListForBinding(names, "work")).not.toContain("boulder");
348
+ expect(filterVaultListForBinding(names, "work")).not.toContain("default");
349
+ });
350
+
351
+ test("filterVaultListForBinding: binding to a vault absent from the list yields empty", () => {
352
+ expect(filterVaultListForBinding(["work", "default"], "ghost")).toEqual([]);
353
+ });
354
+
355
+ test("operator token (VAULT_AUTH_TOKEN) gets the UNFILTERED full listing", async () => {
356
+ createVault("work");
357
+ createVault("boulder");
358
+ createVault("default");
359
+ const prev = process.env.VAULT_AUTH_TOKEN;
360
+ process.env.VAULT_AUTH_TOKEN = "op-secret-token-259";
361
+ try {
362
+ const req = new Request("http://localhost:1940/vaults", {
363
+ headers: { authorization: "Bearer op-secret-token-259" },
364
+ });
365
+ const res = await route(req, "/vaults");
366
+ expect(res.status).toBe(200);
367
+ const body = (await res.json()) as { vaults: { name: string }[] };
368
+ const names = body.vaults.map((v) => v.name);
369
+ expect(new Set(names)).toEqual(new Set(["work", "boulder", "default"]));
370
+ } finally {
371
+ if (prev === undefined) delete process.env.VAULT_AUTH_TOKEN;
372
+ else process.env.VAULT_AUTH_TOKEN = prev;
373
+ }
374
+ });
375
+ });
376
+
314
377
  // ---------------------------------------------------------------------------
315
378
  // /vault/<name>/admin/* — admin SPA static-file mount. Detailed tests live
316
379
  // in admin-spa.test.ts (with a tmp dist dir); these pin the dispatch — i.e.
@@ -368,6 +431,70 @@ describe("/vault/<name>/admin/* SPA mount", () => {
368
431
  expect(res.status).toBe(401);
369
432
  });
370
433
 
434
+ // ---------------------------------------------------------------------
435
+ // /vault/admin[/*] — DAEMON-LEVEL multi-vault SPA mount (B3, 2026-06-09
436
+ // hub-module-boundary migration). `admin` is a reserved vault name (B2),
437
+ // and this branch dispatches BEFORE both the per-vault SPA mount and the
438
+ // per-vault dispatcher. Detailed serving behavior (the bare-mount 301,
439
+ // asset strip) is pinned in admin-spa.test.ts with a tmp dist dir.
440
+ // ---------------------------------------------------------------------
441
+
442
+ test("/vault/admin/ fires the SPA layer, never the per-vault 'Vault not found' JSON", async () => {
443
+ // No vault named "admin" exists (it can't — reserved). Without the
444
+ // daemon-level branch this path would fall to the per-vault dispatcher
445
+ // and 404 as JSON.
446
+ const req = new Request("http://localhost:1940/vault/admin/");
447
+ const res = await route(req, "/vault/admin/");
448
+ expect(res.status === 200 || res.status === 503).toBe(true);
449
+ expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
450
+ });
451
+
452
+ test("/vault/admin/admin does NOT boot per-vault mode for a vault named 'admin'", async () => {
453
+ // The per-vault regex would capture name="admin" here. The daemon-level
454
+ // branch must win — the regexes are deliberately not merged.
455
+ const req = new Request("http://localhost:1940/vault/admin/admin");
456
+ const res = await route(req, "/vault/admin/admin");
457
+ expect(res.status === 200 || res.status === 503).toBe(true);
458
+ expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
459
+ });
460
+
461
+ test("a squatted vault named 'admin' is shadowed — its data plane serves the SPA layer", async () => {
462
+ // A vault created before the reservation landed. The daemon mount wins
463
+ // over its entire /vault/admin/* surface (server boot warns with the
464
+ // recovery procedure — see vault-name.ts:reservedNameSquatWarnings).
465
+ createVault("admin");
466
+ const req = new Request("http://localhost:1940/vault/admin/api/notes");
467
+ const res = await route(req, "/vault/admin/api/notes");
468
+ // The per-vault API would 401 (auth wall); the SPA layer serves the
469
+ // static shell (200) or the unbuilt-dist 503 — never the API's JSON.
470
+ expect(res.status === 200 || res.status === 503).toBe(true);
471
+ expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
472
+ });
473
+
474
+ test("POST /vault/admin/ returns 405 (daemon mount is GET-only)", async () => {
475
+ const req = new Request("http://localhost:1940/vault/admin/", { method: "POST" });
476
+ const res = await route(req, "/vault/admin/");
477
+ expect(res.status).toBe(405);
478
+ });
479
+
480
+ test("/vault/adminx/* does NOT match the daemon mount — routes per-vault", async () => {
481
+ // Exact-segment match only: a real vault whose name merely starts with
482
+ // "admin" keeps its normal per-vault surface (the API auth wall 401s,
483
+ // proving the per-vault dispatcher handled it).
484
+ createVault("adminx");
485
+ const req = new Request("http://localhost:1940/vault/adminx/api/notes");
486
+ const res = await route(req, "/vault/adminx/api/notes");
487
+ expect(res.status).toBe(401);
488
+ });
489
+
490
+ test("per-vault /vault/<real>/admin/ is unaffected by the daemon mount", async () => {
491
+ createVault("work");
492
+ const req = new Request("http://localhost:1940/vault/work/admin/");
493
+ const res = await route(req, "/vault/work/admin/");
494
+ expect(res.status === 200 || res.status === 503).toBe(true);
495
+ expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
496
+ });
497
+
371
498
  test("origin-rooted /admin (legacy mount retired) returns 404", async () => {
372
499
  // Pre-vault#252 the SPA was at /admin/*. Routing now lets that fall
373
500
  // through to the catch-all — hub's directory page should link to
@@ -1633,6 +1760,147 @@ describe("scope enforcement on /api/*", () => {
1633
1760
  expect(res.status).toBe(201);
1634
1761
  });
1635
1762
 
1763
+ // ----- vault#439: near[] BFS is a WALL, not a sieve --------------------
1764
+ // For a tag-scoped token the graph traversal must refuse to walk THROUGH
1765
+ // an out-of-scope note. So an in-scope note reachable ONLY via an
1766
+ // out-of-scope intermediary is unreachable — symmetric with find-path.
1767
+ // Topology: A(#work) --link--> P(#personal) --link--> B(#work).
1768
+ // A token scoped to ["work"] anchored at A, depth 2:
1769
+ // - sieve (old): B survives (reached via P, then output-filtered to keep B)
1770
+ // - wall (new): P is the wall; B is never reached.
1771
+
1772
+ test("vault#439: tag-scoped near[] cannot reach an in-scope note through an out-of-scope hop", async () => {
1773
+ createVault("journal");
1774
+ const store = getVaultStore("journal");
1775
+ const a = await store.createNote("anchor-work", { tags: ["work"] });
1776
+ const p = await store.createNote("bridge-personal", { tags: ["personal"] });
1777
+ const b = await store.createNote("far-work", { tags: ["work"] });
1778
+ await store.createLink(a.id, p.id, "relates");
1779
+ await store.createLink(p.id, b.id, "relates");
1780
+ const token = await mintTagScopedToken("journal", ["vault:read"], ["work"]);
1781
+
1782
+ // `route`'s second arg is the pathname only; the query rides on req.url.
1783
+ const pathname = "/vault/journal/api/notes";
1784
+ const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
1785
+ const res = await route(authed(token, "GET", full), pathname);
1786
+ expect(res.status).toBe(200);
1787
+ const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
1788
+ const list = Array.isArray(body) ? body : (body.notes ?? []);
1789
+ const ids = list.map((n) => n.id);
1790
+ // B is in-scope (#work) but only reachable via the out-of-scope #personal
1791
+ // bridge — the wall makes it unreachable.
1792
+ expect(ids).not.toContain(b.id);
1793
+ // P itself never leaks (it's out of scope).
1794
+ expect(ids).not.toContain(p.id);
1795
+ });
1796
+
1797
+ test("vault#439: tag-scoped near[] still reaches in-scope notes via in-scope hops", async () => {
1798
+ createVault("journal");
1799
+ const store = getVaultStore("journal");
1800
+ const a = await store.createNote("anchor-work", { tags: ["work"] });
1801
+ const mid = await store.createNote("mid-work", { tags: ["work"] });
1802
+ const far = await store.createNote("far-work", { tags: ["work"] });
1803
+ await store.createLink(a.id, mid.id, "relates");
1804
+ await store.createLink(mid.id, far.id, "relates");
1805
+ const token = await mintTagScopedToken("journal", ["vault:read"], ["work"]);
1806
+
1807
+ const pathname = "/vault/journal/api/notes";
1808
+ const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
1809
+ const res = await route(authed(token, "GET", full), pathname);
1810
+ expect(res.status).toBe(200);
1811
+ const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
1812
+ const list = Array.isArray(body) ? body : (body.notes ?? []);
1813
+ const ids = list.map((n) => n.id);
1814
+ // All-in-scope path: both mid (depth 1) and far (depth 2) are reachable.
1815
+ expect(ids).toContain(mid.id);
1816
+ expect(ids).toContain(far.id);
1817
+ });
1818
+
1819
+ test("vault#439: UNSCOPED token near[] still walks the full graph (behavior unchanged)", async () => {
1820
+ createVault("journal");
1821
+ const store = getVaultStore("journal");
1822
+ const a = await store.createNote("anchor-work", { tags: ["work"] });
1823
+ const p = await store.createNote("bridge-personal", { tags: ["personal"] });
1824
+ const b = await store.createNote("far-work", { tags: ["work"] });
1825
+ await store.createLink(a.id, p.id, "relates");
1826
+ await store.createLink(p.id, b.id, "relates");
1827
+ // No scopedTags → unscoped admin token; no wall installed.
1828
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:admin"] });
1829
+
1830
+ const pathname = "/vault/journal/api/notes";
1831
+ const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
1832
+ const res = await route(authed(token, "GET", full), pathname);
1833
+ expect(res.status).toBe(200);
1834
+ const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
1835
+ const list = Array.isArray(body) ? body : (body.notes ?? []);
1836
+ const ids = list.map((n) => n.id);
1837
+ // Unscoped: the full neighborhood is visible, including the #personal
1838
+ // bridge and the note beyond it.
1839
+ expect(ids).toContain(p.id);
1840
+ expect(ids).toContain(b.id);
1841
+ });
1842
+
1843
+ // ----- vault#404: REST-path hub-JWT tag-scoping enforcement (C0) --------
1844
+ // The MCP path's tag-scope enforcement is covered end-to-end; pin that a
1845
+ // tag-scoped HUB JWT (allowlist carried in the `permissions.scoped_tags`
1846
+ // claim, NOT a vestigial DB row) hitting the REST surface enforces the
1847
+ // same allowlist on both read and write. `mintTagScopedToken` mints a real
1848
+ // hub JWT, so these tests exercise the hub-JWT-sourced `scoped_tags` path.
1849
+
1850
+ test("vault#404: hub-JWT tag-scoped READ via REST enforces the allowlist (list + single)", async () => {
1851
+ createVault("journal");
1852
+ const store = getVaultStore("journal");
1853
+ const inScope = await store.createNote("h", { tags: ["health"] });
1854
+ const outOfScope = await store.createNote("w", { tags: ["work"] });
1855
+ const token = await mintTagScopedToken("journal", ["vault:read"], ["health"]);
1856
+
1857
+ // List: only in-scope notes.
1858
+ const listPath = "/vault/journal/api/notes";
1859
+ const listRes = await route(authed(token, "GET", listPath), listPath);
1860
+ expect(listRes.status).toBe(200);
1861
+ const listBody = (await listRes.json()) as { notes?: { id: string }[] } | { id: string }[];
1862
+ const list = Array.isArray(listBody) ? listBody : (listBody.notes ?? []);
1863
+ const ids = list.map((n) => n.id);
1864
+ expect(ids).toContain(inScope.id);
1865
+ expect(ids).not.toContain(outOfScope.id);
1866
+
1867
+ // Single in-scope → 200; single out-of-scope → 404 (no existence leak).
1868
+ const okPath = `/vault/journal/api/notes/${inScope.id}`;
1869
+ expect((await route(authed(token, "GET", okPath), okPath)).status).toBe(200);
1870
+ const denyPath = `/vault/journal/api/notes/${outOfScope.id}`;
1871
+ expect((await route(authed(token, "GET", denyPath), denyPath)).status).toBe(404);
1872
+ });
1873
+
1874
+ test("vault#404: hub-JWT tag-scoped WRITE via REST enforces the allowlist", async () => {
1875
+ createVault("journal");
1876
+ const token = await mintTagScopedToken("journal", ["vault:read", "vault:write"], ["health"]);
1877
+
1878
+ // In-scope write → 201.
1879
+ const path = "/vault/journal/api/notes";
1880
+ const okRes = await route(
1881
+ new Request(`http://localhost:1940${path}`, {
1882
+ method: "POST",
1883
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
1884
+ body: JSON.stringify({ content: "ok", tags: ["health"] }),
1885
+ }),
1886
+ path,
1887
+ );
1888
+ expect(okRes.status).toBe(201);
1889
+
1890
+ // Out-of-scope write → 403 tag_scope_violation.
1891
+ const denyRes = await route(
1892
+ new Request(`http://localhost:1940${path}`, {
1893
+ method: "POST",
1894
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
1895
+ body: JSON.stringify({ content: "denied", tags: ["work"] }),
1896
+ }),
1897
+ path,
1898
+ );
1899
+ expect(denyRes.status).toBe(403);
1900
+ const denyBody = (await denyRes.json()) as { error_type?: string };
1901
+ expect(denyBody.error_type).toBe("tag_scope_violation");
1902
+ });
1903
+
1636
1904
  // ----- Q5: tag-delete dependency check ---------------------------------
1637
1905
  // Deleting a tag referenced by any token's scoped_tags would silently
1638
1906
  // orphan the token's allowlist; fail closed with 409 + referenced_by.
@@ -1980,3 +2248,181 @@ describe("/vault/<name>/.parachute/mirror/run-now — auth + dispatch", () => {
1980
2248
  expect([405, 503]).toContain(res.status);
1981
2249
  });
1982
2250
  });
2251
+
2252
+ // ---------------------------------------------------------------------------
2253
+ // /vault/<name>/.parachute/usage — per-vault data-footprint endpoint.
2254
+ //
2255
+ // READ-scoped (a vault's own user must see their own vault's size; the
2256
+ // operator inherits read via broad/admin). Reports counts + byte sizes; the
2257
+ // two dir-walks (assets, mirror) are TTL-cached, `?fresh=1` bypasses, and an
2258
+ // attachment upload invalidates the cache. These tests exercise the auth
2259
+ // gate, the response shape, the cache hit/bypass, and upload-invalidation
2260
+ // end-to-end through `route()` against the real (tmp) filesystem.
2261
+ // ---------------------------------------------------------------------------
2262
+
2263
+ describe("/vault/<name>/.parachute/usage — data-footprint endpoint", () => {
2264
+ const USAGE_PATH = "/vault/journal/.parachute/usage";
2265
+
2266
+ function authedGet(token: string, path = USAGE_PATH): Request {
2267
+ return new Request(`http://localhost:1940${path}`, {
2268
+ headers: { authorization: `Bearer ${token}` },
2269
+ });
2270
+ }
2271
+
2272
+ /** Upload an attachment through the real storage route (write-scoped). */
2273
+ async function uploadAttachment(token: string, bytes: number): Promise<Response> {
2274
+ const form = new FormData();
2275
+ const file = new File([new Uint8Array(bytes).fill(7)], "photo.png", { type: "image/png" });
2276
+ form.set("file", file);
2277
+ const p = "/vault/journal/api/storage/upload";
2278
+ return route(
2279
+ new Request(`http://localhost:1940${p}`, {
2280
+ method: "POST",
2281
+ headers: { authorization: `Bearer ${token}` },
2282
+ body: form,
2283
+ }),
2284
+ p,
2285
+ );
2286
+ }
2287
+
2288
+ test("unauthenticated → 401", async () => {
2289
+ createVault("journal");
2290
+ const res = await route(new Request(`http://localhost:1940${USAGE_PATH}`), USAGE_PATH);
2291
+ expect(res.status).toBe(401);
2292
+ });
2293
+
2294
+ test("read-scoped token → 200 with the full shape (owner sees own vault)", async () => {
2295
+ createVault("journal");
2296
+ // Seed two notes so counts + contentBytes are non-trivial.
2297
+ const store = getVaultStore("journal");
2298
+ await store.createNote("hello");
2299
+ await store.createNote("world!");
2300
+
2301
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2302
+ const res = await route(authedGet(token), USAGE_PATH);
2303
+ expect(res.status).toBe(200);
2304
+
2305
+ const body = (await res.json()) as {
2306
+ counts: { notes: number; attachments: number; links: number; tags: number };
2307
+ bytes: { content: number; db: number; assets: number; total: number; mirror?: number };
2308
+ computedAt: string;
2309
+ cached: boolean;
2310
+ };
2311
+
2312
+ // counts match getVaultStats
2313
+ const stats = await store.getVaultStats();
2314
+ expect(body.counts.notes).toBe(stats.totalNotes);
2315
+ expect(body.counts.notes).toBe(2);
2316
+ expect(body.counts.attachments).toBe(stats.attachmentCount);
2317
+ expect(body.counts.links).toBe(stats.linkCount);
2318
+ expect(body.counts.tags).toBe(stats.tagCount);
2319
+
2320
+ // bytes shape
2321
+ expect(body.bytes.content).toBe(stats.contentBytes);
2322
+ expect(body.bytes.content).toBe(11); // "hello"(5) + "world!"(6)
2323
+ expect(body.bytes.db).toBeGreaterThan(0); // a real SQLite file exists
2324
+ expect(body.bytes.assets).toBe(0); // no uploads yet
2325
+ // total = db + assets (NOT content, NOT mirror)
2326
+ expect(body.bytes.total).toBe(body.bytes.db + body.bytes.assets);
2327
+ // no mirror configured → omitted
2328
+ expect(body.bytes).not.toHaveProperty("mirror");
2329
+
2330
+ expect(typeof body.computedAt).toBe("string");
2331
+ expect(typeof body.cached).toBe("boolean");
2332
+ });
2333
+
2334
+ test("insufficient scope is impossible at read — but no vault: scope at all → 403", async () => {
2335
+ createVault("journal");
2336
+ // A token carrying only a non-vault scope satisfies neither read nor any
2337
+ // vault verb → 403 insufficient_scope.
2338
+ const token = await mintJwt({ vaultName: "journal", scopes: ["openid"] });
2339
+ const res = await route(authedGet(token), USAGE_PATH);
2340
+ expect(res.status).toBe(403);
2341
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
2342
+ expect(body.error_type).toBe("insufficient_scope");
2343
+ expect(body.required_scope).toBe("vault:read");
2344
+ });
2345
+
2346
+ test("wrong-vault scope is denied (narrowed scope names a different vault)", async () => {
2347
+ createVault("journal");
2348
+ createVault("other");
2349
+ // Token scoped to `other`, used against `journal` → denied.
2350
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:other:read"] });
2351
+ const res = await route(authedGet(token), USAGE_PATH);
2352
+ expect(res.status).toBe(403);
2353
+ const body = (await res.json()) as { error_type?: string };
2354
+ expect(body.error_type).toBe("insufficient_scope");
2355
+ });
2356
+
2357
+ test("write/admin scope inherits read → 200", async () => {
2358
+ createVault("journal");
2359
+ const token = await createAdminToken("journal");
2360
+ const res = await route(authedGet(token), USAGE_PATH);
2361
+ expect(res.status).toBe(200);
2362
+ });
2363
+
2364
+ test("non-GET method → 405", async () => {
2365
+ createVault("journal");
2366
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2367
+ const res = await route(
2368
+ new Request(`http://localhost:1940${USAGE_PATH}`, {
2369
+ method: "POST",
2370
+ headers: { authorization: `Bearer ${token}` },
2371
+ }),
2372
+ USAGE_PATH,
2373
+ );
2374
+ expect(res.status).toBe(405);
2375
+ });
2376
+
2377
+ test("second read within TTL is served from cache (cached:true)", async () => {
2378
+ createVault("journal");
2379
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2380
+
2381
+ const first = (await (await route(authedGet(token), USAGE_PATH)).json()) as { cached: boolean };
2382
+ expect(first.cached).toBe(false);
2383
+
2384
+ const second = (await (await route(authedGet(token), USAGE_PATH)).json()) as { cached: boolean };
2385
+ expect(second.cached).toBe(true);
2386
+ });
2387
+
2388
+ test("?fresh=1 bypasses the cache (cached:false even on a warm cache)", async () => {
2389
+ createVault("journal");
2390
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2391
+
2392
+ // Prime the cache.
2393
+ await route(authedGet(token), USAGE_PATH);
2394
+
2395
+ // ?fresh=1 must recompute regardless. The server passes `url.pathname`
2396
+ // (query stripped) as the `path` arg while the Request URL retains the
2397
+ // query — the route reads `fresh` from `req.url`, not `path`. Mirror that.
2398
+ const reqWithQuery = new Request(`http://localhost:1940${USAGE_PATH}?fresh=1`, {
2399
+ headers: { authorization: `Bearer ${token}` },
2400
+ });
2401
+ const res = await route(reqWithQuery, USAGE_PATH);
2402
+ const body = (await res.json()) as { cached: boolean };
2403
+ expect(body.cached).toBe(false);
2404
+ });
2405
+
2406
+ test("attachment upload invalidates the cache → assets bytes update", async () => {
2407
+ createVault("journal");
2408
+ const writeToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:write"] });
2409
+
2410
+ // Prime: assets empty.
2411
+ const before = (await (await route(authedGet(writeToken), USAGE_PATH)).json()) as {
2412
+ bytes: { assets: number };
2413
+ };
2414
+ expect(before.bytes.assets).toBe(0);
2415
+
2416
+ // Upload a 4096-byte attachment (write-scoped) — this invalidates usage.
2417
+ const up = await uploadAttachment(writeToken, 4096);
2418
+ expect(up.status).toBe(201);
2419
+
2420
+ // Next read must re-walk (cache was invalidated) and reflect the new file.
2421
+ const after = (await (await route(authedGet(writeToken), USAGE_PATH)).json()) as {
2422
+ bytes: { assets: number };
2423
+ cached: boolean;
2424
+ };
2425
+ expect(after.cached).toBe(false); // invalidated → recomputed
2426
+ expect(after.bytes.assets).toBeGreaterThanOrEqual(4096);
2427
+ });
2428
+ });