@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  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 +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/routing.ts CHANGED
@@ -40,11 +40,11 @@ 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,
@@ -53,7 +53,10 @@ import {
53
53
  handleUnresolvedWikilinks,
54
54
  handleStorage,
55
55
  handleViewNote,
56
+ type TagScopeCtx,
56
57
  } from "./routes.ts";
58
+ import { expandTokenTagScope } from "./tag-scope.ts";
59
+ import { handleTokens } from "./tokens-routes.ts";
57
60
  import {
58
61
  handleProtectedResource,
59
62
  handleAuthorizationServer,
@@ -64,6 +67,8 @@ import {
64
67
  getBaseUrl,
65
68
  } from "./oauth.ts";
66
69
  import { handleConfigSchema, handleConfig } from "./module-config.ts";
70
+ import { buildAuthStatus } from "./auth-status.ts";
71
+ import { getAuthorizeRateLimiter } from "./owner-auth.ts";
67
72
 
68
73
  /**
69
74
  * Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
@@ -100,15 +105,15 @@ async function withMcpChallenge(
100
105
  * Returns true if authenticated, false if not. Never rejects — unauthenticated
101
106
  * requests still get public notes.
102
107
  */
103
- function isViewAuthenticated(
108
+ async function isViewAuthenticated(
104
109
  req: Request,
105
110
  vaultConfig: VaultConfig | null,
106
111
  vaultDb?: import("bun:sqlite").Database,
107
- ): boolean {
112
+ ): Promise<boolean> {
108
113
  if (!vaultConfig) return false;
109
114
  const key = extractApiKey(req);
110
115
  if (!key) return false;
111
- const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
116
+ const auth = await authenticateVaultRequest(req, vaultConfig, vaultDb);
112
117
  return !("error" in auth);
113
118
  }
114
119
 
@@ -188,7 +193,7 @@ export async function route(
188
193
  /^\/\.well-known\/oauth-protected-resource\/vault\/([^/]+)(?:\/mcp)?$/,
189
194
  );
190
195
  if (protectedResourceInsert) {
191
- const vaultName = protectedResourceInsert[1];
196
+ const vaultName = protectedResourceInsert[1]!;
192
197
  if (!readVaultConfig(vaultName)) {
193
198
  return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
194
199
  }
@@ -198,7 +203,7 @@ export async function route(
198
203
  /^\/\.well-known\/oauth-authorization-server\/vault\/([^/]+)(?:\/mcp)?$/,
199
204
  );
200
205
  if (authServerInsert) {
201
- const vaultName = authServerInsert[1];
206
+ const vaultName = authServerInsert[1]!;
202
207
  if (!readVaultConfig(vaultName)) {
203
208
  return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
204
209
  }
@@ -210,7 +215,7 @@ export async function route(
210
215
  // ---------------------------------------------------------------------
211
216
 
212
217
  if (path === "/health") {
213
- const auth = authenticateGlobalRequest(req);
218
+ const auth = await authenticateGlobalRequest(req);
214
219
  if ("error" in auth) {
215
220
  return Response.json({ status: "ok" });
216
221
  }
@@ -229,9 +234,43 @@ export async function route(
229
234
  return Response.json({ vaults: listVaults() });
230
235
  }
231
236
 
237
+ // Public auth-state probe. Tells a first-contact client whether the
238
+ // server has any vaults yet, which bearer formats it accepts, and
239
+ // whether owner-password / TOTP / tokens are configured. Replaces
240
+ // hub's out-of-process filesystem snoop (parachute-hub#86 follow-up).
241
+ // No auth, GET-only, no token counts — `hasTokens` is a yes/no signal
242
+ // only, with `null` for "DB read failed."
243
+ //
244
+ // Gated on `discovery: disabled` like /vaults/list — both leak vault
245
+ // existence (the `vaults` array names them), so an operator who hides
246
+ // one wants both hidden (#191).
247
+ if (path === "/auth/status" && req.method === "GET") {
248
+ if (readGlobalConfig().discovery === "disabled") {
249
+ return Response.json({ error: "Not found" }, { status: 404 });
250
+ }
251
+ return Response.json(buildAuthStatus(), {
252
+ headers: { "Access-Control-Allow-Origin": "*" },
253
+ });
254
+ }
255
+
256
+ // Admin SPA — per-vault at `/vault/<name>/admin/*` (vault#252). Static-
257
+ // file serving only (index.html + Vite asset bundle); no auth at this
258
+ // seam since the bundle reveals nothing privileged. The SPA's data
259
+ // fetches land on existing per-vault routes that enforce
260
+ // `vault:<name>:read` / `:admin`. GET-only — POSTs to `.../admin/*` aren't
261
+ // a thing the SPA bundle exposes; rejecting them here keeps surprises
262
+ // out. Must fire BEFORE the per-vault dispatch below or the auth wall
263
+ // there would short-circuit the static-asset response.
264
+ if (isAdminSpaPath(path)) {
265
+ if (req.method !== "GET") {
266
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
267
+ }
268
+ return serveAdminSpa(defaultAdminSpaDistDir(), path);
269
+ }
270
+
232
271
  // Authenticated vault metadata list.
233
272
  if (path === "/vaults" && req.method === "GET") {
234
- const auth = authenticateGlobalRequest(req);
273
+ const auth = await authenticateGlobalRequest(req);
235
274
  if ("error" in auth) return auth.error;
236
275
  const names = listVaults();
237
276
  const vaults = names.map((name) => {
@@ -254,7 +293,7 @@ export async function route(
254
293
  return Response.json({ error: "Not found" }, { status: 404 });
255
294
  }
256
295
 
257
- const vaultName = vaultMatch[1];
296
+ const vaultName = vaultMatch[1]!;
258
297
  const subpath = vaultMatch[2] ?? "";
259
298
 
260
299
  const vaultConfig = readVaultConfig(vaultName);
@@ -266,7 +305,7 @@ export async function route(
266
305
  // convenience for published-note URLs that predate the /view/ path).
267
306
  const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
268
307
  if (vaultPublicMatch && req.method === "GET") {
269
- const dest = new URL(`/vault/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
308
+ const dest = new URL(`/vault/${vaultName}/view/${vaultPublicMatch[1]!}`, req.url);
270
309
  dest.search = new URL(req.url).search;
271
310
  return Response.redirect(dest.toString(), 301);
272
311
  }
@@ -277,8 +316,8 @@ export async function route(
277
316
  const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
278
317
  if (vaultViewMatch && req.method === "GET") {
279
318
  const store = getVaultStore(vaultName);
280
- const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
281
- return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]), {
319
+ const authenticated = await isViewAuthenticated(req, vaultConfig, store.db);
320
+ return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]!), {
282
321
  authenticated,
283
322
  publishedTag: vaultConfig.published_tag,
284
323
  });
@@ -308,6 +347,10 @@ export async function route(
308
347
  clientIp,
309
348
  ownerPasswordHash,
310
349
  totpSecret,
350
+ // Per-vault rate-limit instance — prevents brute-force traffic on
351
+ // one vault's consent flow from locking out IPs trying to authorize
352
+ // against an unrelated vault (#93).
353
+ rateLimiter: getAuthorizeRateLimiter(vaultConfig.name),
311
354
  });
312
355
  }
313
356
  return Response.json({ error: "method_not_allowed" }, { status: 405 });
@@ -350,14 +393,14 @@ export async function route(
350
393
  // (worker intervals, TTLs, retention policy) but are still configuration
351
394
  // an attacker could use to map the deployment. `vault:admin` keeps the
352
395
  // hub's loopback workflow intact while locking out read-only tokens.
353
- const configAuth = authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
396
+ const configAuth = await authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
354
397
  if ("error" in configAuth) return configAuth.error;
355
- if (!requireScope(configAuth, SCOPE_ADMIN)) {
398
+ if (!hasScopeForVault(configAuth.scopes, vaultName, "admin")) {
356
399
  return Response.json(
357
400
  {
358
401
  error: "Forbidden",
359
402
  error_type: "insufficient_scope",
360
- message: `This endpoint requires the '${SCOPE_ADMIN}' scope.`,
403
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
361
404
  required_scope: SCOPE_ADMIN,
362
405
  granted_scopes: configAuth.scopes,
363
406
  },
@@ -385,7 +428,7 @@ export async function route(
385
428
  // ---------------------------------------------------------------------
386
429
 
387
430
  const store = getVaultStore(vaultName);
388
- const auth = authenticateVaultRequest(req, vaultConfig, store.db);
431
+ const auth = await authenticateVaultRequest(req, vaultConfig, store.db);
389
432
  const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
390
433
  if ("error" in auth) {
391
434
  return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
@@ -411,6 +454,31 @@ export async function route(
411
454
  });
412
455
  }
413
456
 
457
+ // /tokens — admin-gated REST surface for minting/listing/revoking pvt_*
458
+ // tokens. Admin gate applies to every method (POST/GET/DELETE) since both
459
+ // the plaintext mint and the metadata listing are sensitive — knowing
460
+ // labels and scopes of issued tokens leaks the deployment's auth shape.
461
+ // The handler's POST path applies a strict subset check on requested
462
+ // scopes via `validateMintedScopes` (defense-in-depth: cross-vault and
463
+ // privilege-escalation rejections survive even if this gate is later
464
+ // relaxed).
465
+ const tokensMatch = subpath.match(/^\/tokens(\/.*)?$/);
466
+ if (tokensMatch) {
467
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
468
+ return Response.json(
469
+ {
470
+ error: "Forbidden",
471
+ error_type: "insufficient_scope",
472
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
473
+ required_scope: SCOPE_ADMIN,
474
+ granted_scopes: auth.scopes,
475
+ },
476
+ { status: 403 },
477
+ );
478
+ }
479
+ return handleTokens(req, store, vaultName, auth.scopes, auth.scoped_tags, tokensMatch[1] ?? "");
480
+ }
481
+
414
482
  const apiMatch = subpath.match(/^\/api(\/.*)?$/);
415
483
  if (!apiMatch) {
416
484
  return Response.json({ error: "Not found" }, { status: 404 });
@@ -418,14 +486,17 @@ export async function route(
418
486
 
419
487
  // REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
420
488
  // 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)) {
489
+ // and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
490
+ // `vault:<vaultName>:<verb>` from hub JWTs) are handled by
491
+ // `hasScopeForVault`.
492
+ const requiredVerb = verbForMethod(req.method);
493
+ if (!hasScopeForVault(auth.scopes, vaultName, requiredVerb)) {
494
+ const requiredApiScope = scopeForMethod(req.method);
424
495
  return Response.json(
425
496
  {
426
497
  error: "Forbidden",
427
498
  error_type: "insufficient_scope",
428
- message: `This endpoint requires the '${requiredApiScope}' scope.`,
499
+ message: `This endpoint requires the '${requiredApiScope}' scope (or '${requiredApiScope.replace("vault:", `vault:${vaultName}:`)}').`,
429
500
  required_scope: requiredApiScope,
430
501
  granted_scopes: auth.scopes,
431
502
  },
@@ -435,9 +506,20 @@ export async function route(
435
506
 
436
507
  const apiPath = apiMatch[1] ?? "";
437
508
 
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);
509
+ // Tag-scoped tokens (patterns/tag-scoped-tokens.md): expand the token's
510
+ // root-tag allowlist into `{root} ∪ descendants(root)` once per request,
511
+ // so handlers can intersect against the note's tag set without re-walking
512
+ // the `_tags/<name>` hierarchy on every check. `tagScope.allowed` is null
513
+ // for unscoped tokens — the no-op fast path leaves the pre-tag-scope
514
+ // behavior identical.
515
+ const tagScope: TagScopeCtx = {
516
+ allowed: await expandTokenTagScope(store, auth.scoped_tags),
517
+ raw: auth.scoped_tags,
518
+ };
519
+
520
+ if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
521
+ if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
522
+ if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
441
523
  if (apiPath === "/vault") {
442
524
  return handleVault(req, store, vaultConfig, () => {
443
525
  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
  /**
package/src/server.ts CHANGED
@@ -15,7 +15,8 @@
15
15
  * The request pipeline lives in ./routing.ts (exported for unit testing).
16
16
  */
17
17
 
18
- import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey } from "./config.ts";
18
+ import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath } from "./config.ts";
19
+ import { existsSync, rmSync } from "fs";
19
20
  import { migrateVaultKeys } from "./token-store.ts";
20
21
  import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
21
22
  import { defaultHookRegistry } from "../core/src/hooks.ts";
@@ -198,7 +199,7 @@ const server = Bun.serve({
198
199
  const trustProxy = process.env.TRUST_PROXY === "1" || process.env.TRUST_PROXY === "true";
199
200
  const forwardedFor = trustProxy ? req.headers.get("x-forwarded-for") : null;
200
201
  const clientIp = forwardedFor
201
- ? forwardedFor.split(",")[0].trim()
202
+ ? forwardedFor.split(",")[0]!.trim()
202
203
  : server.requestIP(req)?.address;
203
204
 
204
205
  try {
@@ -241,3 +242,24 @@ async function shutdown(signal: string): Promise<void> {
241
242
  process.on("SIGINT", () => void shutdown("SIGINT"));
242
243
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
243
244
 
245
+ // Filesystem-sentinel shutdown: `parachute-vault stop` writes
246
+ // `~/.parachute/vault/stop.signal`; we poll for it and exit cleanly when it
247
+ // appears. Useful when no signal channel is available — Docker exec, foreground
248
+ // runs without a TTY, scripts that can't manage PIDs. Any stale sentinel is
249
+ // removed before we start polling so a previous `stop` can't pre-empt this boot.
250
+ const STOP_POLL_MS = 500;
251
+ try {
252
+ if (existsSync(stopSignalPath())) rmSync(stopSignalPath(), { force: true });
253
+ } catch (err) {
254
+ console.warn("[stop-signal] could not clear stale sentinel:", err);
255
+ }
256
+ setInterval(() => {
257
+ if (!existsSync(stopSignalPath())) return;
258
+ try {
259
+ rmSync(stopSignalPath(), { force: true });
260
+ } catch (err) {
261
+ console.warn("[stop-signal] could not remove sentinel:", err);
262
+ }
263
+ void shutdown("STOP_SIGNAL");
264
+ }, STOP_POLL_MS).unref();
265
+