@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.
- package/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/cli.ts +179 -121
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +583 -0
- package/src/transcription-worker.ts +346 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -16
package/src/routing.test.ts
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the HTTP routing layer (src/routing.ts).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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, "
|
|
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
|
|
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'
|
|
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 —
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
223
|
-
//
|
|
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("
|
|
227
|
-
test("
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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("
|
|
232
|
+
test("/vault/<name>/api/notes reaches per-vault auth (401 unauthenticated)", async () => {
|
|
242
233
|
createVault("journal");
|
|
243
|
-
const
|
|
244
|
-
const res = await route(
|
|
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("
|
|
239
|
+
test("/vault/<name>/oauth/register reaches the OAuth handler", async () => {
|
|
251
240
|
createVault("journal");
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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("
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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("
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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("
|
|
305
|
-
createVault("journal");
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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.
|
|
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("
|
|
295
|
+
test("/vault/<name>/mcp 401 carries the vault-scoped pointer", async () => {
|
|
362
296
|
createVault("journal");
|
|
363
|
-
const
|
|
364
|
-
const res = await route(
|
|
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/
|
|
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
|
|
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.
|
|
310
|
+
// reject on resource mismatch anyway.
|
|
388
311
|
createVault("journal");
|
|
389
312
|
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
const
|
|
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/
|
|
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
|
|
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,
|
|
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/
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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.
|
|
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
|
|
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,
|
|
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/
|
|
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
|
|
373
|
+
// Per-vault OAuth discovery (RFC 8414 / RFC 9728, path-append form).
|
|
482
374
|
//
|
|
483
|
-
// For a resource at `/
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
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("
|
|
494
|
-
test("
|
|
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
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
expect(body.
|
|
508
|
-
expect(body.
|
|
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("
|
|
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-
|
|
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("
|
|
522
|
-
expect(body.registration_endpoint).toBe(
|
|
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("
|
|
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-
|
|
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/
|
|
532
|
-
expect(body.authorization_servers).toEqual(["http://localhost:1940/
|
|
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("
|
|
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/
|
|
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/
|
|
544
|
+
expect(body.resource).toBe("http://localhost:1940/vault/journal/mcp");
|
|
542
545
|
});
|
|
543
546
|
|
|
544
|
-
test("path-insertion and path-append forms return
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
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/
|
|
573
|
-
"/.well-known/oauth-authorization-server/
|
|
574
|
-
"/.well-known/oauth-protected-resource/
|
|
575
|
-
"/.well-known/oauth-protected-resource/
|
|
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
|
|
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/
|
|
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/
|
|
606
|
+
expect(body.issuer).toBe("https://vault.example.com/vault/journal");
|
|
601
607
|
expect(body.registration_endpoint).toBe(
|
|
602
|
-
"https://vault.example.com/
|
|
608
|
+
"https://vault.example.com/vault/journal/oauth/register",
|
|
603
609
|
);
|
|
604
610
|
});
|
|
605
611
|
|
|
606
|
-
test("end-to-end
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
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:
|
|
615
|
-
const
|
|
616
|
-
|
|
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
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
const
|
|
628
|
-
|
|
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]; //
|
|
635
|
+
const asBase = prm.authorization_servers[0]; // http://localhost:1940/vault/journal
|
|
632
636
|
|
|
633
|
-
// Step
|
|
634
|
-
|
|
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
|
|
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
|
});
|