@openparachute/vault 0.2.4 → 0.3.0-rc.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 (98) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/indexed-fields.test.ts +285 -0
  7. package/core/src/indexed-fields.ts +238 -0
  8. package/core/src/mcp.ts +127 -6
  9. package/core/src/notes.ts +153 -11
  10. package/core/src/query-operators.ts +174 -0
  11. package/core/src/schema.ts +69 -2
  12. package/core/src/store.ts +92 -0
  13. package/core/src/tag-schemas.ts +5 -0
  14. package/core/src/types.ts +28 -1
  15. package/docs/HTTP_API.md +105 -1
  16. package/package/package.json +32 -0
  17. package/package.json +2 -2
  18. package/src/auth.test.ts +83 -114
  19. package/src/auth.ts +68 -6
  20. package/src/backup-launchd.ts +1 -1
  21. package/src/backup.test.ts +1 -1
  22. package/src/backup.ts +18 -17
  23. package/src/cli.ts +179 -121
  24. package/src/config-triggers.test.ts +49 -0
  25. package/src/config.test.ts +317 -2
  26. package/src/config.ts +420 -40
  27. package/src/context.test.ts +136 -0
  28. package/src/context.ts +115 -0
  29. package/src/daemon.ts +17 -16
  30. package/src/doctor.test.ts +9 -7
  31. package/src/launchd.test.ts +1 -1
  32. package/src/launchd.ts +6 -6
  33. package/src/mcp-http.ts +75 -21
  34. package/src/mcp-install.test.ts +125 -0
  35. package/src/mcp-install.ts +60 -0
  36. package/src/mcp-tools.ts +34 -96
  37. package/src/module-config.ts +109 -0
  38. package/src/oauth.test.ts +345 -57
  39. package/src/oauth.ts +155 -35
  40. package/src/published.test.ts +2 -2
  41. package/src/routes.ts +209 -33
  42. package/src/routing.test.ts +817 -300
  43. package/src/routing.ts +204 -202
  44. package/src/scopes.test.ts +136 -0
  45. package/src/scopes.ts +105 -0
  46. package/src/scribe-env.test.ts +49 -0
  47. package/src/scribe-env.ts +33 -0
  48. package/src/server.ts +57 -5
  49. package/src/services-manifest.test.ts +140 -0
  50. package/src/services-manifest.ts +99 -0
  51. package/src/systemd.ts +3 -3
  52. package/src/token-store.ts +42 -9
  53. package/src/transcription-worker.test.ts +583 -0
  54. package/src/transcription-worker.ts +346 -0
  55. package/src/triggers.test.ts +191 -1
  56. package/src/triggers.ts +17 -2
  57. package/src/vault.test.ts +693 -77
  58. package/src/version.test.ts +1 -1
  59. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  60. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  61. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  62. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  63. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  64. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  65. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  66. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  67. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  68. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  69. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  70. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  71. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  72. package/religions-abrahamic-filter.png +0 -0
  73. package/religions-buddhism-v2.png +0 -0
  74. package/religions-buddhism.png +0 -0
  75. package/religions-final.png +0 -0
  76. package/religions-v1.png +0 -0
  77. package/religions-v2.png +0 -0
  78. package/religions-zen.png +0 -0
  79. package/web/README.md +0 -73
  80. package/web/bun.lock +0 -827
  81. package/web/eslint.config.js +0 -23
  82. package/web/index.html +0 -15
  83. package/web/package.json +0 -36
  84. package/web/public/favicon.svg +0 -1
  85. package/web/public/icons.svg +0 -24
  86. package/web/src/App.tsx +0 -149
  87. package/web/src/Graph.tsx +0 -200
  88. package/web/src/NoteView.tsx +0 -155
  89. package/web/src/Sidebar.tsx +0 -186
  90. package/web/src/api.ts +0 -21
  91. package/web/src/index.css +0 -50
  92. package/web/src/main.tsx +0 -10
  93. package/web/src/types.ts +0 -37
  94. package/web/src/utils.ts +0 -107
  95. package/web/tsconfig.app.json +0 -25
  96. package/web/tsconfig.json +0 -7
  97. package/web/tsconfig.node.json +0 -24
  98. package/web/vite.config.ts +0 -16
@@ -1,19 +1,17 @@
1
1
  /**
2
2
  * Tests for the HTTP routing layer (src/routing.ts).
3
3
  *
4
- * Two surfaces under test here:
4
+ * The server exposes four root-level endpoints and everything else under
5
+ * `/vault/<name>/...`. These tests pin the dispatcher's behaviour:
5
6
  *
6
- * 1. `/vaults/list` — public, unauthenticated discovery endpoint.
7
- * Intended for the Daily vault-picker dropdown pre-OAuth.
8
- * Must never leak anything beyond vault names; must return 404 when
9
- * the operator disables discovery in config.yaml.
7
+ * 1. `/vaults/list` — public, unauthenticated discovery. Returns vault
8
+ * names only, 404 when operator disables discovery.
9
+ * 2. `/vaults` authenticated metadata listing.
10
+ * 3. `/vault/<name>/...` per-vault routing (OAuth, MCP, view, API).
11
+ * 4. The RFC 9728 WWW-Authenticate challenge that decorates MCP 401s.
10
12
  *
11
- * 2. Single-vault auto-default. A user with exactly one vault (named
12
- * anything not necessarily "default") should be able to hit unscoped
13
- * routes (/mcp, /api/*, /oauth/*) and have them transparently target
14
- * their sole vault. Previously `/mcp` tried to look up a vault literally
15
- * named "default" and failed. The coherence invariant from PR #111 says
16
- * `/mcp` and `/vaults/:name/mcp` must behave identically for that vault.
13
+ * No unscoped `/mcp`, `/api/*`, `/oauth/*` routes exist — every per-vault
14
+ * resource must name the vault it targets.
17
15
  *
18
16
  * Uses PARACHUTE_HOME override so each test's vaults live in a tmp dir and
19
17
  * never touch ~/.parachute.
@@ -42,7 +40,8 @@ const {
42
40
  // clearVaultStoreCache was added in #111 for exactly this kind of test
43
41
  // that wipes its PARACHUTE_HOME between runs — it closes stores silently
44
42
  // even when the DB files are already gone.
45
- const { clearVaultStoreCache } = await import("./vault-store.ts");
43
+ const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
44
+ const { generateToken, createToken } = await import("./token-store.ts");
46
45
 
47
46
  function createVault(name: string, description?: string): void {
48
47
  writeVaultConfig({
@@ -53,11 +52,26 @@ function createVault(name: string, description?: string): void {
53
52
  });
54
53
  }
55
54
 
55
+ /**
56
+ * Mint an admin-scoped token for `vaultName` and return its bearer value.
57
+ * Used by tests that hit admin-gated endpoints (e.g. /.parachute/config).
58
+ */
59
+ function createAdminToken(vaultName: string): string {
60
+ const store = getVaultStore(vaultName);
61
+ const { fullToken } = generateToken();
62
+ createToken(store.db, fullToken, {
63
+ label: "test-admin",
64
+ permission: "full",
65
+ scopes: ["vault:read", "vault:write", "vault:admin"],
66
+ });
67
+ return fullToken;
68
+ }
69
+
56
70
  function reset(): void {
57
71
  clearVaultStoreCache();
58
72
  if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
59
73
  mkdirSync(testDir, { recursive: true });
60
- mkdirSync(join(testDir, "vaults"), { recursive: true });
74
+ mkdirSync(join(testDir, "vault", "data"), { recursive: true });
61
75
  writeGlobalConfig({ port: 1940 });
62
76
  }
63
77
 
@@ -71,7 +85,8 @@ afterAll(() => {
71
85
  });
72
86
 
73
87
  // ---------------------------------------------------------------------------
74
- // resolveDefaultVault — the function that powers every "unscoped" route.
88
+ // resolveDefaultVault — used by the CLI to pick the vault to wire into
89
+ // `~/.claude.json`, not on the request path (which is always scoped).
75
90
  // ---------------------------------------------------------------------------
76
91
 
77
92
  describe("resolveDefaultVault", () => {
@@ -84,13 +99,10 @@ describe("resolveDefaultVault", () => {
84
99
 
85
100
  test("returns the sole vault when default_vault is unset", () => {
86
101
  createVault("journal");
87
- // No default_vault in global config.
88
102
  expect(resolveDefaultVault()).toBe("journal");
89
103
  });
90
104
 
91
105
  test("returns the sole vault even if default_vault points to a deleted one", () => {
92
- // Simulates: user had two vaults, removed the one that was the default,
93
- // but config.yaml still references the old name.
94
106
  createVault("journal");
95
107
  writeGlobalConfig({ port: 1940, default_vault: "deleted-vault" });
96
108
  expect(resolveDefaultVault()).toBe("journal");
@@ -107,19 +119,16 @@ describe("resolveDefaultVault", () => {
107
119
  expect(resolveDefaultVault()).toBeNull();
108
120
  });
109
121
 
110
- test("does not special-case the name 'default' — a vault named 'journal' alone is the default", () => {
111
- // This is the Aaron-acked bug: having to go to /vaults/journal/mcp
112
- // when it's the only vault was confusing. Now the name doesn't matter.
122
+ test("does not special-case the name 'default'", () => {
113
123
  createVault("journal");
114
124
  expect(resolveDefaultVault()).toBe("journal");
115
125
  expect(listVaults()).toEqual(["journal"]);
116
- // And explicitly: "default" is NOT synthesized when it doesn't exist.
117
126
  expect(resolveDefaultVault()).not.toBe("default");
118
127
  });
119
128
  });
120
129
 
121
130
  // ---------------------------------------------------------------------------
122
- // /vaults/list — Task 1: public discovery endpoint for the Daily picker.
131
+ // /vaults/list — public discovery endpoint for the Daily picker.
123
132
  // ---------------------------------------------------------------------------
124
133
 
125
134
  describe("GET /vaults/list (public discovery)", () => {
@@ -183,8 +192,6 @@ describe("GET /vaults/list (public discovery)", () => {
183
192
  });
184
193
 
185
194
  test("ignores Authorization header (endpoint is public)", async () => {
186
- // Even with an obviously-wrong token, the endpoint must still succeed.
187
- // This catches a regression where we'd accidentally gate it behind auth.
188
195
  createVault("journal");
189
196
  const req = new Request("http://localhost:1940/vaults/list", {
190
197
  headers: { Authorization: "Bearer not-a-real-token" },
@@ -193,25 +200,16 @@ describe("GET /vaults/list (public discovery)", () => {
193
200
  expect(res.status).toBe(200);
194
201
  });
195
202
 
196
- test("rejects non-GET methods (falls through to vault-scoped 404 path)", async () => {
197
- // Non-GET methods on /vaults/list fall through to the /vaults/:name
198
- // matcher with name="list". Since no vault named "list" can exist
199
- // (reserved name), we get a 404. This is the expected behavior —
200
- // the endpoint is GET-only.
203
+ test("rejects non-GET methods (falls through to 404)", async () => {
201
204
  createVault("journal");
202
205
  const req = new Request("http://localhost:1940/vaults/list", { method: "POST" });
203
206
  const res = await route(req, "/vaults/list");
204
207
  expect(res.status).toBe(404);
205
208
  });
206
209
 
207
- test("discovery disabled still allows authenticated /vaults listing (they are separate concerns)", async () => {
208
- // /vaults (authenticated, with metadata) is orthogonal to /vaults/list
209
- // (public, names only). Disabling discovery hides the public endpoint
210
- // but not the authenticated one.
210
+ test("discovery disabled still allows authenticated /vaults listing (separate concerns)", async () => {
211
211
  createVault("journal");
212
212
  writeGlobalConfig({ port: 1940, discovery: "disabled" });
213
- // We can at least assert that /vaults still returns 401 (auth required)
214
- // rather than 404 (disabled). Past that, auth is covered elsewhere.
215
213
  const req = new Request("http://localhost:1940/vaults");
216
214
  const res = await route(req, "/vaults");
217
215
  expect(res.status).toBe(401);
@@ -219,131 +217,69 @@ describe("GET /vaults/list (public discovery)", () => {
219
217
  });
220
218
 
221
219
  // ---------------------------------------------------------------------------
222
- // Single-vault auto-default Task 2: /mcp, /api/*, /oauth/* target the
223
- // only vault regardless of its name.
220
+ // Per-vault routing: /vault/<name>/... is the only URL shape for vault
221
+ // resources. Unscoped routes (/mcp, /api/*, /oauth/*) no longer exist.
224
222
  // ---------------------------------------------------------------------------
225
223
 
226
- describe("single-vault auto-default", () => {
227
- test("one vault named 'journal' /mcp requires auth (would error if vault not resolvable)", async () => {
228
- // With one vault named "journal" and no default_vault set, /mcp must
229
- // still hit the MCP handler. The handler itself requires a valid
230
- // Bearer token we assert 401 (auth failure), proving the request
231
- // reached the MCP layer and did NOT bail out with "Default vault not
232
- // found". Before this fix, /mcp would error because the code
233
- // hardcoded the fallback name "default".
234
- createVault("journal");
235
- // Deliberately no default_vault — single-vault fallback should kick in.
236
- const req = new Request("http://localhost:1940/mcp");
237
- const res = await route(req, "/mcp");
224
+ describe("per-vault routing under /vault/<name>/", () => {
225
+ test("/vault/<name>/mcp reaches the MCP handler (401 unauthenticated)", async () => {
226
+ createVault("journal");
227
+ const path = "/vault/journal/mcp";
228
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
238
229
  expect(res.status).toBe(401);
239
230
  });
240
231
 
241
- test("one vault named 'journal' → /api/notes reaches auth (not 404'd)", async () => {
232
+ test("/vault/<name>/api/notes reaches per-vault auth (401 unauthenticated)", async () => {
242
233
  createVault("journal");
243
- const req = new Request("http://localhost:1940/api/notes");
244
- const res = await route(req, "/api/notes");
245
- // Unauthenticated: 401 from per-vault auth. Before the fix, it would
246
- // have 404'd with "Default vault not found".
234
+ const path = "/vault/journal/api/notes";
235
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
247
236
  expect(res.status).toBe(401);
248
237
  });
249
238
 
250
- test("one vault named 'journal' → /oauth/register reaches the OAuth handler (not 500'd)", async () => {
239
+ test("/vault/<name>/oauth/register reaches the OAuth handler", async () => {
251
240
  createVault("journal");
252
- const req = new Request("http://localhost:1940/oauth/register", {
253
- method: "POST",
254
- headers: { "Content-Type": "application/json" },
255
- body: JSON.stringify({ client_name: "test", redirect_uris: ["https://x.example/cb"] }),
256
- });
257
- const res = await route(req, "/oauth/register");
258
- // Successful registration (201) or 400 from a validation issue —
259
- // either way, we're PAST the "Default vault not configured" path.
241
+ const path = "/vault/journal/oauth/register";
242
+ const res = await route(
243
+ new Request(`http://localhost:1940${path}`, {
244
+ method: "POST",
245
+ headers: { "Content-Type": "application/json" },
246
+ body: JSON.stringify({ client_name: "test", redirect_uris: ["https://x.example/cb"] }),
247
+ }),
248
+ path,
249
+ );
260
250
  expect(res.status).not.toBe(500);
261
251
  expect([201, 400]).toContain(res.status);
262
252
  });
263
253
 
264
- test("one vault named 'journal' /vaults/journal/mcp still works identically (coherence invariant)", async () => {
265
- // This is the invariant from PR #111: the scoped and unscoped MCP paths
266
- // must behave identically for a single-vault deployment. Both should
267
- // land on the MCP auth gate and return 401 unauthenticated.
254
+ test("unknown vault returns 404 before hitting auth", async () => {
268
255
  createVault("journal");
269
- const unscopedReq = new Request("http://localhost:1940/mcp");
270
- const unscopedRes = await route(unscopedReq, "/mcp");
271
- const scopedReq = new Request("http://localhost:1940/vaults/journal/mcp");
272
- const scopedRes = await route(scopedReq, "/vaults/journal/mcp");
273
- expect(unscopedRes.status).toBe(scopedRes.status);
274
- expect(unscopedRes.status).toBe(401);
256
+ for (const path of [
257
+ "/vault/nonexistent/mcp",
258
+ "/vault/nonexistent/api/notes",
259
+ "/vault/nonexistent/oauth/register",
260
+ ]) {
261
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
262
+ expect(res.status).toBe(404);
263
+ const body = (await res.json()) as { error: string };
264
+ expect(body.error).toBe("Vault not found");
265
+ }
275
266
  });
276
267
 
277
- test("multiple vaults, no default_vault → /mcp returns a clear error, not a silent guess", async () => {
278
- createVault("journal");
279
- createVault("work");
280
- // No default_vault set.
281
- const req = new Request("http://localhost:1940/mcp");
282
- const res = await route(req, "/mcp");
283
- // We refuse to guess when multiple vaults exist. Hitting /mcp here
284
- // must NOT silently target one of them. We expect a non-200 status
285
- // that is not the auth gate (since resolveDefaultVault returns null,
286
- // we return 401 because auth runs first, but the underlying MCP
287
- // handler never runs — the previous test guarantees that). For the
288
- // /api/ path, which checks the vault before auth, we get 404.
289
- const apiReq = new Request("http://localhost:1940/api/notes");
290
- const apiRes = await route(apiReq, "/api/notes");
291
- expect(apiRes.status).toBe(404);
292
- });
293
-
294
- test("multiple vaults, default_vault='journal' → /api/notes targets journal", async () => {
268
+ test("no /mcp, /api, /oauth unscoped routes all 404", async () => {
295
269
  createVault("journal");
296
- createVault("work");
297
- writeGlobalConfig({ port: 1940, default_vault: "journal" });
298
- const req = new Request("http://localhost:1940/api/notes");
299
- const res = await route(req, "/api/notes");
300
- // Reaches auth (401) rather than "Default vault not found" (404).
301
- expect(res.status).toBe(401);
270
+ for (const path of ["/mcp", "/api/notes", "/oauth/register", "/oauth/authorize"]) {
271
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
272
+ expect(res.status).toBe(404);
273
+ }
302
274
  });
303
275
 
304
- test("default_vault points to a deleted vault but one other exists → fall back to the survivor", async () => {
305
- createVault("journal");
306
- writeGlobalConfig({ port: 1940, default_vault: "deleted-vault" });
307
- // resolveDefaultVault ignores the stale pointer and returns journal.
308
- expect(resolveDefaultVault()).toBe("journal");
309
- // And /api/notes now routes to it — 401 from per-vault auth, not 404.
310
- const req = new Request("http://localhost:1940/api/notes");
311
- const res = await route(req, "/api/notes");
276
+ test("bare /vault/<name> returns metadata for authenticated callers", async () => {
277
+ createVault("journal", "My journal vault");
278
+ const path = "/vault/journal";
279
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
280
+ // No auth → 401 from per-vault auth gate.
312
281
  expect(res.status).toBe(401);
313
282
  });
314
-
315
- test("default_vault points to a deleted vault and multiple others exist → error (no guessing)", async () => {
316
- createVault("journal");
317
- createVault("work");
318
- writeGlobalConfig({ port: 1940, default_vault: "deleted-vault" });
319
- expect(resolveDefaultVault()).toBeNull();
320
- const req = new Request("http://localhost:1940/api/notes");
321
- const res = await route(req, "/api/notes");
322
- expect(res.status).toBe(404);
323
- });
324
-
325
- test("no vaults exist → /mcp returns auth error (MCP handler short-circuits on empty global config)", async () => {
326
- // Edge case: /mcp runs global auth first. With no vaults and no keys,
327
- // auth fails → 401. This is fine — the handler never sees the empty
328
- // state. We assert we never return 500.
329
- const req = new Request("http://localhost:1940/mcp");
330
- const res = await route(req, "/mcp");
331
- expect(res.status).not.toBe(500);
332
- });
333
-
334
- test("empty config file (no default_vault) with single vault — existing deployments keep working", async () => {
335
- // Migration concern: users with a config.yaml that never had
336
- // default_vault set (pre-#111 deployments) should not break.
337
- // Drop a minimal config.yaml that only specifies port.
338
- writeGlobalConfig({ port: 1940 });
339
- createVault("my-only-vault");
340
- const cfg = readGlobalConfig();
341
- expect(cfg.default_vault).toBeUndefined();
342
- // /api/notes still routes to my-only-vault via single-vault fallback.
343
- const req = new Request("http://localhost:1940/api/notes");
344
- const res = await route(req, "/api/notes");
345
- expect(res.status).toBe(401); // reached per-vault auth
346
- });
347
283
  });
348
284
 
349
285
  // ---------------------------------------------------------------------------
@@ -352,104 +288,60 @@ describe("single-vault auto-default", () => {
352
288
  // Claude Code's MCP SDK (and any other strict RFC 9728 client) requires the
353
289
  // server to emit `WWW-Authenticate: Bearer resource_metadata="..."` on 401
354
290
  // so the client knows which protected-resource metadata document applies to
355
- // the endpoint it just hit. Without it, clients fall back to probing the
356
- // root `/.well-known/oauth-protected-resource`, get `resource: <base>/mcp`,
357
- // and reject any connection to `/vaults/<name>/mcp` as a resource mismatch.
291
+ // the endpoint it just hit.
358
292
  // ---------------------------------------------------------------------------
359
293
 
360
294
  describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
361
- test("unscoped /mcp 401 carries the root protected-resource pointer", async () => {
295
+ test("/vault/<name>/mcp 401 carries the vault-scoped pointer", async () => {
362
296
  createVault("journal");
363
- const req = new Request("http://localhost:1940/mcp");
364
- const res = await route(req, "/mcp");
365
- expect(res.status).toBe(401);
366
- const header = res.headers.get("WWW-Authenticate");
367
- expect(header).toBe(
368
- 'Bearer resource_metadata="http://localhost:1940/.well-known/oauth-protected-resource"',
369
- );
370
- });
371
-
372
- test("scoped /vaults/{name}/mcp 401 carries the vault-scoped pointer", async () => {
373
- createVault("journal");
374
- const req = new Request("http://localhost:1940/vaults/journal/mcp");
375
- const res = await route(req, "/vaults/journal/mcp");
297
+ const path = "/vault/journal/mcp";
298
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
376
299
  expect(res.status).toBe(401);
377
300
  const header = res.headers.get("WWW-Authenticate");
378
301
  expect(header).toBe(
379
- 'Bearer resource_metadata="http://localhost:1940/vaults/journal/.well-known/oauth-protected-resource"',
302
+ 'Bearer resource_metadata="http://localhost:1940/vault/journal/.well-known/oauth-protected-resource"',
380
303
  );
381
304
  });
382
305
 
383
- test("challenge points at the same PRM document the server actually serves", async () => {
306
+ test("challenge points at the PRM document the server actually serves", async () => {
384
307
  // Belt-and-braces: whatever we advertise in the header MUST line up with
385
308
  // what `/.well-known/oauth-protected-resource` actually returns. If these
386
309
  // drift, a conforming client will chase the pointer, fetch the PRM, then
387
- // reject on resource mismatch anyway. Test both directions.
310
+ // reject on resource mismatch anyway.
388
311
  createVault("journal");
389
312
 
390
- // Scoped: header points at /vaults/journal/.well-known/...
391
- const scopedReq = new Request("http://localhost:1940/vaults/journal/mcp");
392
- const scopedRes = await route(scopedReq, "/vaults/journal/mcp");
393
- const scopedHeader = scopedRes.headers.get("WWW-Authenticate")!;
394
- const scopedPrmUrl = scopedHeader.match(/resource_metadata="([^"]+)"/)![1];
395
- // Fetch that PRM. Bypass the full URL by extracting the path.
396
- const prmPath = new URL(scopedPrmUrl).pathname;
313
+ const mcpPath = "/vault/journal/mcp";
314
+ const mcpRes = await route(new Request(`http://localhost:1940${mcpPath}`), mcpPath);
315
+ const header = mcpRes.headers.get("WWW-Authenticate")!;
316
+ const prmUrl = header.match(/resource_metadata="([^"]+)"/)![1];
317
+ const prmPath = new URL(prmUrl).pathname;
397
318
  const prmRes = await route(new Request(`http://localhost:1940${prmPath}`), prmPath);
398
319
  expect(prmRes.status).toBe(200);
399
320
  const prm = (await prmRes.json()) as { resource: string };
400
- expect(prm.resource).toBe("http://localhost:1940/vaults/journal/mcp");
401
-
402
- // Unscoped: header points at root /.well-known/...
403
- const unscopedReq = new Request("http://localhost:1940/mcp");
404
- const unscopedRes = await route(unscopedReq, "/mcp");
405
- const unscopedHeader = unscopedRes.headers.get("WWW-Authenticate")!;
406
- const unscopedPrmUrl = unscopedHeader.match(/resource_metadata="([^"]+)"/)![1];
407
- const unscopedPrmPath = new URL(unscopedPrmUrl).pathname;
408
- const unscopedPrmRes = await route(
409
- new Request(`http://localhost:1940${unscopedPrmPath}`),
410
- unscopedPrmPath,
411
- );
412
- expect(unscopedPrmRes.status).toBe(200);
413
- const unscopedPrm = (await unscopedPrmRes.json()) as { resource: string };
414
- expect(unscopedPrm.resource).toBe("http://localhost:1940/mcp");
321
+ expect(prm.resource).toBe("http://localhost:1940/vault/journal/mcp");
415
322
  });
416
323
 
417
324
  test("MCP 401 with invalid token still carries the challenge", async () => {
418
- // The no-token case is one 401 code path (extractApiKey returns null);
419
- // the invalid-token case is another (extractApiKey returns a string but
420
- // resolveToken / validateKey all fail). Both must emit the header.
421
325
  createVault("journal");
422
- const req = new Request("http://localhost:1940/vaults/journal/mcp", {
326
+ const path = "/vault/journal/mcp";
327
+ const req = new Request(`http://localhost:1940${path}`, {
423
328
  headers: { Authorization: "Bearer pvt_not-a-real-token" },
424
329
  });
425
- const res = await route(req, "/vaults/journal/mcp");
330
+ const res = await route(req, path);
426
331
  expect(res.status).toBe(401);
427
332
  expect(res.headers.get("WWW-Authenticate")).toBe(
428
- 'Bearer resource_metadata="http://localhost:1940/vaults/journal/.well-known/oauth-protected-resource"',
333
+ 'Bearer resource_metadata="http://localhost:1940/vault/journal/.well-known/oauth-protected-resource"',
429
334
  );
430
335
  });
431
336
 
432
337
  test("non-MCP 401s do NOT carry the challenge (spec is MCP-only)", async () => {
433
- // The RFC 9728 challenge header is specific to the MCP resource; plain
434
- // REST endpoints are not OAuth resources in the same sense. A spurious
435
- // challenge here could confuse non-MCP clients and makes the /api
436
- // surface look OAuth-gated when it is not.
437
- createVault("journal");
438
-
439
- // /api/notes (unscoped) — 401, no challenge.
440
- const unscopedApi = await route(new Request("http://localhost:1940/api/notes"), "/api/notes");
441
- expect(unscopedApi.status).toBe(401);
442
- expect(unscopedApi.headers.get("WWW-Authenticate")).toBeNull();
443
-
444
- // /vaults/journal/api/notes (scoped) — 401, no challenge. This is the
445
- // code path that shares the auth check with the scoped MCP branch, so
446
- // if we leak the header here the isScopedMcp gate has regressed.
447
- const scopedApi = await route(
448
- new Request("http://localhost:1940/vaults/journal/api/notes"),
449
- "/vaults/journal/api/notes",
450
- );
451
- expect(scopedApi.status).toBe(401);
452
- expect(scopedApi.headers.get("WWW-Authenticate")).toBeNull();
338
+ createVault("journal");
339
+
340
+ // /vault/journal/api/notes 401, no challenge.
341
+ const apiPath = "/vault/journal/api/notes";
342
+ const apiRes = await route(new Request(`http://localhost:1940${apiPath}`), apiPath);
343
+ expect(apiRes.status).toBe(401);
344
+ expect(apiRes.headers.get("WWW-Authenticate")).toBeNull();
453
345
 
454
346
  // /vaults (authenticated listing) — 401, no challenge.
455
347
  const vaultsList = await route(new Request("http://localhost:1940/vaults"), "/vaults");
@@ -460,40 +352,37 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
460
352
  test("x-forwarded-host and x-forwarded-proto shape the challenge URL", async () => {
461
353
  // Remote deployments behind Cloudflare Tunnel / Tailscale Funnel / any
462
354
  // reverse proxy need the challenge URL to match the external origin,
463
- // not the 127.0.0.1:1940 the server actually binds. Parallels how the
464
- // /.well-known/* endpoints already honor these headers.
355
+ // not the 127.0.0.1:1940 the server actually binds.
465
356
  createVault("journal");
466
- const req = new Request("http://127.0.0.1:1940/vaults/journal/mcp", {
357
+ const path = "/vault/journal/mcp";
358
+ const req = new Request(`http://127.0.0.1:1940${path}`, {
467
359
  headers: {
468
360
  "x-forwarded-host": "vault.example.com",
469
361
  "x-forwarded-proto": "https",
470
362
  },
471
363
  });
472
- const res = await route(req, "/vaults/journal/mcp");
364
+ const res = await route(req, path);
473
365
  expect(res.status).toBe(401);
474
366
  expect(res.headers.get("WWW-Authenticate")).toBe(
475
- 'Bearer resource_metadata="https://vault.example.com/vaults/journal/.well-known/oauth-protected-resource"',
367
+ 'Bearer resource_metadata="https://vault.example.com/vault/journal/.well-known/oauth-protected-resource"',
476
368
  );
477
369
  });
478
370
  });
479
371
 
480
372
  // ---------------------------------------------------------------------------
481
- // RFC 8414 §3.1 / RFC 9728 §3 path-insertion discovery.
373
+ // Per-vault OAuth discovery (RFC 8414 / RFC 9728, path-append form).
482
374
  //
483
- // For a resource at `/vaults/<name>/mcp`, the spec-mandated metadata URLs are
484
- // /.well-known/oauth-authorization-server/vaults/<name>[/mcp]
485
- // /.well-known/oauth-protected-resource/vaults/<name>[/mcp]
486
- // rather than the path-append form
487
- // /vaults/<name>/.well-known/<type>
488
- // that PR #111 also ships. Strict clients (including Claude Code's MCP OAuth
489
- // SDK) probe only the path-insertion form; lax clients try path-append. We
490
- // serve both so any conformant probe hits a live endpoint.
375
+ // For a resource at `/vault/<name>/mcp`, clients fetch metadata from
376
+ // /vault/<name>/.well-known/oauth-protected-resource
377
+ // /vault/<name>/.well-known/oauth-authorization-server
378
+ // All endpoints in the AS metadata are vault-scoped so a client that
379
+ // discovers the AS at that URL can drive the full authorization flow.
491
380
  // ---------------------------------------------------------------------------
492
381
 
493
- describe("path-insertion OAuth discovery (RFC 8414 §3.1 / RFC 9728 §3)", () => {
494
- test("/.well-known/oauth-authorization-server/vaults/<name> returns vault-scoped AS metadata", async () => {
382
+ describe("per-vault OAuth discovery", () => {
383
+ test("/vault/<name>/.well-known/oauth-authorization-server returns vault-scoped AS metadata", async () => {
495
384
  createVault("journal");
496
- const path = "/.well-known/oauth-authorization-server/vaults/journal";
385
+ const path = "/vault/journal/.well-known/oauth-authorization-server";
497
386
  const res = await route(new Request(`http://localhost:1940${path}`), path);
498
387
  expect(res.status).toBe(200);
499
388
  const body = (await res.json()) as {
@@ -502,90 +391,207 @@ describe("path-insertion OAuth discovery (RFC 8414 §3.1 / RFC 9728 §3)", () =>
502
391
  token_endpoint: string;
503
392
  registration_endpoint: string;
504
393
  };
505
- // All four endpoints must be vault-scoped — otherwise Claude Code's
506
- // registration_endpoint falls back to root `/register` and cascades 404.
507
- expect(body.issuer).toBe("http://localhost:1940/vaults/journal");
508
- expect(body.authorization_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/authorize");
509
- expect(body.token_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/token");
510
- expect(body.registration_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/register");
394
+ expect(body.issuer).toBe("http://localhost:1940/vault/journal");
395
+ expect(body.authorization_endpoint).toBe("http://localhost:1940/vault/journal/oauth/authorize");
396
+ expect(body.token_endpoint).toBe("http://localhost:1940/vault/journal/oauth/token");
397
+ expect(body.registration_endpoint).toBe("http://localhost:1940/vault/journal/oauth/register");
511
398
  });
512
399
 
513
- test("/.well-known/oauth-authorization-server/vaults/<name>/mcp (longer form) also returns AS metadata", async () => {
514
- // Aaron's log shows Claude Code probes this longer form too; cheap to
515
- // support since it resolves to the same AS for the same vault.
400
+ test("/vault/<name>/.well-known/oauth-protected-resource returns vault-scoped PRM", async () => {
516
401
  createVault("journal");
517
- const path = "/.well-known/oauth-authorization-server/vaults/journal/mcp";
402
+ const path = "/vault/journal/.well-known/oauth-protected-resource";
518
403
  const res = await route(new Request(`http://localhost:1940${path}`), path);
519
404
  expect(res.status).toBe(200);
405
+ const body = (await res.json()) as { resource: string; authorization_servers: string[] };
406
+ expect(body.resource).toBe("http://localhost:1940/vault/journal/mcp");
407
+ expect(body.authorization_servers).toEqual(["http://localhost:1940/vault/journal"]);
408
+ });
409
+
410
+ test("unknown vault returns 404 rather than boilerplate metadata", async () => {
411
+ createVault("journal");
412
+ for (const path of [
413
+ "/vault/nonexistent/.well-known/oauth-authorization-server",
414
+ "/vault/nonexistent/.well-known/oauth-protected-resource",
415
+ ]) {
416
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
417
+ expect(res.status).toBe(404);
418
+ }
419
+ });
420
+
421
+ test("x-forwarded-* headers propagate into the generated metadata URLs", async () => {
422
+ createVault("journal");
423
+ const path = "/vault/journal/.well-known/oauth-authorization-server";
424
+ const res = await route(
425
+ new Request(`http://127.0.0.1:1940${path}`, {
426
+ headers: {
427
+ "x-forwarded-host": "vault.example.com",
428
+ "x-forwarded-proto": "https",
429
+ },
430
+ }),
431
+ path,
432
+ );
433
+ expect(res.status).toBe(200);
520
434
  const body = (await res.json()) as { issuer: string; registration_endpoint: string };
521
- expect(body.issuer).toBe("http://localhost:1940/vaults/journal");
522
- expect(body.registration_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/register");
435
+ expect(body.issuer).toBe("https://vault.example.com/vault/journal");
436
+ expect(body.registration_endpoint).toBe(
437
+ "https://vault.example.com/vault/journal/oauth/register",
438
+ );
439
+ });
440
+
441
+ test("end-to-end flow: WWW-Authenticate → PRM → AS metadata → registration_endpoint is live", async () => {
442
+ // On 401, follow the challenge to the PRM, then follow
443
+ // PRM.authorization_servers[0] to the AS metadata, then hit the
444
+ // `registration_endpoint`. Every hop must resolve.
445
+ createVault("journal");
446
+
447
+ // Step 1: unauthenticated MCP → 401 + WWW-Authenticate.
448
+ const mcpPath = "/vault/journal/mcp";
449
+ const mcpRes = await route(new Request(`http://localhost:1940${mcpPath}`), mcpPath);
450
+ expect(mcpRes.status).toBe(401);
451
+ const challenge = mcpRes.headers.get("WWW-Authenticate")!;
452
+ const prmUrl = challenge.match(/resource_metadata="([^"]+)"/)![1];
453
+
454
+ // Step 2: fetch PRM.
455
+ const prmPath = new URL(prmUrl).pathname;
456
+ const prmRes = await route(new Request(`http://localhost:1940${prmPath}`), prmPath);
457
+ expect(prmRes.status).toBe(200);
458
+ const prm = (await prmRes.json()) as { authorization_servers: string[] };
459
+ const asBase = prm.authorization_servers[0]; // "http://localhost:1940/vault/journal"
460
+
461
+ // Step 3: AS metadata lives at `{asBase}/.well-known/oauth-authorization-server`.
462
+ const asBasePath = new URL(asBase).pathname; // "/vault/journal"
463
+ const asMetaPath = `${asBasePath}/.well-known/oauth-authorization-server`;
464
+ const asRes = await route(new Request(`http://localhost:1940${asMetaPath}`), asMetaPath);
465
+ expect(asRes.status).toBe(200);
466
+ const asMeta = (await asRes.json()) as { registration_endpoint: string };
467
+
468
+ // Step 4: the advertised registration_endpoint must be live.
469
+ const regPath = new URL(asMeta.registration_endpoint).pathname;
470
+ const regRes = await route(
471
+ new Request(`http://localhost:1940${regPath}`, {
472
+ method: "POST",
473
+ headers: { "Content-Type": "application/json" },
474
+ body: JSON.stringify({
475
+ client_name: "Test",
476
+ redirect_uris: ["https://example.com/cb"],
477
+ }),
478
+ }),
479
+ regPath,
480
+ );
481
+ expect(regRes.status).toBe(201);
482
+ });
483
+ });
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // RFC 8414 §3.1 / RFC 9728 §3 path-insertion discovery URLs.
487
+ //
488
+ // For a resource at `/vault/<name>/mcp`, conformant clients (including
489
+ // Claude Code's MCP OAuth SDK) probe metadata at
490
+ //
491
+ // /.well-known/oauth-authorization-server/vault/<name>[/mcp]
492
+ // /.well-known/oauth-protected-resource/vault/<name>[/mcp]
493
+ //
494
+ // These routes MUST return the same document as the path-append form —
495
+ // otherwise mixed-toolchain clients see drifted metadata. Coherence check
496
+ // below asserts deep equality.
497
+ // ---------------------------------------------------------------------------
498
+
499
+ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
500
+ test("AS metadata at path-insertion short form returns vault-scoped endpoints", async () => {
501
+ createVault("journal");
502
+ const path = "/.well-known/oauth-authorization-server/vault/journal";
503
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
504
+ expect(res.status).toBe(200);
505
+ const body = (await res.json()) as {
506
+ issuer: string;
507
+ authorization_endpoint: string;
508
+ token_endpoint: string;
509
+ registration_endpoint: string;
510
+ };
511
+ expect(body.issuer).toBe("http://localhost:1940/vault/journal");
512
+ expect(body.authorization_endpoint).toBe("http://localhost:1940/vault/journal/oauth/authorize");
513
+ expect(body.token_endpoint).toBe("http://localhost:1940/vault/journal/oauth/token");
514
+ expect(body.registration_endpoint).toBe("http://localhost:1940/vault/journal/oauth/register");
523
515
  });
524
516
 
525
- test("/.well-known/oauth-protected-resource/vaults/<name> returns vault-scoped PRM", async () => {
517
+ test("AS metadata at path-insertion long form (/mcp suffix) also works", async () => {
518
+ // Some SDKs probe with the exact resource path appended. The optional
519
+ // `/mcp` suffix covers that.
526
520
  createVault("journal");
527
- const path = "/.well-known/oauth-protected-resource/vaults/journal";
521
+ const path = "/.well-known/oauth-authorization-server/vault/journal/mcp";
522
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
523
+ expect(res.status).toBe(200);
524
+ const body = (await res.json()) as { issuer: string };
525
+ expect(body.issuer).toBe("http://localhost:1940/vault/journal");
526
+ });
527
+
528
+ test("PRM at path-insertion short form returns vault-scoped resource", async () => {
529
+ createVault("journal");
530
+ const path = "/.well-known/oauth-protected-resource/vault/journal";
528
531
  const res = await route(new Request(`http://localhost:1940${path}`), path);
529
532
  expect(res.status).toBe(200);
530
533
  const body = (await res.json()) as { resource: string; authorization_servers: string[] };
531
- expect(body.resource).toBe("http://localhost:1940/vaults/journal/mcp");
532
- expect(body.authorization_servers).toEqual(["http://localhost:1940/vaults/journal"]);
534
+ expect(body.resource).toBe("http://localhost:1940/vault/journal/mcp");
535
+ expect(body.authorization_servers).toEqual(["http://localhost:1940/vault/journal"]);
533
536
  });
534
537
 
535
- test("/.well-known/oauth-protected-resource/vaults/<name>/mcp (longer form) also returns PRM", async () => {
538
+ test("PRM at path-insertion long form (/mcp suffix) also works", async () => {
536
539
  createVault("journal");
537
- const path = "/.well-known/oauth-protected-resource/vaults/journal/mcp";
540
+ const path = "/.well-known/oauth-protected-resource/vault/journal/mcp";
538
541
  const res = await route(new Request(`http://localhost:1940${path}`), path);
539
542
  expect(res.status).toBe(200);
540
543
  const body = (await res.json()) as { resource: string };
541
- expect(body.resource).toBe("http://localhost:1940/vaults/journal/mcp");
544
+ expect(body.resource).toBe("http://localhost:1940/vault/journal/mcp");
542
545
  });
543
546
 
544
- test("path-insertion and path-append forms return identical metadata", async () => {
545
- // The coherence guarantee: a client that follows either spec shape MUST
546
- // land on the same AS config. If these drift, a mixed-toolchain deploy
547
- // (CLI using one form, daemon using the other) would mint tokens
548
- // against inconsistent endpoints.
547
+ test("path-insertion and path-append forms return deep-equal JSON (coherence)", async () => {
548
+ // Belt-and-braces: a client that probes one form and validates against
549
+ // the other (or a proxy that caches based on URL) must see identical
550
+ // bytes. If these drift, strict/lax clients will disagree on reality.
549
551
  createVault("journal");
552
+ const asPaths = [
553
+ "/.well-known/oauth-authorization-server/vault/journal",
554
+ "/vault/journal/.well-known/oauth-authorization-server",
555
+ ];
556
+ const [asInsert, asAppend] = await Promise.all(
557
+ asPaths.map(async (p) => {
558
+ const r = await route(new Request(`http://localhost:1940${p}`), p);
559
+ return r.json();
560
+ }),
561
+ );
562
+ expect(asInsert).toEqual(asAppend);
550
563
 
551
- // AS metadata
552
- const insertAsPath = "/.well-known/oauth-authorization-server/vaults/journal";
553
- const appendAsPath = "/vaults/journal/.well-known/oauth-authorization-server";
554
- const insertAsRes = await route(new Request(`http://localhost:1940${insertAsPath}`), insertAsPath);
555
- const appendAsRes = await route(new Request(`http://localhost:1940${appendAsPath}`), appendAsPath);
556
- expect(await insertAsRes.json()).toEqual(await appendAsRes.json());
557
-
558
- // PRM
559
- const insertPrmPath = "/.well-known/oauth-protected-resource/vaults/journal";
560
- const appendPrmPath = "/vaults/journal/.well-known/oauth-protected-resource";
561
- const insertPrmRes = await route(new Request(`http://localhost:1940${insertPrmPath}`), insertPrmPath);
562
- const appendPrmRes = await route(new Request(`http://localhost:1940${appendPrmPath}`), appendPrmPath);
563
- expect(await insertPrmRes.json()).toEqual(await appendPrmRes.json());
564
+ const prmPaths = [
565
+ "/.well-known/oauth-protected-resource/vault/journal",
566
+ "/vault/journal/.well-known/oauth-protected-resource",
567
+ ];
568
+ const [prmInsert, prmAppend] = await Promise.all(
569
+ prmPaths.map(async (p) => {
570
+ const r = await route(new Request(`http://localhost:1940${p}`), p);
571
+ return r.json();
572
+ }),
573
+ );
574
+ expect(prmInsert).toEqual(prmAppend);
564
575
  });
565
576
 
566
- test("unknown vault in path-insertion URL returns 404, not boilerplate metadata", async () => {
567
- // Don't leak metadata for phantom vaults. The equivalent path-append
568
- // route also 404s when the vault doesn't exist (`readVaultConfig` miss
569
- // at the vault-scoped routes branch); path-insertion must match.
577
+ test("unknown vault 404 on all four path-insertion shapes (no phantom metadata)", async () => {
570
578
  createVault("journal");
571
579
  for (const path of [
572
- "/.well-known/oauth-authorization-server/vaults/nonexistent",
573
- "/.well-known/oauth-authorization-server/vaults/nonexistent/mcp",
574
- "/.well-known/oauth-protected-resource/vaults/nonexistent",
575
- "/.well-known/oauth-protected-resource/vaults/nonexistent/mcp",
580
+ "/.well-known/oauth-authorization-server/vault/nonexistent",
581
+ "/.well-known/oauth-authorization-server/vault/nonexistent/mcp",
582
+ "/.well-known/oauth-protected-resource/vault/nonexistent",
583
+ "/.well-known/oauth-protected-resource/vault/nonexistent/mcp",
576
584
  ]) {
577
585
  const res = await route(new Request(`http://localhost:1940${path}`), path);
578
586
  expect(res.status).toBe(404);
587
+ const body = (await res.json()) as { error: string };
588
+ expect(body.error).toBe("Vault not found");
579
589
  }
580
590
  });
581
591
 
582
- test("x-forwarded-* headers propagate into the generated metadata URLs", async () => {
583
- // Same contract as the WWW-Authenticate challenge and the root/append
584
- // discovery endpoints: metadata must match the public-facing origin so
585
- // a Cloudflare Tunnel / Tailscale Funnel deployment doesn't advertise
586
- // internal localhost:1940 URLs.
592
+ test("x-forwarded-* headers propagate through path-insertion URLs", async () => {
587
593
  createVault("journal");
588
- const path = "/.well-known/oauth-authorization-server/vaults/journal";
594
+ const path = "/.well-known/oauth-authorization-server/vault/journal";
589
595
  const res = await route(
590
596
  new Request(`http://127.0.0.1:1940${path}`, {
591
597
  headers: {
@@ -597,52 +603,49 @@ describe("path-insertion OAuth discovery (RFC 8414 §3.1 / RFC 9728 §3)", () =>
597
603
  );
598
604
  expect(res.status).toBe(200);
599
605
  const body = (await res.json()) as { issuer: string; registration_endpoint: string };
600
- expect(body.issuer).toBe("https://vault.example.com/vaults/journal");
606
+ expect(body.issuer).toBe("https://vault.example.com/vault/journal");
601
607
  expect(body.registration_endpoint).toBe(
602
- "https://vault.example.com/vaults/journal/oauth/register",
608
+ "https://vault.example.com/vault/journal/oauth/register",
603
609
  );
604
610
  });
605
611
 
606
- test("end-to-end flow: WWW-Authenticate → PRM → AS metadataregistration_endpoint is live", async () => {
607
- // The actual Claude-Code bug: on 401, follow the challenge to the PRM,
608
- // then follow PRM.authorization_servers[0] to the AS metadata (via
609
- // path-insertion), then hit the `registration_endpoint`. Every hop
610
- // must resolve — before the fix, the AS-metadata-via-path-insertion
611
- // step 404'd and the SDK fell back to `/register` which also 404'd.
612
+ test("end-to-end: 401 → PRM (path-insertion) → AS (path-insertion)DCR lands", async () => {
613
+ // Exact handshake Claude Code's MCP OAuth SDK performs. If any hop
614
+ // 404s, auth cascade-fails this is the launch-blocker regression
615
+ // we're fixing.
612
616
  createVault("journal");
613
617
 
614
- // Step 1: unauthenticated MCP → 401 + WWW-Authenticate.
615
- const mcpRes = await route(
616
- new Request("http://localhost:1940/vaults/journal/mcp"),
617
- "/vaults/journal/mcp",
618
- );
618
+ // Step 1: unauth MCP → 401 + WWW-Authenticate with PRM pointer.
619
+ const mcpPath = "/vault/journal/mcp";
620
+ const mcpRes = await route(new Request(`http://localhost:1940${mcpPath}`), mcpPath);
619
621
  expect(mcpRes.status).toBe(401);
620
622
  const challenge = mcpRes.headers.get("WWW-Authenticate")!;
621
623
  const prmUrl = challenge.match(/resource_metadata="([^"]+)"/)![1];
622
624
 
623
- // Step 2: fetch PRM. The challenge points at the path-append form, but
624
- // a strict client might also try path-insertion both must work.
625
- // Follow the advertised URL (path-append in this case) and note the
626
- // authorization_servers pointer.
627
- const prmPath = new URL(prmUrl).pathname;
628
- const prmRes = await route(new Request(`http://localhost:1940${prmPath}`), prmPath);
625
+ // The challenge still points at the path-append PRM (we emit one URL
626
+ // in the header). Strict clients ignore the hint and probe the
627
+ // path-insertion form regardless that's the path we care about here.
628
+ const prmInsertPath = "/.well-known/oauth-protected-resource/vault/journal";
629
+ const prmRes = await route(
630
+ new Request(`http://localhost:1940${prmInsertPath}`),
631
+ prmInsertPath,
632
+ );
629
633
  expect(prmRes.status).toBe(200);
630
634
  const prm = (await prmRes.json()) as { authorization_servers: string[] };
631
- const asBase = prm.authorization_servers[0]; // "http://localhost:1940/vaults/journal"
635
+ const asBase = prm.authorization_servers[0]; // http://localhost:1940/vault/journal
632
636
 
633
- // Step 3: strict-client path-insertion probe for AS metadata.
634
- const asBasePath = new URL(asBase).pathname; // "/vaults/journal"
637
+ // Step 2: fetch AS metadata via path-insertion. The issuer path is
638
+ // everything after the host here, `/vault/journal`.
639
+ const asBasePath = new URL(asBase).pathname;
635
640
  const asInsertPath = `/.well-known/oauth-authorization-server${asBasePath}`;
636
641
  const asRes = await route(
637
642
  new Request(`http://localhost:1940${asInsertPath}`),
638
643
  asInsertPath,
639
644
  );
640
- // This was the 404 before the fix — the reason Claude Code's SDK gave
641
- // up and cascade-404'd on `/register`.
642
645
  expect(asRes.status).toBe(200);
643
646
  const asMeta = (await asRes.json()) as { registration_endpoint: string };
644
647
 
645
- // Step 4: the advertised registration_endpoint must be live (POST-only).
648
+ // Step 3: registration_endpoint must be live.
646
649
  const regPath = new URL(asMeta.registration_endpoint).pathname;
647
650
  const regRes = await route(
648
651
  new Request(`http://localhost:1940${regPath}`, {
@@ -655,7 +658,521 @@ describe("path-insertion OAuth discovery (RFC 8414 §3.1 / RFC 9728 §3)", () =>
655
658
  }),
656
659
  regPath,
657
660
  );
658
- // Successful DCR is 201; anything but 404 proves the endpoint is wired.
659
661
  expect(regRes.status).toBe(201);
662
+
663
+ // Path-append PRM URL in the challenge header must still resolve —
664
+ // we haven't broken the back-compat path.
665
+ const prmAppendRes = await route(
666
+ new Request(prmUrl),
667
+ new URL(prmUrl).pathname,
668
+ );
669
+ expect(prmAppendRes.status).toBe(200);
670
+ });
671
+ });
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // /.parachute/info + /.parachute/icon.svg — public service-info card for the
675
+ // CLI hub page at the ecosystem root. The hub fetches these per service to
676
+ // render tiles, so the endpoints are public (no auth), CORS `*`, and zero
677
+ // PII. Shape is locked so all services line up in the aggregator UI.
678
+ // ---------------------------------------------------------------------------
679
+
680
+ describe("/.parachute/info + /.parachute/icon.svg", () => {
681
+ test("info returns the locked card shape with version from package.json", async () => {
682
+ createVault("journal");
683
+ const pkg = (await import("../package.json", { with: { type: "json" } })).default;
684
+ const path = "/vault/journal/.parachute/info";
685
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
686
+ expect(res.status).toBe(200);
687
+ expect(res.headers.get("Content-Type")).toContain("application/json");
688
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
689
+ const body = (await res.json()) as {
690
+ name: string;
691
+ displayName: string;
692
+ tagline: string;
693
+ version: string;
694
+ iconUrl: string;
695
+ kind: string;
696
+ };
697
+ expect(body).toEqual({
698
+ name: "parachute-vault",
699
+ displayName: "Vault",
700
+ tagline: expect.stringContaining("knowledge graph"),
701
+ version: pkg.version,
702
+ iconUrl: "/vault/journal/.parachute/icon.svg",
703
+ kind: "api",
704
+ });
705
+ });
706
+
707
+ test("info iconUrl is vault-scoped and points at a live icon handler", async () => {
708
+ createVault("work");
709
+ const infoPath = "/vault/work/.parachute/info";
710
+ const infoRes = await route(new Request(`http://localhost:1940${infoPath}`), infoPath);
711
+ const info = (await infoRes.json()) as { iconUrl: string };
712
+ expect(info.iconUrl).toBe("/vault/work/.parachute/icon.svg");
713
+
714
+ // Follow the pointer — the advertised iconUrl must resolve.
715
+ const iconRes = await route(
716
+ new Request(`http://localhost:1940${info.iconUrl}`),
717
+ info.iconUrl,
718
+ );
719
+ expect(iconRes.status).toBe(200);
720
+ });
721
+
722
+ test("icon.svg returns an SVG body with the right content-type + CORS", async () => {
723
+ createVault("journal");
724
+ const path = "/vault/journal/.parachute/icon.svg";
725
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
726
+ expect(res.status).toBe(200);
727
+ expect(res.headers.get("Content-Type")).toBe("image/svg+xml");
728
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
729
+ // Pin nosniff so older Edge/IE can't sniff the inline SVG as HTML.
730
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
731
+ const body = await res.text();
732
+ expect(body).toContain("<svg");
733
+ expect(body).toContain("</svg>");
734
+ });
735
+
736
+ test("both endpoints are public — no auth header required, none honored", async () => {
737
+ createVault("journal");
738
+ for (const path of [
739
+ "/vault/journal/.parachute/info",
740
+ "/vault/journal/.parachute/icon.svg",
741
+ ]) {
742
+ // No Authorization header.
743
+ const resAnon = await route(new Request(`http://localhost:1940${path}`), path);
744
+ expect(resAnon.status).toBe(200);
745
+
746
+ // Bogus Authorization header — still 200, auth is not consulted.
747
+ const resWithHeader = await route(
748
+ new Request(`http://localhost:1940${path}`, {
749
+ headers: { Authorization: "Bearer pvt_nonsense" },
750
+ }),
751
+ path,
752
+ );
753
+ expect(resWithHeader.status).toBe(200);
754
+ }
755
+ });
756
+
757
+ test("unknown vault returns 404 before reaching the info/icon handlers", async () => {
758
+ createVault("journal");
759
+ for (const path of [
760
+ "/vault/nonexistent/.parachute/info",
761
+ "/vault/nonexistent/.parachute/icon.svg",
762
+ ]) {
763
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
764
+ expect(res.status).toBe(404);
765
+ }
766
+ });
767
+
768
+ test("non-GET methods return 405 (and never trigger auth — stays public)", async () => {
769
+ createVault("journal");
770
+ for (const path of [
771
+ "/vault/journal/.parachute/info",
772
+ "/vault/journal/.parachute/icon.svg",
773
+ ]) {
774
+ const res = await route(
775
+ new Request(`http://localhost:1940${path}`, { method: "POST" }),
776
+ path,
777
+ );
778
+ expect(res.status).toBe(405);
779
+ }
780
+ });
781
+ });
782
+
783
+ // ---------------------------------------------------------------------------
784
+ // /.parachute/config/schema + /.parachute/config — module config (Phase 2).
785
+ // ---------------------------------------------------------------------------
786
+
787
+ describe("/.parachute/config/schema + /.parachute/config", () => {
788
+ test("schema returns JSON Schema draft-07 shape with the documented properties", async () => {
789
+ createVault("journal");
790
+ const path = "/vault/journal/.parachute/config/schema";
791
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
792
+ expect(res.status).toBe(200);
793
+ expect(res.headers.get("Content-Type")).toContain("application/json");
794
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
795
+ const body = (await res.json()) as {
796
+ $schema: string;
797
+ type: string;
798
+ title: string;
799
+ properties: Record<string, { type?: string; enum?: string[]; writeOnly?: boolean; readOnly?: boolean }>;
800
+ };
801
+ expect(body.$schema).toBe("http://json-schema.org/draft-07/schema#");
802
+ expect(body.type).toBe("object");
803
+ expect(body.properties.audio_retention?.type).toBe("string");
804
+ expect(body.properties.audio_retention?.enum).toEqual(["keep", "until_transcribed", "never"]);
805
+ expect(body.properties.scribe_url?.type).toBe("string");
806
+ expect(body.properties.scribe_token?.writeOnly).toBe(true);
807
+ expect(body.properties.port?.readOnly).toBe(true);
808
+ });
809
+
810
+ test("config returns current values with writeOnly fields excluded", async () => {
811
+ createVault("journal");
812
+ const token = createAdminToken("journal");
813
+ const path = "/vault/journal/.parachute/config";
814
+ const origScribeToken = process.env.SCRIBE_TOKEN;
815
+ const origScribeUrl = process.env.SCRIBE_URL;
816
+ process.env.SCRIBE_TOKEN = "super-secret-should-never-appear";
817
+ process.env.SCRIBE_URL = "https://scribe.example/v1";
818
+ try {
819
+ const res = await route(
820
+ new Request(`http://localhost:1940${path}`, {
821
+ headers: { authorization: `Bearer ${token}` },
822
+ }),
823
+ path,
824
+ );
825
+ expect(res.status).toBe(200);
826
+ const body = (await res.json()) as Record<string, unknown>;
827
+ expect(body.audio_retention).toBe("keep"); // default when unset
828
+ expect(body.scribe_url).toBe("https://scribe.example/v1");
829
+ expect(body.port).toBe(1940);
830
+ // writeOnly field must not appear in GET.
831
+ expect(body).not.toHaveProperty("scribe_token");
832
+ // Defense in depth: grep the raw body for the token value.
833
+ expect(JSON.stringify(body)).not.toContain("super-secret-should-never-appear");
834
+ } finally {
835
+ if (origScribeToken === undefined) delete process.env.SCRIBE_TOKEN;
836
+ else process.env.SCRIBE_TOKEN = origScribeToken;
837
+ if (origScribeUrl === undefined) delete process.env.SCRIBE_URL;
838
+ else process.env.SCRIBE_URL = origScribeUrl;
839
+ }
840
+ });
841
+
842
+ test("config reflects per-vault audio_retention override", async () => {
843
+ writeVaultConfig({
844
+ name: "journal",
845
+ api_keys: [],
846
+ created_at: new Date().toISOString(),
847
+ audio_retention: "until_transcribed",
848
+ });
849
+ const token = createAdminToken("journal");
850
+ const path = "/vault/journal/.parachute/config";
851
+ const res = await route(
852
+ new Request(`http://localhost:1940${path}`, {
853
+ headers: { authorization: `Bearer ${token}` },
854
+ }),
855
+ path,
856
+ );
857
+ const body = (await res.json()) as { audio_retention: string };
858
+ expect(body.audio_retention).toBe("until_transcribed");
859
+ });
860
+
861
+ test("config scribe_url falls back to empty string when SCRIBE_URL env is unset", async () => {
862
+ createVault("journal");
863
+ const token = createAdminToken("journal");
864
+ const orig = process.env.SCRIBE_URL;
865
+ delete process.env.SCRIBE_URL;
866
+ try {
867
+ const path = "/vault/journal/.parachute/config";
868
+ const res = await route(
869
+ new Request(`http://localhost:1940${path}`, {
870
+ headers: { authorization: `Bearer ${token}` },
871
+ }),
872
+ path,
873
+ );
874
+ const body = (await res.json()) as { scribe_url: string };
875
+ expect(body.scribe_url).toBe("");
876
+ } finally {
877
+ if (orig !== undefined) process.env.SCRIBE_URL = orig;
878
+ }
879
+ });
880
+
881
+ test("unknown vault returns 404 before reaching the config handlers", async () => {
882
+ createVault("journal");
883
+ for (const path of [
884
+ "/vault/nonexistent/.parachute/config/schema",
885
+ "/vault/nonexistent/.parachute/config",
886
+ ]) {
887
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
888
+ expect(res.status).toBe(404);
889
+ }
890
+ });
891
+
892
+ test("non-GET methods return 405 — PUT lands in Phase 3, not silently accepted now", async () => {
893
+ createVault("journal");
894
+ for (const path of [
895
+ "/vault/journal/.parachute/config/schema",
896
+ "/vault/journal/.parachute/config",
897
+ ]) {
898
+ for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
899
+ const res = await route(
900
+ new Request(`http://localhost:1940${path}`, { method }),
901
+ path,
902
+ );
903
+ expect(res.status).toBe(405);
904
+ }
905
+ }
906
+ });
907
+
908
+ test("schema endpoint is public — hub form renders without auth", async () => {
909
+ createVault("journal");
910
+ const path = "/vault/journal/.parachute/config/schema";
911
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
912
+ expect(res.status).toBe(200);
913
+ });
914
+
915
+ test("config endpoint requires vault:admin — unauthenticated GET returns 401", async () => {
916
+ createVault("journal");
917
+ const path = "/vault/journal/.parachute/config";
918
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
919
+ expect(res.status).toBe(401);
920
+ });
921
+
922
+ test("config endpoint rejects a vault:read token with 403 + insufficient_scope", async () => {
923
+ createVault("journal");
924
+ const store = getVaultStore("journal");
925
+ const { fullToken } = generateToken();
926
+ createToken(store.db, fullToken, {
927
+ label: "reader",
928
+ permission: "read",
929
+ scopes: ["vault:read"],
930
+ });
931
+ const path = "/vault/journal/.parachute/config";
932
+ const res = await route(
933
+ new Request(`http://localhost:1940${path}`, {
934
+ headers: { authorization: `Bearer ${fullToken}` },
935
+ }),
936
+ path,
937
+ );
938
+ expect(res.status).toBe(403);
939
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
940
+ expect(body.error_type).toBe("insufficient_scope");
941
+ expect(body.required_scope).toBe("vault:admin");
942
+ });
943
+ });
944
+
945
+ // ---------------------------------------------------------------------------
946
+ // Scope enforcement on /api/* — PR D (task #97).
947
+ //
948
+ // The REST surface picks the required scope per method (GET → vault:read,
949
+ // POST/PATCH/DELETE → vault:write). These tests pin the full matrix:
950
+ // read-only rejected on writes, write succeeds on GET via inheritance, admin
951
+ // succeeds everywhere, legacy DB rows with NULL scopes keep working, and the
952
+ // 403 body names the required scope so agents can diagnose without tracing.
953
+ // ---------------------------------------------------------------------------
954
+
955
+ describe("scope enforcement on /api/*", () => {
956
+ /** Mint a token with the given scopes and return its bearer value. */
957
+ function mintToken(
958
+ vaultName: string,
959
+ opts: {
960
+ permission: "full" | "read";
961
+ scopes?: string[];
962
+ legacyNullScopes?: boolean;
963
+ },
964
+ ): string {
965
+ const store = getVaultStore(vaultName);
966
+ const { fullToken } = generateToken();
967
+ if (opts.legacyNullScopes) {
968
+ // Simulate a pre-v12 token row: NULL scopes column, legacy permission
969
+ // value. resolveToken should fall back via legacyPermissionToScopes.
970
+ const { hashKey } = require("./config.ts");
971
+ const hash = hashKey(fullToken);
972
+ store.db.prepare(
973
+ "INSERT INTO tokens (token_hash, label, permission, created_at) VALUES (?, ?, ?, ?)",
974
+ ).run(hash, `legacy-${opts.permission}`, opts.permission, new Date().toISOString());
975
+ } else {
976
+ createToken(store.db, fullToken, {
977
+ label: `test-${opts.permission}`,
978
+ permission: opts.permission,
979
+ scopes: opts.scopes,
980
+ });
981
+ }
982
+ return fullToken;
983
+ }
984
+
985
+ function authed(token: string, method = "GET", path: string): Request {
986
+ return new Request(`http://localhost:1940${path}`, {
987
+ method,
988
+ headers: { authorization: `Bearer ${token}` },
989
+ });
990
+ }
991
+
992
+ test("vault:read token permits GET /api/vault", async () => {
993
+ createVault("journal");
994
+ const token = mintToken("journal", {
995
+ permission: "read",
996
+ scopes: ["vault:read"],
997
+ });
998
+ const path = "/vault/journal/api/vault";
999
+ const res = await route(authed(token, "GET", path), path);
1000
+ expect(res.status).toBe(200);
1001
+ });
1002
+
1003
+ test("vault:read token rejected on POST /api/notes with 403 insufficient_scope", async () => {
1004
+ createVault("journal");
1005
+ const token = mintToken("journal", {
1006
+ permission: "read",
1007
+ scopes: ["vault:read"],
1008
+ });
1009
+ const path = "/vault/journal/api/notes";
1010
+ const res = await route(
1011
+ new Request(`http://localhost:1940${path}`, {
1012
+ method: "POST",
1013
+ headers: {
1014
+ authorization: `Bearer ${token}`,
1015
+ "content-type": "application/json",
1016
+ },
1017
+ body: JSON.stringify({ content: "nope" }),
1018
+ }),
1019
+ path,
1020
+ );
1021
+ expect(res.status).toBe(403);
1022
+ const body = (await res.json()) as { error_type?: string; required_scope?: string; granted_scopes?: string[] };
1023
+ expect(body.error_type).toBe("insufficient_scope");
1024
+ expect(body.required_scope).toBe("vault:write");
1025
+ expect(body.granted_scopes).toEqual(["vault:read"]);
1026
+ });
1027
+
1028
+ test("vault:write token permits GET (inheritance: write ⊇ read)", async () => {
1029
+ createVault("journal");
1030
+ const token = mintToken("journal", {
1031
+ permission: "full",
1032
+ scopes: ["vault:write"],
1033
+ });
1034
+ const path = "/vault/journal/api/vault";
1035
+ const res = await route(authed(token, "GET", path), path);
1036
+ expect(res.status).toBe(200);
1037
+ });
1038
+
1039
+ test("vault:write token permits POST /api/notes", async () => {
1040
+ createVault("journal");
1041
+ const token = mintToken("journal", {
1042
+ permission: "full",
1043
+ scopes: ["vault:write"],
1044
+ });
1045
+ const path = "/vault/journal/api/notes";
1046
+ const res = await route(
1047
+ new Request(`http://localhost:1940${path}`, {
1048
+ method: "POST",
1049
+ headers: {
1050
+ authorization: `Bearer ${token}`,
1051
+ "content-type": "application/json",
1052
+ },
1053
+ body: JSON.stringify({ content: "hi" }),
1054
+ }),
1055
+ path,
1056
+ );
1057
+ // 200/201 means scope gate allowed through — we don't care about the
1058
+ // shape of the note here, just that we got past auth.
1059
+ expect(res.status).toBeLessThan(400);
1060
+ });
1061
+
1062
+ test("vault:admin token permits admin-only /.parachute/config", async () => {
1063
+ createVault("journal");
1064
+ const token = mintToken("journal", {
1065
+ permission: "full",
1066
+ scopes: ["vault:admin"],
1067
+ });
1068
+ const path = "/vault/journal/.parachute/config";
1069
+ const res = await route(authed(token, "GET", path), path);
1070
+ expect(res.status).toBe(200);
1071
+ });
1072
+
1073
+ test("vault:admin token permits GET + POST via inheritance", async () => {
1074
+ createVault("journal");
1075
+ const token = mintToken("journal", {
1076
+ permission: "full",
1077
+ scopes: ["vault:admin"],
1078
+ });
1079
+ const getPath = "/vault/journal/api/vault";
1080
+ const getRes = await route(authed(token, "GET", getPath), getPath);
1081
+ expect(getRes.status).toBe(200);
1082
+
1083
+ const postPath = "/vault/journal/api/notes";
1084
+ const postRes = await route(
1085
+ new Request(`http://localhost:1940${postPath}`, {
1086
+ method: "POST",
1087
+ headers: {
1088
+ authorization: `Bearer ${token}`,
1089
+ "content-type": "application/json",
1090
+ },
1091
+ body: JSON.stringify({ content: "hi" }),
1092
+ }),
1093
+ postPath,
1094
+ );
1095
+ expect(postRes.status).toBeLessThan(400);
1096
+ });
1097
+
1098
+ test("legacy token (NULL scopes, permission='full') still works on writes", async () => {
1099
+ createVault("journal");
1100
+ const token = mintToken("journal", {
1101
+ permission: "full",
1102
+ legacyNullScopes: true,
1103
+ });
1104
+ const path = "/vault/journal/api/notes";
1105
+ const res = await route(
1106
+ new Request(`http://localhost:1940${path}`, {
1107
+ method: "POST",
1108
+ headers: {
1109
+ authorization: `Bearer ${token}`,
1110
+ "content-type": "application/json",
1111
+ },
1112
+ body: JSON.stringify({ content: "legacy" }),
1113
+ }),
1114
+ path,
1115
+ );
1116
+ expect(res.status).toBeLessThan(400);
1117
+ });
1118
+
1119
+ test("legacy token (NULL scopes, permission='read') rejected on writes", async () => {
1120
+ createVault("journal");
1121
+ const token = mintToken("journal", {
1122
+ permission: "read",
1123
+ legacyNullScopes: true,
1124
+ });
1125
+ const path = "/vault/journal/api/notes";
1126
+ const res = await route(
1127
+ new Request(`http://localhost:1940${path}`, {
1128
+ method: "POST",
1129
+ headers: {
1130
+ authorization: `Bearer ${token}`,
1131
+ "content-type": "application/json",
1132
+ },
1133
+ body: JSON.stringify({ content: "nope" }),
1134
+ }),
1135
+ path,
1136
+ );
1137
+ expect(res.status).toBe(403);
1138
+ const body = (await res.json()) as { required_scope?: string };
1139
+ expect(body.required_scope).toBe("vault:write");
1140
+ });
1141
+
1142
+ test("unauthenticated request to /api returns 401 (not 403)", async () => {
1143
+ createVault("journal");
1144
+ const path = "/vault/journal/api/vault";
1145
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
1146
+ expect(res.status).toBe(401);
1147
+ });
1148
+
1149
+ test("CLI --read equivalent token (permission='read', scopes=[vault:read]) is read-only at the HTTP boundary", async () => {
1150
+ // This pins the end-to-end contract: a token minted the way
1151
+ // `parachute-vault tokens create --read` mints them actually refuses
1152
+ // writes. Without this, a cosmetic `--read` flag could silently allow
1153
+ // mutations — the whole point of the review item.
1154
+ createVault("journal");
1155
+ const token = mintToken("journal", {
1156
+ permission: "read",
1157
+ scopes: ["vault:read"],
1158
+ });
1159
+
1160
+ const readPath = "/vault/journal/api/vault";
1161
+ const readRes = await route(authed(token, "GET", readPath), readPath);
1162
+ expect(readRes.status).toBe(200);
1163
+
1164
+ const writePath = "/vault/journal/api/notes";
1165
+ const writeRes = await route(
1166
+ new Request(`http://localhost:1940${writePath}`, {
1167
+ method: "POST",
1168
+ headers: {
1169
+ authorization: `Bearer ${token}`,
1170
+ "content-type": "application/json",
1171
+ },
1172
+ body: JSON.stringify({ content: "nope" }),
1173
+ }),
1174
+ writePath,
1175
+ );
1176
+ expect(writeRes.status).toBe(403);
660
1177
  });
661
1178
  });