@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/auth.test.ts CHANGED
@@ -83,7 +83,7 @@ function bearer(token: string): Request {
83
83
  }
84
84
 
85
85
  describe("auth — per-vault routing", () => {
86
- test("token minted in a vault authenticates at its own /vault/<name>/* endpoints", () => {
86
+ test("token minted in a vault authenticates at its own /vault/<name>/* endpoints", async () => {
87
87
  seedVault("journal");
88
88
  const token = mintTokenInVault("journal");
89
89
  const journalConfig = readVaultConfig("journal")!;
@@ -91,44 +91,44 @@ describe("auth — per-vault routing", () => {
91
91
 
92
92
  // /vault/journal/api/* and /vault/journal/mcp both funnel into
93
93
  // authenticateVaultRequest with journal's config + DB.
94
- const vaultAuth = authenticateVaultRequest(bearer(token), journalConfig, journalStore.db);
94
+ const vaultAuth = await authenticateVaultRequest(bearer(token), journalConfig, journalStore.db);
95
95
  expect("error" in vaultAuth).toBe(false);
96
96
  if (!("error" in vaultAuth)) expect(vaultAuth.permission).toBe("full");
97
97
 
98
98
  // /vaults (global metadata listing) uses authenticateGlobalRequest which
99
99
  // scans every vault's DB. Since the token is in journal's DB, it must resolve.
100
- const global = authenticateGlobalRequest(bearer(token));
100
+ const global = await authenticateGlobalRequest(bearer(token));
101
101
  expect("error" in global).toBe(false);
102
102
  });
103
103
 
104
104
  // HTTP-level routing stand-in. Mirrors routing.ts: every vault-scoped path
105
105
  // matches `/vault/<name>/...`, we look the vault up, then authenticate the
106
106
  // request against that vault's DB.
107
- function dispatchAuthFromPath(path: string, req: Request): {
107
+ async function dispatchAuthFromPath(path: string, req: Request): Promise<{
108
108
  status: number;
109
109
  permission?: string;
110
- } {
110
+ }> {
111
111
  const match = path.match(/^\/vault\/([^/]+)(\/.*)?$/);
112
112
  if (!match) return { status: 404 };
113
- const vaultName = match[1];
113
+ const vaultName = match[1]!;
114
114
  const vaultConfig = readVaultConfig(vaultName);
115
115
  if (!vaultConfig) return { status: 404 };
116
116
  const store = getVaultStore(vaultName);
117
- const res = authenticateVaultRequest(req, vaultConfig, store.db);
117
+ const res = await authenticateVaultRequest(req, vaultConfig, store.db);
118
118
  if ("error" in res) return { status: res.error.status };
119
119
  return { status: 200, permission: res.permission };
120
120
  }
121
121
 
122
- test("routing: /vault/<name>/api/health accepts a token minted in that vault", () => {
122
+ test("routing: /vault/<name>/api/health accepts a token minted in that vault", async () => {
123
123
  seedVault("journal");
124
124
  const token = mintTokenInVault("journal");
125
125
 
126
- const result = dispatchAuthFromPath("/vault/journal/api/health", bearer(token));
126
+ const result = await dispatchAuthFromPath("/vault/journal/api/health", bearer(token));
127
127
  expect(result.status).toBe(200);
128
128
  expect(result.permission).toBe("full");
129
129
  });
130
130
 
131
- test("routing: /vault/A/api/* rejects a token issued for vault B", () => {
131
+ test("routing: /vault/A/api/* rejects a token issued for vault B", async () => {
132
132
  // The privilege-escalation barrier: a valid token for vault A must not
133
133
  // authenticate at vault B's endpoint, even though the token is valid
134
134
  // for some vault. This is the point of per-vault DBs.
@@ -136,44 +136,103 @@ describe("auth — per-vault routing", () => {
136
136
  seedVault("work");
137
137
  const workToken = mintTokenInVault("work");
138
138
 
139
- const crossVault = dispatchAuthFromPath("/vault/journal/api/health", bearer(workToken));
139
+ const crossVault = await dispatchAuthFromPath("/vault/journal/api/health", bearer(workToken));
140
140
  expect(crossVault.status).toBe(401);
141
141
  });
142
142
 
143
- test("routing: /vault/<unknown> returns 404 (not 401)", () => {
143
+ test("routing: /vault/<unknown> returns 404 (not 401)", async () => {
144
144
  seedVault("journal");
145
145
  const token = mintTokenInVault("journal");
146
- const result = dispatchAuthFromPath("/vault/nonexistent/api/health", bearer(token));
146
+ const result = await dispatchAuthFromPath("/vault/nonexistent/api/health", bearer(token));
147
147
  expect(result.status).toBe(404);
148
148
  });
149
149
  });
150
150
 
151
151
  describe("auth — cross-vault isolation", () => {
152
- test("token minted in a non-default vault authenticates via scoped and global paths", () => {
152
+ test("token minted in a non-default vault authenticates via scoped and global paths", async () => {
153
153
  seedVault("journal", { isDefault: true });
154
154
  seedVault("work");
155
155
  const workToken = mintTokenInVault("work");
156
156
  const workConfig = readVaultConfig("work")!;
157
157
  const workStore = getVaultStore("work");
158
158
 
159
- const scoped = authenticateVaultRequest(bearer(workToken), workConfig, workStore.db);
159
+ const scoped = await authenticateVaultRequest(bearer(workToken), workConfig, workStore.db);
160
160
  expect("error" in scoped).toBe(false);
161
161
 
162
162
  // Global auth scans every vault, must find the token in work's DB.
163
- const global = authenticateGlobalRequest(bearer(workToken));
163
+ const global = await authenticateGlobalRequest(bearer(workToken));
164
164
  expect("error" in global).toBe(false);
165
165
  });
166
166
 
167
- test("a work-vault token does NOT authenticate against the journal vault", () => {
167
+ test("a work-vault token does NOT authenticate against the journal vault", async () => {
168
168
  seedVault("journal", { isDefault: true });
169
169
  seedVault("work");
170
170
  const workToken = mintTokenInVault("work");
171
171
  const journalConfig = readVaultConfig("journal")!;
172
172
  const journalStore = getVaultStore("journal");
173
173
 
174
- const res = authenticateVaultRequest(bearer(workToken), journalConfig, journalStore.db);
174
+ const res = await authenticateVaultRequest(bearer(workToken), journalConfig, journalStore.db);
175
175
  expect("error" in res).toBe(true);
176
176
  });
177
+
178
+ test("v16 vault_name binding rejects with 403 when the row is cross-vault", async () => {
179
+ // Per-vault DB scoping is the first line of defense (a token only
180
+ // resolves against the DB it was minted in). The v16 vault_name column
181
+ // is defense-in-depth: if a token row somehow lives in vault A's DB
182
+ // but its vault_name says "B" (e.g. an out-of-band copy, or a future
183
+ // mistake in the mint path), the binding mismatch must reject. We
184
+ // simulate that by minting into journal's DB with vault_name="work".
185
+ seedVault("journal", { isDefault: true });
186
+ seedVault("work");
187
+ const journalStore = getVaultStore("journal");
188
+ const { fullToken } = generateToken();
189
+ createToken(journalStore.db, fullToken, {
190
+ label: "mis-bound",
191
+ permission: "full",
192
+ vault_name: "work",
193
+ });
194
+ const journalConfig = readVaultConfig("journal")!;
195
+
196
+ const res = await authenticateVaultRequest(bearer(fullToken), journalConfig, journalStore.db);
197
+ expect("error" in res).toBe(true);
198
+ if ("error" in res) {
199
+ expect(res.error.status).toBe(403);
200
+ }
201
+ });
202
+
203
+ test("v16 vault_name binding accepts when token is bound to the requested vault", async () => {
204
+ seedVault("work");
205
+ const workStore = getVaultStore("work");
206
+ const { fullToken } = generateToken();
207
+ createToken(workStore.db, fullToken, {
208
+ label: "bound",
209
+ permission: "full",
210
+ vault_name: "work",
211
+ });
212
+ const workConfig = readVaultConfig("work")!;
213
+
214
+ const res = await authenticateVaultRequest(bearer(fullToken), workConfig, workStore.db);
215
+ expect("error" in res).toBe(false);
216
+ if (!("error" in res)) {
217
+ expect(res.vault_name).toBe("work");
218
+ }
219
+ });
220
+
221
+ test("legacy NULL-bound tokens still authenticate against any vault", async () => {
222
+ // Backwards compatibility: pre-v16 tokens (and legacy YAML keys, and
223
+ // hub JWTs) all carry vault_name = null. They keep working at any
224
+ // vault — the migration is lenient by design.
225
+ seedVault("work");
226
+ const workToken = mintTokenInVault("work");
227
+ const workConfig = readVaultConfig("work")!;
228
+ const workStore = getVaultStore("work");
229
+
230
+ const res = await authenticateVaultRequest(bearer(workToken), workConfig, workStore.db);
231
+ expect("error" in res).toBe(false);
232
+ if (!("error" in res)) {
233
+ expect(res.vault_name).toBeNull();
234
+ }
235
+ });
177
236
  });
178
237
 
179
238
  // ---------------------------------------------------------------------------
@@ -256,11 +315,11 @@ describe("OAuth-minted tokens — per-vault coherence", () => {
256
315
  const store = getVaultStore("journal");
257
316
 
258
317
  // /vault/journal/api/* and /vault/journal/mcp both reach this auth call.
259
- const vaultAuth = authenticateVaultRequest(bearer(token), cfg, store.db);
318
+ const vaultAuth = await authenticateVaultRequest(bearer(token), cfg, store.db);
260
319
  expect("error" in vaultAuth).toBe(false);
261
320
 
262
321
  // /vaults (authenticated listing) uses authenticateGlobalRequest.
263
- const global = authenticateGlobalRequest(bearer(token));
322
+ const global = await authenticateGlobalRequest(bearer(token));
264
323
  expect("error" in global).toBe(false);
265
324
  });
266
325
 
@@ -272,17 +331,70 @@ describe("OAuth-minted tokens — per-vault coherence", () => {
272
331
  const workStore = getVaultStore("work");
273
332
 
274
333
  // Valid at work's own endpoints.
275
- const scoped = authenticateVaultRequest(bearer(token), workCfg, workStore.db);
334
+ const scoped = await authenticateVaultRequest(bearer(token), workCfg, workStore.db);
276
335
  expect("error" in scoped).toBe(false);
277
336
 
278
337
  // Global auth finds the token in work's DB.
279
- const global = authenticateGlobalRequest(bearer(token));
338
+ const global = await authenticateGlobalRequest(bearer(token));
280
339
  expect("error" in global).toBe(false);
281
340
 
282
341
  // Isolation: the token is NOT usable against the journal vault.
283
342
  const journalCfg = readVaultConfig("journal")!;
284
343
  const journalStore = getVaultStore("journal");
285
- const crossCheck = authenticateVaultRequest(bearer(token), journalCfg, journalStore.db);
344
+ const crossCheck = await authenticateVaultRequest(bearer(token), journalCfg, journalStore.db);
286
345
  expect("error" in crossCheck).toBe(true);
287
346
  });
288
347
  });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Legacy YAML global keys — scope must round-trip through the parser
351
+ // ---------------------------------------------------------------------------
352
+
353
+ describe("auth — legacy global YAML keys honor declared scope", () => {
354
+ test("scope: read on a global config.yaml key resolves to read permission, not full", async () => {
355
+ // Regression: the global parser used to drop the `scope` field, leaving
356
+ // `globalKey.scope` undefined. The auth check `=== "read"` then resolved
357
+ // any undefined value to "full", silently escalating read-only keys.
358
+ const { fullKey, keyId } = generateApiKey();
359
+ writeGlobalConfig({
360
+ port: 1940,
361
+ api_keys: [
362
+ {
363
+ id: keyId,
364
+ label: "reader",
365
+ scope: "read",
366
+ key_hash: hashKey(fullKey),
367
+ created_at: new Date().toISOString(),
368
+ },
369
+ ],
370
+ });
371
+
372
+ const result = await authenticateGlobalRequest(bearer(fullKey));
373
+ expect("error" in result).toBe(false);
374
+ if (!("error" in result)) {
375
+ expect(result.permission).toBe("read");
376
+ }
377
+ });
378
+
379
+ test("scope: write on a global config.yaml key resolves to full permission", async () => {
380
+ const { fullKey, keyId } = generateApiKey();
381
+ writeGlobalConfig({
382
+ port: 1940,
383
+ api_keys: [
384
+ {
385
+ id: keyId,
386
+ label: "writer",
387
+ scope: "write",
388
+ key_hash: hashKey(fullKey),
389
+ created_at: new Date().toISOString(),
390
+ },
391
+ ],
392
+ });
393
+
394
+ const result = await authenticateGlobalRequest(bearer(fullKey));
395
+ expect("error" in result).toBe(false);
396
+ if (!("error" in result)) {
397
+ expect(result.permission).toBe("full");
398
+ }
399
+ });
400
+ });
package/src/auth.ts CHANGED
@@ -21,7 +21,16 @@ import { resolveToken } from "./token-store.ts";
21
21
  import type { TokenPermission } from "./token-store.ts";
22
22
  import type { Database } from "bun:sqlite";
23
23
  import { getVaultStore } from "./vault-store.ts";
24
- import { hasScope, legacyPermissionToScopes, SCOPE_ADMIN, SCOPE_READ, SCOPE_WRITE } from "./scopes.ts";
24
+ import {
25
+ findBroadVaultScopes,
26
+ hasScope,
27
+ hasScopeForVault,
28
+ legacyPermissionToScopes,
29
+ SCOPE_ADMIN,
30
+ SCOPE_READ,
31
+ SCOPE_WRITE,
32
+ } from "./scopes.ts";
33
+ import { HubJwtError, looksLikeJwt, validateHubJwt } from "./hub-jwt.ts";
25
34
 
26
35
  /** Result of a successful auth check. */
27
36
  export interface AuthResult {
@@ -34,6 +43,21 @@ export interface AuthResult {
34
43
  * one-time deprecation warning when they encounter `legacyDerived: true`.
35
44
  */
36
45
  legacyDerived: boolean;
46
+ /**
47
+ * Tag-allowlist (root tag names) for tag-scoped tokens. NULL = unscoped
48
+ * (current full-vault behavior). Hub-issued JWTs and legacy YAML keys
49
+ * always have null — tag scoping is a per-token vault-DB attribute, not
50
+ * an OAuth-claim concern. See patterns/tag-scoped-tokens.md.
51
+ */
52
+ scoped_tags: string[] | null;
53
+ /**
54
+ * Per-vault binding (v16). Non-null = token can only authenticate against
55
+ * this vault. authenticateVaultRequest enforces the match before this
56
+ * result returns; callers downstream can read it for additional scoping
57
+ * (e.g. cross-vault filtering at the unified /mcp endpoint). NULL =
58
+ * legacy / server-wide / hub JWT — no per-vault binding. See vault#257.
59
+ */
60
+ vault_name: string | null;
37
61
  }
38
62
 
39
63
  /**
@@ -46,17 +70,11 @@ function legacyAuthResult(permission: TokenPermission): AuthResult {
46
70
  permission,
47
71
  scopes: legacyPermissionToScopes(permission),
48
72
  legacyDerived: true,
73
+ scoped_tags: null,
74
+ vault_name: null,
49
75
  };
50
76
  }
51
77
 
52
- /**
53
- * Guard: does the authenticated request carry the required scope?
54
- * Uses `hasScope` inheritance: admin ⊇ write ⊇ read.
55
- */
56
- export function requireScope(auth: AuthResult, required: string): boolean {
57
- return hasScope(auth.scopes, required);
58
- }
59
-
60
78
  // One-shot deprecation warning tracker, keyed by token hash / legacy label so
61
79
  // we don't spam the log on every request.
62
80
  const warnedLegacyTokens = new Set<string>();
@@ -128,23 +146,59 @@ function validateKey(keys: StoredKey[], providedKey: string): StoredKey | null {
128
146
 
129
147
  /**
130
148
  * Authenticate for a specific vault.
131
- * Checks the vault's token DB first, then falls back to legacy YAML keys.
149
+ *
150
+ * Token shape decides the path:
151
+ * - JWT-shaped (`eyJ…`) → validate against the hub's JWKS. JWT-shaped tokens
152
+ * commit to JWT validation; we don't fall through to `pvt_*` lookup on
153
+ * failure, since a malformed JWT was never going to be a valid local
154
+ * token anyway.
155
+ * - Anything else → try the vault's token DB, then legacy YAML keys.
156
+ *
157
+ * Dual-validate window: both paths are live during this release cycle so
158
+ * existing `pvt_*` callers continue to work. A follow-up issue retires the
159
+ * legacy path.
132
160
  */
133
- export function authenticateVaultRequest(
161
+ export async function authenticateVaultRequest(
134
162
  req: Request,
135
163
  vaultConfig: VaultConfig,
136
164
  vaultDb?: Database,
137
- ): { error: Response } | AuthResult {
165
+ ): Promise<{ error: Response } | AuthResult> {
138
166
  const key = extractApiKey(req);
139
167
  if (!key) {
140
168
  return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
141
169
  }
142
170
 
171
+ // JWT path: hub-issued tokens. Trust pinned to the hub origin via `iss`
172
+ // verification inside validateHubJwt; signature checked against hub's JWKS.
173
+ // Audience strict-checked against `vault.<name>` so a token stamped for
174
+ // one vault can't reach another.
175
+ if (looksLikeJwt(key)) {
176
+ return await authenticateHubJwt(key, { expectedAudience: `vault.${vaultConfig.name}` });
177
+ }
178
+
143
179
  // Try vault's token DB first
144
180
  if (vaultDb) {
145
181
  try {
146
182
  const resolved = resolveToken(vaultDb, key);
147
183
  if (resolved) {
184
+ // Per-vault binding (v16): tokens minted via /vault/<name>/tokens
185
+ // carry vault_name = <name>. Reject if presented at a different
186
+ // vault. NULL = legacy / server-wide; accept anywhere. The DB
187
+ // lookup itself already filters by per-vault DB scoping (resolve
188
+ // only succeeds if the token row lives in this vault's DB), so
189
+ // a mismatch here would only happen if a token row was copied
190
+ // across vault DBs out-of-band — defense-in-depth.
191
+ if (resolved.vault_name !== null && resolved.vault_name !== vaultConfig.name) {
192
+ return {
193
+ error: Response.json(
194
+ {
195
+ error: "Unauthorized",
196
+ message: `token is bound to vault '${resolved.vault_name}'; cannot be used against vault '${vaultConfig.name}'`,
197
+ },
198
+ { status: 403 },
199
+ ),
200
+ };
201
+ }
148
202
  if (resolved.legacyDerived) {
149
203
  warnLegacyOnce(`vault-token:${vaultConfig.name ?? ""}`, "vault token without scopes column");
150
204
  }
@@ -152,6 +206,8 @@ export function authenticateVaultRequest(
152
206
  permission: resolved.permission,
153
207
  scopes: resolved.scopes,
154
208
  legacyDerived: resolved.legacyDerived,
209
+ scoped_tags: resolved.scoped_tags,
210
+ vault_name: resolved.vault_name,
155
211
  };
156
212
  }
157
213
  } catch {
@@ -181,20 +237,86 @@ export function authenticateVaultRequest(
181
237
  return { error: Response.json({ error: "Unauthorized", message: "Invalid API key" }, { status: 401 }) };
182
238
  }
183
239
 
240
+ /**
241
+ * Validate a JWT-shaped bearer and convert the result into an `AuthResult`.
242
+ * The token's scope claim becomes the granted scopes; permission is derived
243
+ * for back-compat with code paths that still branch on `permission` (MCP
244
+ * tool gating, view auth). `legacyDerived` is `false` — JWT-issued scopes
245
+ * are explicit, never inferred.
246
+ *
247
+ * Scope-shape policy: hub-issued tokens MUST carry resource-narrowed
248
+ * `vault:<name>:<verb>` scopes. Broad `vault:<verb>` scopes are rejected
249
+ * here — Phase B2 settled that hub tokens always name the resource. Per-
250
+ * vault audience enforcement happens inside `validateHubJwt` via
251
+ * `opts.expectedAudience`.
252
+ */
253
+ async function authenticateHubJwt(
254
+ token: string,
255
+ opts: { expectedAudience: string | null },
256
+ ): Promise<{ error: Response } | AuthResult> {
257
+ try {
258
+ const claims = await validateHubJwt(token, { expectedAudience: opts.expectedAudience });
259
+ const broad = findBroadVaultScopes(claims.scopes);
260
+ if (broad.length > 0) {
261
+ return {
262
+ error: Response.json(
263
+ {
264
+ error: "Unauthorized",
265
+ message: `hub JWT carries broad vault scope(s): ${broad.join(" ")}. Hub-issued tokens must use resource-narrowed scopes (vault:<name>:<verb>).`,
266
+ },
267
+ { status: 401 },
268
+ ),
269
+ };
270
+ }
271
+ const permission: TokenPermission =
272
+ hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
273
+ ? "full"
274
+ : "read";
275
+ return { permission, scopes: claims.scopes, legacyDerived: false, scoped_tags: null, vault_name: null };
276
+ } catch (err) {
277
+ if (err instanceof HubJwtError) {
278
+ return { error: Response.json({ error: "Unauthorized", message: err.message }, { status: 401 }) };
279
+ }
280
+ // Unknown failure shape — surface the message but stay 401.
281
+ const msg = err instanceof Error ? err.message : "JWT validation failed";
282
+ return { error: Response.json({ error: "Unauthorized", message: msg }, { status: 401 }) };
283
+ }
284
+ }
285
+
184
286
  /**
185
287
  * Authenticate for the unified /mcp endpoint.
186
288
  * Checks legacy global config.yaml keys first, then falls back to checking
187
289
  * each vault's token DB. This allows OAuth-minted pvt_ tokens to work on
188
290
  * the unified endpoint.
189
291
  */
190
- export function authenticateGlobalRequest(
292
+ export async function authenticateGlobalRequest(
191
293
  req: Request,
192
- ): { error: Response } | AuthResult {
294
+ ): Promise<{ error: Response } | AuthResult> {
193
295
  const key = extractApiKey(req);
194
296
  if (!key) {
195
297
  return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
196
298
  }
197
299
 
300
+ // Hub-issued JWTs are always vault-bound (aud=vault.<name>). The unified
301
+ // /vaults / /health surface spans every vault and has no single audience to
302
+ // strict-check against, so JWTs aren't accepted here. Cross-vault listing
303
+ // for hub-authenticated callers will land alongside the `parachute:host:*`
304
+ // scope namespace; until then, callers wanting `/vaults` from a JWT
305
+ // context use a per-vault token at `/vault/<name>` and rely on services.json
306
+ // for the broader catalog.
307
+ if (looksLikeJwt(key)) {
308
+ return {
309
+ error: Response.json(
310
+ {
311
+ error: "Unauthorized",
312
+ message:
313
+ "Hub-issued JWTs are vault-bound; use /vault/<name>/* endpoints. Cross-vault routes accept legacy config.yaml keys or per-vault tokens.",
314
+ },
315
+ { status: 401 },
316
+ ),
317
+ };
318
+ }
319
+
198
320
  // Legacy: check global keys from config.yaml
199
321
  const globalConfig = readGlobalConfig();
200
322
  if (globalConfig.api_keys) {
@@ -208,7 +330,12 @@ export function authenticateGlobalRequest(
208
330
 
209
331
  // Fall through to vault token DBs — check each vault for the token.
210
332
  // This enables OAuth-minted pvt_ tokens and CLI-created tokens to
211
- // authenticate against the unified /mcp endpoint.
333
+ // authenticate against the unified /mcp endpoint. The token's vault
334
+ // binding (if any) is propagated via AuthResult.vault_name; downstream
335
+ // handlers that operate on a specific vault are responsible for
336
+ // checking that binding matches their target. The unified surface
337
+ // itself doesn't reject here — a vault-bound token authenticating to
338
+ // call back into its own vault via /mcp is legitimate.
212
339
  for (const vaultName of listVaults()) {
213
340
  try {
214
341
  const store = getVaultStore(vaultName);
@@ -221,6 +348,8 @@ export function authenticateGlobalRequest(
221
348
  permission: resolved.permission,
222
349
  scopes: resolved.scopes,
223
350
  legacyDerived: resolved.legacyDerived,
351
+ scoped_tags: resolved.scoped_tags,
352
+ vault_name: resolved.vault_name,
224
353
  };
225
354
  }
226
355
  } catch {
package/src/backup.ts CHANGED
@@ -97,7 +97,7 @@ export function backupFilename(timestamp: string): string {
97
97
  export function parseBackupFilename(name: string): { timestamp: string } | null {
98
98
  const m = name.match(/^parachute-backup-(.+)\.tar\.gz$/);
99
99
  if (!m) return null;
100
- return { timestamp: m[1] };
100
+ return { timestamp: m[1]! };
101
101
  }
102
102
 
103
103
  // ---------------------------------------------------------------------------
@@ -298,13 +298,10 @@ async function writeToDestination(
298
298
  const pruned = pruneRetention(target, retention);
299
299
  return { destination: dest, writtenPath: out, pruned };
300
300
  }
301
- // Exhaustiveness guard — if a future destination kind is added to the
302
- // type union but not handled here, the compiler fails the build.
303
- default: {
304
- const _exhaustive: never = dest;
305
- throw new Error(`Unsupported destination kind: ${JSON.stringify(_exhaustive)}`);
306
- }
307
301
  }
302
+ // Unreachable while BackupDestination only has "local"; once a second
303
+ // kind is added, restore an exhaustiveness `never` check here.
304
+ throw new Error(`Unsupported destination kind: ${JSON.stringify(dest)}`);
308
305
  }
309
306
 
310
307
  // ---------------------------------------------------------------------------