@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.
- package/.parachute/module.json +14 -3
- package/README.md +7 -7
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +97 -2
- package/core/src/mcp.ts +201 -33
- package/core/src/notes.ts +44 -8
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/schema.ts +58 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +463 -1
- package/src/mirror-routes.ts +474 -4
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +696 -121
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +113 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-CGL256oe.js +60 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
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,
|
|
@@ -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.
|
|
104
|
-
* survives + `/auth/status` probes it for leftover pre-0.
|
|
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.
|
|
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)
|
|
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
|
+
});
|