@openparachute/vault 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.parachute/module.json +15 -0
  2. package/core/src/core.test.ts +2252 -7
  3. package/core/src/links.ts +1 -1
  4. package/core/src/mcp.ts +801 -67
  5. package/core/src/note-schemas.ts +232 -0
  6. package/core/src/notes.ts +313 -35
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +287 -0
  11. package/core/src/schema.ts +393 -9
  12. package/core/src/store.ts +248 -6
  13. package/core/src/tag-hierarchy.ts +137 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +100 -6
  16. package/core/src/wikilinks.ts +3 -3
  17. package/package.json +13 -3
  18. package/src/admin-spa.test.ts +161 -0
  19. package/src/admin-spa.ts +161 -0
  20. package/src/auth-hub-jwt.test.ts +231 -0
  21. package/src/auth-status.ts +84 -0
  22. package/src/auth.test.ts +135 -23
  23. package/src/auth.ts +144 -15
  24. package/src/backup.ts +4 -7
  25. package/src/cli.ts +322 -57
  26. package/src/config.test.ts +44 -0
  27. package/src/config.ts +68 -40
  28. package/src/hub-jwt.test.ts +296 -0
  29. package/src/hub-jwt.ts +79 -0
  30. package/src/init.test.ts +216 -0
  31. package/src/mcp-http.ts +30 -28
  32. package/src/mcp-install.ts +1 -1
  33. package/src/mcp-tools.ts +294 -6
  34. package/src/module-config.ts +1 -1
  35. package/src/oauth.test.ts +345 -0
  36. package/src/oauth.ts +85 -14
  37. package/src/owner-auth.ts +57 -1
  38. package/src/prompt.ts +6 -5
  39. package/src/routes.ts +686 -58
  40. package/src/routing.test.ts +466 -1
  41. package/src/routing.ts +108 -24
  42. package/src/scopes.test.ts +66 -8
  43. package/src/scopes.ts +163 -37
  44. package/src/server.ts +24 -2
  45. package/src/services-manifest.test.ts +20 -0
  46. package/src/services-manifest.ts +9 -2
  47. package/src/stop-signal.test.ts +85 -0
  48. package/src/storage.test.ts +92 -0
  49. package/src/tag-scope.ts +118 -0
  50. package/src/token-store.test.ts +47 -0
  51. package/src/token-store.ts +128 -13
  52. package/src/tokens-routes.test.ts +720 -0
  53. package/src/tokens-routes.ts +392 -0
  54. package/src/transcription-worker.test.ts +5 -0
  55. package/src/triggers.ts +1 -1
  56. package/src/two-factor.ts +2 -2
  57. package/src/vault-create.test.ts +193 -0
  58. package/src/vault-name.test.ts +123 -0
  59. package/src/vault-name.ts +80 -0
  60. package/src/vault.test.ts +868 -3
  61. package/tsconfig.json +8 -1
  62. package/.claude/settings.local.json +0 -8
  63. package/.dockerignore +0 -8
  64. package/.env.example +0 -9
  65. package/CHANGELOG.md +0 -175
  66. package/CLAUDE.md +0 -125
  67. package/Caddyfile +0 -3
  68. package/Dockerfile +0 -22
  69. package/bun.lock +0 -219
  70. package/bunfig.toml +0 -2
  71. package/deploy/parachute-vault.service +0 -20
  72. package/docker-compose.yml +0 -50
  73. package/docs/HTTP_API.md +0 -434
  74. package/docs/auth-model.md +0 -340
  75. package/fly.toml +0 -24
  76. package/package/package.json +0 -32
  77. package/railway.json +0 -14
  78. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  79. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/routing.ts CHANGED
@@ -40,20 +40,24 @@ import {
40
40
  authenticateVaultRequest,
41
41
  authenticateGlobalRequest,
42
42
  extractApiKey,
43
- requireScope,
44
43
  } from "./auth.ts";
45
- import { SCOPE_ADMIN, scopeForMethod } from "./scopes.ts";
44
+ import { hasScopeForVault, SCOPE_ADMIN, scopeForMethod, verbForMethod } from "./scopes.ts";
46
45
  import { getVaultStore } from "./vault-store.ts";
47
46
  import { handleScopedMcp } from "./mcp-http.ts";
47
+ import { defaultAdminSpaDistDir, isAdminSpaPath, serveAdminSpa } from "./admin-spa.ts";
48
48
  import {
49
49
  handleNotes,
50
50
  handleTags,
51
+ handleNoteSchemas,
51
52
  handleFindPath,
52
53
  handleVault,
53
54
  handleUnresolvedWikilinks,
54
55
  handleStorage,
55
56
  handleViewNote,
57
+ type TagScopeCtx,
56
58
  } from "./routes.ts";
59
+ import { expandTokenTagScope } from "./tag-scope.ts";
60
+ import { handleTokens } from "./tokens-routes.ts";
57
61
  import {
58
62
  handleProtectedResource,
59
63
  handleAuthorizationServer,
@@ -64,6 +68,8 @@ import {
64
68
  getBaseUrl,
65
69
  } from "./oauth.ts";
66
70
  import { handleConfigSchema, handleConfig } from "./module-config.ts";
71
+ import { buildAuthStatus } from "./auth-status.ts";
72
+ import { getAuthorizeRateLimiter } from "./owner-auth.ts";
67
73
 
68
74
  /**
69
75
  * Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
@@ -100,15 +106,15 @@ async function withMcpChallenge(
100
106
  * Returns true if authenticated, false if not. Never rejects — unauthenticated
101
107
  * requests still get public notes.
102
108
  */
103
- function isViewAuthenticated(
109
+ async function isViewAuthenticated(
104
110
  req: Request,
105
111
  vaultConfig: VaultConfig | null,
106
112
  vaultDb?: import("bun:sqlite").Database,
107
- ): boolean {
113
+ ): Promise<boolean> {
108
114
  if (!vaultConfig) return false;
109
115
  const key = extractApiKey(req);
110
116
  if (!key) return false;
111
- const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
117
+ const auth = await authenticateVaultRequest(req, vaultConfig, vaultDb);
112
118
  return !("error" in auth);
113
119
  }
114
120
 
@@ -188,7 +194,7 @@ export async function route(
188
194
  /^\/\.well-known\/oauth-protected-resource\/vault\/([^/]+)(?:\/mcp)?$/,
189
195
  );
190
196
  if (protectedResourceInsert) {
191
- const vaultName = protectedResourceInsert[1];
197
+ const vaultName = protectedResourceInsert[1]!;
192
198
  if (!readVaultConfig(vaultName)) {
193
199
  return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
194
200
  }
@@ -198,7 +204,7 @@ export async function route(
198
204
  /^\/\.well-known\/oauth-authorization-server\/vault\/([^/]+)(?:\/mcp)?$/,
199
205
  );
200
206
  if (authServerInsert) {
201
- const vaultName = authServerInsert[1];
207
+ const vaultName = authServerInsert[1]!;
202
208
  if (!readVaultConfig(vaultName)) {
203
209
  return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
204
210
  }
@@ -210,7 +216,7 @@ export async function route(
210
216
  // ---------------------------------------------------------------------
211
217
 
212
218
  if (path === "/health") {
213
- const auth = authenticateGlobalRequest(req);
219
+ const auth = await authenticateGlobalRequest(req);
214
220
  if ("error" in auth) {
215
221
  return Response.json({ status: "ok" });
216
222
  }
@@ -229,9 +235,43 @@ export async function route(
229
235
  return Response.json({ vaults: listVaults() });
230
236
  }
231
237
 
238
+ // Public auth-state probe. Tells a first-contact client whether the
239
+ // server has any vaults yet, which bearer formats it accepts, and
240
+ // whether owner-password / TOTP / tokens are configured. Replaces
241
+ // hub's out-of-process filesystem snoop (parachute-hub#86 follow-up).
242
+ // No auth, GET-only, no token counts — `hasTokens` is a yes/no signal
243
+ // only, with `null` for "DB read failed."
244
+ //
245
+ // Gated on `discovery: disabled` like /vaults/list — both leak vault
246
+ // existence (the `vaults` array names them), so an operator who hides
247
+ // one wants both hidden (#191).
248
+ if (path === "/auth/status" && req.method === "GET") {
249
+ if (readGlobalConfig().discovery === "disabled") {
250
+ return Response.json({ error: "Not found" }, { status: 404 });
251
+ }
252
+ return Response.json(buildAuthStatus(), {
253
+ headers: { "Access-Control-Allow-Origin": "*" },
254
+ });
255
+ }
256
+
257
+ // Admin SPA — per-vault at `/vault/<name>/admin/*` (vault#252). Static-
258
+ // file serving only (index.html + Vite asset bundle); no auth at this
259
+ // seam since the bundle reveals nothing privileged. The SPA's data
260
+ // fetches land on existing per-vault routes that enforce
261
+ // `vault:<name>:read` / `:admin`. GET-only — POSTs to `.../admin/*` aren't
262
+ // a thing the SPA bundle exposes; rejecting them here keeps surprises
263
+ // out. Must fire BEFORE the per-vault dispatch below or the auth wall
264
+ // there would short-circuit the static-asset response.
265
+ if (isAdminSpaPath(path)) {
266
+ if (req.method !== "GET") {
267
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
268
+ }
269
+ return serveAdminSpa(defaultAdminSpaDistDir(), path);
270
+ }
271
+
232
272
  // Authenticated vault metadata list.
233
273
  if (path === "/vaults" && req.method === "GET") {
234
- const auth = authenticateGlobalRequest(req);
274
+ const auth = await authenticateGlobalRequest(req);
235
275
  if ("error" in auth) return auth.error;
236
276
  const names = listVaults();
237
277
  const vaults = names.map((name) => {
@@ -254,7 +294,7 @@ export async function route(
254
294
  return Response.json({ error: "Not found" }, { status: 404 });
255
295
  }
256
296
 
257
- const vaultName = vaultMatch[1];
297
+ const vaultName = vaultMatch[1]!;
258
298
  const subpath = vaultMatch[2] ?? "";
259
299
 
260
300
  const vaultConfig = readVaultConfig(vaultName);
@@ -266,7 +306,7 @@ export async function route(
266
306
  // convenience for published-note URLs that predate the /view/ path).
267
307
  const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
268
308
  if (vaultPublicMatch && req.method === "GET") {
269
- const dest = new URL(`/vault/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
309
+ const dest = new URL(`/vault/${vaultName}/view/${vaultPublicMatch[1]!}`, req.url);
270
310
  dest.search = new URL(req.url).search;
271
311
  return Response.redirect(dest.toString(), 301);
272
312
  }
@@ -277,8 +317,8 @@ export async function route(
277
317
  const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
278
318
  if (vaultViewMatch && req.method === "GET") {
279
319
  const store = getVaultStore(vaultName);
280
- const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
281
- return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]), {
320
+ const authenticated = await isViewAuthenticated(req, vaultConfig, store.db);
321
+ return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]!), {
282
322
  authenticated,
283
323
  publishedTag: vaultConfig.published_tag,
284
324
  });
@@ -308,6 +348,10 @@ export async function route(
308
348
  clientIp,
309
349
  ownerPasswordHash,
310
350
  totpSecret,
351
+ // Per-vault rate-limit instance — prevents brute-force traffic on
352
+ // one vault's consent flow from locking out IPs trying to authorize
353
+ // against an unrelated vault (#93).
354
+ rateLimiter: getAuthorizeRateLimiter(vaultConfig.name),
311
355
  });
312
356
  }
313
357
  return Response.json({ error: "method_not_allowed" }, { status: 405 });
@@ -350,14 +394,14 @@ export async function route(
350
394
  // (worker intervals, TTLs, retention policy) but are still configuration
351
395
  // an attacker could use to map the deployment. `vault:admin` keeps the
352
396
  // hub's loopback workflow intact while locking out read-only tokens.
353
- const configAuth = authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
397
+ const configAuth = await authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
354
398
  if ("error" in configAuth) return configAuth.error;
355
- if (!requireScope(configAuth, SCOPE_ADMIN)) {
399
+ if (!hasScopeForVault(configAuth.scopes, vaultName, "admin")) {
356
400
  return Response.json(
357
401
  {
358
402
  error: "Forbidden",
359
403
  error_type: "insufficient_scope",
360
- message: `This endpoint requires the '${SCOPE_ADMIN}' scope.`,
404
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
361
405
  required_scope: SCOPE_ADMIN,
362
406
  granted_scopes: configAuth.scopes,
363
407
  },
@@ -385,7 +429,7 @@ export async function route(
385
429
  // ---------------------------------------------------------------------
386
430
 
387
431
  const store = getVaultStore(vaultName);
388
- const auth = authenticateVaultRequest(req, vaultConfig, store.db);
432
+ const auth = await authenticateVaultRequest(req, vaultConfig, store.db);
389
433
  const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
390
434
  if ("error" in auth) {
391
435
  return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
@@ -411,6 +455,31 @@ export async function route(
411
455
  });
412
456
  }
413
457
 
458
+ // /tokens — admin-gated REST surface for minting/listing/revoking pvt_*
459
+ // tokens. Admin gate applies to every method (POST/GET/DELETE) since both
460
+ // the plaintext mint and the metadata listing are sensitive — knowing
461
+ // labels and scopes of issued tokens leaks the deployment's auth shape.
462
+ // The handler's POST path applies a strict subset check on requested
463
+ // scopes via `validateMintedScopes` (defense-in-depth: cross-vault and
464
+ // privilege-escalation rejections survive even if this gate is later
465
+ // relaxed).
466
+ const tokensMatch = subpath.match(/^\/tokens(\/.*)?$/);
467
+ if (tokensMatch) {
468
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
469
+ return Response.json(
470
+ {
471
+ error: "Forbidden",
472
+ error_type: "insufficient_scope",
473
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
474
+ required_scope: SCOPE_ADMIN,
475
+ granted_scopes: auth.scopes,
476
+ },
477
+ { status: 403 },
478
+ );
479
+ }
480
+ return handleTokens(req, store, vaultName, auth.scopes, auth.scoped_tags, tokensMatch[1] ?? "");
481
+ }
482
+
414
483
  const apiMatch = subpath.match(/^\/api(\/.*)?$/);
415
484
  if (!apiMatch) {
416
485
  return Response.json({ error: "Not found" }, { status: 404 });
@@ -418,14 +487,17 @@ export async function route(
418
487
 
419
488
  // REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
420
489
  // POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
421
- // is handled inside `requireScope`.
422
- const requiredApiScope = scopeForMethod(req.method);
423
- if (!requireScope(auth, requiredApiScope)) {
490
+ // and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
491
+ // `vault:<vaultName>:<verb>` from hub JWTs) are handled by
492
+ // `hasScopeForVault`.
493
+ const requiredVerb = verbForMethod(req.method);
494
+ if (!hasScopeForVault(auth.scopes, vaultName, requiredVerb)) {
495
+ const requiredApiScope = scopeForMethod(req.method);
424
496
  return Response.json(
425
497
  {
426
498
  error: "Forbidden",
427
499
  error_type: "insufficient_scope",
428
- message: `This endpoint requires the '${requiredApiScope}' scope.`,
500
+ message: `This endpoint requires the '${requiredApiScope}' scope (or '${requiredApiScope.replace("vault:", `vault:${vaultName}:`)}').`,
429
501
  required_scope: requiredApiScope,
430
502
  granted_scopes: auth.scopes,
431
503
  },
@@ -435,9 +507,21 @@ export async function route(
435
507
 
436
508
  const apiPath = apiMatch[1] ?? "";
437
509
 
438
- if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName);
439
- if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
440
- if (apiPath === "/find-path") return handleFindPath(req, store);
510
+ // Tag-scoped tokens (patterns/tag-scoped-tokens.md): expand the token's
511
+ // root-tag allowlist into `{root} ∪ descendants(root)` once per request,
512
+ // so handlers can intersect against the note's tag set without re-walking
513
+ // the `_tags/<name>` hierarchy on every check. `tagScope.allowed` is null
514
+ // for unscoped tokens — the no-op fast path leaves the pre-tag-scope
515
+ // behavior identical.
516
+ const tagScope: TagScopeCtx = {
517
+ allowed: await expandTokenTagScope(store, auth.scoped_tags),
518
+ raw: auth.scoped_tags,
519
+ };
520
+
521
+ if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
522
+ if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
523
+ if (apiPath.startsWith("/note-schemas")) return handleNoteSchemas(req, store, apiPath.slice(13), tagScope);
524
+ if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
441
525
  if (apiPath === "/vault") {
442
526
  return handleVault(req, store, vaultConfig, () => {
443
527
  writeVaultConfig(vaultConfig);
@@ -13,7 +13,10 @@ import {
13
13
  parseScopeFlags,
14
14
  resolveCreateTokenFlags,
15
15
  hasScope,
16
+ hasScopeForVault,
17
+ findBroadVaultScopes,
16
18
  scopeForMethod,
19
+ verbForMethod,
17
20
  legacyPermissionToScopes,
18
21
  serializeScopes,
19
22
  } from "./scopes.ts";
@@ -34,10 +37,10 @@ describe("parseScopes", () => {
34
37
  ]);
35
38
  });
36
39
 
37
- test("collapses vault:<name>:<verb> synonym to vault:<verb>", () => {
38
- expect(parseScopes("vault:journal:read")).toEqual([SCOPE_READ]);
40
+ test("preserves vault:<name>:<verb> narrowed shape verbatim", () => {
41
+ expect(parseScopes("vault:journal:read")).toEqual(["vault:journal:read"]);
39
42
  expect(parseScopes("vault:journal:write vault:work:admin")).toEqual([
40
- SCOPE_WRITE, SCOPE_ADMIN,
43
+ "vault:journal:write", "vault:work:admin",
41
44
  ]);
42
45
  });
43
46
 
@@ -46,12 +49,13 @@ describe("parseScopes", () => {
46
49
  expect(parseScopes("vault:unknown:frob")).toEqual(["vault:unknown:frob"]);
47
50
  });
48
51
 
49
- test("empty name segment does NOT collapse (vault::read stays literal)", () => {
52
+ test("empty name segment is treated as unrecognized (vault::read stays literal)", () => {
50
53
  // Guard against a hand-crafted DB row with `vault::read` satisfying a
51
54
  // `vault:read` check by accident. Only reachable via direct DB write,
52
55
  // not API input, but the parser stays honest.
53
56
  expect(parseScopes("vault::read")).toEqual(["vault::read"]);
54
57
  expect(hasScope(parseScopes("vault::read"), SCOPE_READ)).toBe(false);
58
+ expect(hasScopeForVault(parseScopes("vault::read"), "", "read")).toBe(false);
55
59
  });
56
60
  });
57
61
 
@@ -91,25 +95,79 @@ describe("hasScope — inheritance admin ⊇ write ⊇ read", () => {
91
95
  expect(hasScope(["profile"], "email")).toBe(false);
92
96
  expect(hasScope([SCOPE_ADMIN], "profile")).toBe(false);
93
97
  });
98
+
99
+ test("narrowed grant satisfies broad query (more-specific is at-least-as-strong)", () => {
100
+ expect(hasScope(["vault:work:write"], SCOPE_READ)).toBe(true);
101
+ expect(hasScope(["vault:work:write"], SCOPE_WRITE)).toBe(true);
102
+ expect(hasScope(["vault:work:write"], SCOPE_ADMIN)).toBe(false);
103
+ expect(hasScope(["vault:work:admin"], SCOPE_ADMIN)).toBe(true);
104
+ });
105
+
106
+ test("broad query against narrowed query returns false (use hasScopeForVault for that)", () => {
107
+ // hasScope refuses to answer narrowed queries — they need a vault context.
108
+ expect(hasScope([SCOPE_ADMIN], "vault:work:read")).toBe(false);
109
+ expect(hasScope(["vault:work:write"], "vault:work:read")).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe("hasScopeForVault — per-vault matching with inheritance", () => {
114
+ test("broad grant satisfies any vault (caller pins via DB lookup / aud check)", () => {
115
+ expect(hasScopeForVault([SCOPE_READ], "work", "read")).toBe(true);
116
+ expect(hasScopeForVault([SCOPE_WRITE], "work", "read")).toBe(true);
117
+ expect(hasScopeForVault([SCOPE_WRITE], "work", "write")).toBe(true);
118
+ expect(hasScopeForVault([SCOPE_ADMIN], "anything", "admin")).toBe(true);
119
+ });
120
+
121
+ test("narrowed grant satisfies only the matching vault", () => {
122
+ expect(hasScopeForVault(["vault:work:read"], "work", "read")).toBe(true);
123
+ expect(hasScopeForVault(["vault:work:read"], "personal", "read")).toBe(false);
124
+ expect(hasScopeForVault(["vault:work:write"], "work", "read")).toBe(true);
125
+ expect(hasScopeForVault(["vault:work:admin"], "work", "write")).toBe(true);
126
+ });
127
+
128
+ test("narrowed grant does NOT satisfy a higher verb on its vault", () => {
129
+ expect(hasScopeForVault(["vault:work:read"], "work", "write")).toBe(false);
130
+ expect(hasScopeForVault(["vault:work:write"], "work", "admin")).toBe(false);
131
+ });
132
+
133
+ test("mixed grant — narrowed for one vault, broad fallback nowhere", () => {
134
+ expect(hasScopeForVault(["vault:work:write"], "personal", "read")).toBe(false);
135
+ });
136
+
137
+ test("empty grant fails everywhere", () => {
138
+ expect(hasScopeForVault([], "any", "read")).toBe(false);
139
+ });
140
+ });
141
+
142
+ describe("findBroadVaultScopes", () => {
143
+ test("returns broad vault scopes only", () => {
144
+ expect(findBroadVaultScopes([SCOPE_READ, SCOPE_WRITE])).toEqual([SCOPE_READ, SCOPE_WRITE]);
145
+ expect(findBroadVaultScopes(["vault:work:read"])).toEqual([]);
146
+ expect(findBroadVaultScopes([SCOPE_READ, "vault:work:write", "profile"])).toEqual([SCOPE_READ]);
147
+ expect(findBroadVaultScopes([])).toEqual([]);
148
+ });
94
149
  });
95
150
 
96
- describe("scopeForMethod", () => {
97
- test("read methods → vault:read", () => {
151
+ describe("scopeForMethod / verbForMethod", () => {
152
+ test("read methods → vault:read / read", () => {
98
153
  expect(scopeForMethod("GET")).toBe(SCOPE_READ);
99
154
  expect(scopeForMethod("HEAD")).toBe(SCOPE_READ);
100
155
  expect(scopeForMethod("OPTIONS")).toBe(SCOPE_READ);
101
156
  expect(scopeForMethod("get")).toBe(SCOPE_READ); // case-insensitive
157
+ expect(verbForMethod("GET")).toBe("read");
102
158
  });
103
159
 
104
- test("write methods → vault:write", () => {
160
+ test("write methods → vault:write / write", () => {
105
161
  expect(scopeForMethod("POST")).toBe(SCOPE_WRITE);
106
162
  expect(scopeForMethod("PATCH")).toBe(SCOPE_WRITE);
107
163
  expect(scopeForMethod("PUT")).toBe(SCOPE_WRITE);
108
164
  expect(scopeForMethod("DELETE")).toBe(SCOPE_WRITE);
165
+ expect(verbForMethod("POST")).toBe("write");
109
166
  });
110
167
 
111
- test("unknown method falls back to vault:write (default-deny)", () => {
168
+ test("unknown method falls back to write (default-deny)", () => {
112
169
  expect(scopeForMethod("TRACE")).toBe(SCOPE_WRITE);
170
+ expect(verbForMethod("TRACE")).toBe("write");
113
171
  });
114
172
  });
115
173
 
package/src/scopes.ts CHANGED
@@ -1,11 +1,20 @@
1
1
  /**
2
- * Scope primitives for Phase 2 enforcement.
2
+ * Scope primitives for vault enforcement.
3
3
  *
4
- * Tokens carry OAuth-standard whitespace-separated scopes. This module parses,
5
- * normalizes, and matches them — including the `admin ⊇ write ⊇ read`
6
- * inheritance rule and the `vault:<name>:<verb>` future-shape synonym
7
- * (narrowed per-vault scopes are Phase 2+; today we treat them as equivalent
8
- * to `vault:<verb>`).
4
+ * Tokens carry OAuth-standard whitespace-separated scopes. Two shapes coexist:
5
+ *
6
+ * - **Broad** `vault:<verb>` — used by `pvt_*` tokens, which are vault-pinned
7
+ * by storage (each vault has its own tokens DB; a token only resolves
8
+ * against the vault that minted it).
9
+ * - **Narrowed** `vault:<name>:<verb>` — used by hub-issued JWTs, which are
10
+ * not pinned by storage and so MUST name the resource they grant access
11
+ * to. Hub JWTs carrying broad `vault:<verb>` are rejected at validation
12
+ * (see `authenticateHubJwt`).
13
+ *
14
+ * Inheritance is `admin ⊇ write ⊇ read` for both shapes. `hasScopeForVault`
15
+ * resolves a (vault, verb) request: broad grants satisfy any vault (the
16
+ * caller has already pinned the vault via DB lookup), narrowed grants
17
+ * satisfy only the matching vault.
9
18
  *
10
19
  * Legacy back-compat: tokens without any `vault:*` scope — but with a
11
20
  * 0.2.x-era `permission = "full" | "read"` — are mapped to the appropriate
@@ -21,14 +30,40 @@ export const SCOPE_ADMIN = "vault:admin" as const;
21
30
  export const VAULT_SCOPES = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN] as const;
22
31
  export type VaultScope = (typeof VAULT_SCOPES)[number];
23
32
 
33
+ /** The verb component of a vault scope — `read`, `write`, or `admin`. */
34
+ export type VaultVerb = "read" | "write" | "admin";
35
+
36
+ const VERB_RANK: Record<VaultVerb, number> = { read: 0, write: 1, admin: 2 };
37
+
38
+ function isVerb(s: string): s is VaultVerb {
39
+ return s === "read" || s === "write" || s === "admin";
40
+ }
41
+
24
42
  /**
25
- * Parse a whitespace-separated scope string into a normalized scope list.
43
+ * Decompose a scope string into `{ vault?, verb }` if it's a recognized vault
44
+ * scope; return `null` otherwise. Recognizes both broad (`vault:<verb>`) and
45
+ * narrowed (`vault:<name>:<verb>`) shapes. The empty-name case
46
+ * (`vault::read`) is rejected — a hand-crafted DB row with that shape must
47
+ * not satisfy any vault scope check.
48
+ */
49
+ function decomposeVaultScope(scope: string): { vault: string | null; verb: VaultVerb } | null {
50
+ const parts = scope.split(":");
51
+ if (parts.length === 2 && parts[0] === "vault" && isVerb(parts[1]!)) {
52
+ return { vault: null, verb: parts[1]! as VaultVerb };
53
+ }
54
+ if (parts.length === 3 && parts[0] === "vault" && parts[1]!.length > 0 && isVerb(parts[2]!)) {
55
+ return { vault: parts[1]!, verb: parts[2]! as VaultVerb };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Parse a whitespace-separated scope string into a scope list.
26
62
  *
27
- * Normalization:
28
63
  * - Empty / null → []
29
64
  * - Trim + split on any whitespace
30
- * - `vault:<name>:<verb>` collapses to `vault:<verb>` (per-vault narrowing
31
- * is Phase 2+; today it's treated as a synonym)
65
+ * - Both `vault:<verb>` and `vault:<name>:<verb>` shapes are preserved
66
+ * verbatim; `hasScope` / `hasScopeForVault` decide what each satisfies.
32
67
  * - Unrecognized scopes are preserved as-is (they just won't match anything)
33
68
  */
34
69
  export function parseScopes(raw: string | null | undefined): string[] {
@@ -36,38 +71,65 @@ export function parseScopes(raw: string | null | undefined): string[] {
36
71
  return raw
37
72
  .split(/\s+/)
38
73
  .map((s) => s.trim())
39
- .filter(Boolean)
40
- .map((s) => normalizeScope(s));
41
- }
42
-
43
- function normalizeScope(scope: string): string {
44
- // `vault:<name>:<verb>` → `vault:<verb>` (synonym collapse). Reject an empty
45
- // name segment (`vault::read`) — preserve it as-is so it can't accidentally
46
- // satisfy a `vault:read` check. Only reachable via direct DB write, but the
47
- // one-liner keeps the parser honest.
48
- const parts = scope.split(":");
49
- if (parts.length === 3 && parts[0] === "vault" && parts[1].length > 0) {
50
- const verb = parts[2];
51
- if (verb === "read" || verb === "write" || verb === "admin") {
52
- return `vault:${verb}`;
53
- }
54
- }
55
- return scope;
74
+ .filter(Boolean);
56
75
  }
57
76
 
58
77
  /**
59
- * Return true iff `granted` satisfies `required` under the inheritance rule
60
- * `admin ⊇ write ⊇ read`. Exact-match required for non-vault scopes.
78
+ * Broad-query check: does `granted` satisfy `required` (e.g. `vault:read`)?
79
+ *
80
+ * Used by code paths that don't have a specific vault in hand — JWT claim
81
+ * inspection, MCP tool list filtering inside a session that's already pinned
82
+ * to one vault, the legacy permission-derivation path. For per-request
83
+ * routing where the URL names a vault, prefer `hasScopeForVault`.
84
+ *
85
+ * A `vault:<name>:<verb>` grant DOES satisfy a broad `vault:<verb>` query —
86
+ * narrowed scopes are strictly more specific. The reverse is not true; broad
87
+ * grants do not satisfy narrowed queries via this function.
88
+ *
89
+ * Inheritance `admin ⊇ write ⊇ read` applies in both forms. Non-vault scopes
90
+ * require exact match.
61
91
  */
62
92
  export function hasScope(granted: string[], required: string): boolean {
63
93
  if (granted.includes(required)) return true;
64
94
 
65
- // Inheritance: admin ⊇ write ⊇ read
66
- if (required === SCOPE_READ) {
67
- return granted.includes(SCOPE_WRITE) || granted.includes(SCOPE_ADMIN);
95
+ const requiredDecomposed = decomposeVaultScope(required);
96
+ if (!requiredDecomposed || requiredDecomposed.vault !== null) {
97
+ // Non-vault scope or narrowed query — exact match only via hasScope.
98
+ // (Narrowed queries belong on hasScopeForVault.)
99
+ return false;
68
100
  }
69
- if (required === SCOPE_WRITE) {
70
- return granted.includes(SCOPE_ADMIN);
101
+ const reqRank = VERB_RANK[requiredDecomposed.verb];
102
+ for (const s of granted) {
103
+ const d = decomposeVaultScope(s);
104
+ if (d && VERB_RANK[d.verb] >= reqRank) return true;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /**
110
+ * Per-vault check: does `granted` satisfy a (vault, verb) request? Use this
111
+ * at request-routing time — the URL names the vault and the method picks
112
+ * the verb.
113
+ *
114
+ * Match rules:
115
+ * - Broad `vault:<verb>` in granted satisfies any vault (the broad scope
116
+ * has no resource constraint; the caller pins the vault upstream — pvt_*
117
+ * resolves only against its issuing vault's DB, hub JWTs reject broad
118
+ * scopes at validation).
119
+ * - Narrowed `vault:<name>:<verb>` satisfies only the matching `vaultName`.
120
+ * - Verb inheritance `admin ⊇ write ⊇ read` applies in both forms.
121
+ */
122
+ export function hasScopeForVault(
123
+ granted: string[],
124
+ vaultName: string,
125
+ requiredVerb: VaultVerb,
126
+ ): boolean {
127
+ const reqRank = VERB_RANK[requiredVerb];
128
+ for (const s of granted) {
129
+ const d = decomposeVaultScope(s);
130
+ if (!d) continue;
131
+ if (d.vault !== null && d.vault !== vaultName) continue;
132
+ if (VERB_RANK[d.verb] >= reqRank) return true;
71
133
  }
72
134
  return false;
73
135
  }
@@ -78,12 +140,76 @@ export function hasScope(granted: string[], required: string): boolean {
78
140
  * - POST/PATCH/PUT/DELETE → write
79
141
  *
80
142
  * Admin-gated endpoints (like `/.parachute/config`) don't go through this
81
- * helper — they call `hasScope(auth.scopes, SCOPE_ADMIN)` directly.
143
+ * helper — they call `hasScopeForVault(auth.scopes, vaultName, "admin")`
144
+ * directly.
82
145
  */
83
146
  export function scopeForMethod(method: string): VaultScope {
147
+ return verbForMethod(method) === "read" ? SCOPE_READ : SCOPE_WRITE;
148
+ }
149
+
150
+ /** Verb-only variant of `scopeForMethod`, for use with `hasScopeForVault`. */
151
+ export function verbForMethod(method: string): VaultVerb {
84
152
  const m = method.toUpperCase();
85
- if (m === "GET" || m === "HEAD" || m === "OPTIONS") return SCOPE_READ;
86
- return SCOPE_WRITE;
153
+ if (m === "GET" || m === "HEAD" || m === "OPTIONS") return "read";
154
+ return "write";
155
+ }
156
+
157
+ /**
158
+ * Validate scopes requested for token minting on a specific vault.
159
+ *
160
+ * Each requested scope must be (a) a recognized vault scope shape, (b) not
161
+ * naming a different vault (cross-vault rejected), and (c) within the
162
+ * caller's verb power on `vaultName`. The third check is defense-in-depth:
163
+ * the REST endpoint already gates on `vault:admin`, but enforcing subset
164
+ * here means a future loosening of the gate (or a partially-trusted caller)
165
+ * still cannot mint a token stronger than what they hold.
166
+ *
167
+ * Pass-through on success — we don't rewrite scopes, just decide yes/no.
168
+ */
169
+ export function validateMintedScopes(
170
+ requested: string[],
171
+ vaultName: string,
172
+ callerScopes: string[],
173
+ ): { ok: true } | { ok: false; rejected: { scope: string; reason: string }[] } {
174
+ const rejected: { scope: string; reason: string }[] = [];
175
+ for (const s of requested) {
176
+ const d = decomposeVaultScope(s);
177
+ if (!d) {
178
+ rejected.push({ scope: s, reason: "unknown or unsupported scope" });
179
+ continue;
180
+ }
181
+ if (d.vault !== null && d.vault !== vaultName) {
182
+ rejected.push({
183
+ scope: s,
184
+ reason: `cross-vault scope not allowed (this endpoint mints for vault '${vaultName}')`,
185
+ });
186
+ continue;
187
+ }
188
+ if (!hasScopeForVault(callerScopes, vaultName, d.verb)) {
189
+ rejected.push({
190
+ scope: s,
191
+ reason: `caller lacks '${d.verb}' on vault '${vaultName}' — cannot grant a stronger scope than held`,
192
+ });
193
+ continue;
194
+ }
195
+ }
196
+ if (rejected.length > 0) return { ok: false, rejected };
197
+ return { ok: true };
198
+ }
199
+
200
+ /**
201
+ * Detect a broad `vault:<verb>` scope in a granted list. Hub-issued JWTs
202
+ * must NOT carry broad vault scopes — the hub mints `vault:<name>:<verb>` so
203
+ * the resource is named on the wire. `authenticateHubJwt` calls this to
204
+ * reject tokens that slipped through with the old shape.
205
+ */
206
+ export function findBroadVaultScopes(granted: string[]): string[] {
207
+ const out: string[] = [];
208
+ for (const s of granted) {
209
+ const d = decomposeVaultScope(s);
210
+ if (d && d.vault === null) out.push(s);
211
+ }
212
+ return out;
87
213
  }
88
214
 
89
215
  /**