@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2

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 (62) hide show
  1. package/README.md +51 -54
  2. package/core/src/core.test.ts +4 -1
  3. package/core/src/indexed-fields.test.ts +151 -0
  4. package/core/src/indexed-fields.ts +98 -0
  5. package/core/src/mcp.ts +66 -43
  6. package/core/src/notes.ts +26 -2
  7. package/core/src/portable-md.test.ts +52 -0
  8. package/core/src/portable-md.ts +48 -0
  9. package/core/src/schema.ts +87 -14
  10. package/core/src/store.ts +117 -0
  11. package/core/src/types.ts +28 -0
  12. package/package.json +2 -2
  13. package/src/auth-hub-jwt.test.ts +191 -11
  14. package/src/auth-status.ts +12 -5
  15. package/src/auth.test.ts +135 -219
  16. package/src/auth.ts +158 -107
  17. package/src/cli.ts +306 -224
  18. package/src/config.ts +12 -4
  19. package/src/export-watch.test.ts +23 -0
  20. package/src/export-watch.ts +14 -0
  21. package/src/git-preflight.test.ts +70 -0
  22. package/src/git-preflight.ts +68 -0
  23. package/src/hub-jwt.test.ts +27 -2
  24. package/src/hub-jwt.ts +10 -0
  25. package/src/init-summary.test.ts +4 -4
  26. package/src/init-summary.ts +36 -10
  27. package/src/mcp-config.test.ts +4 -2
  28. package/src/mcp-http.ts +24 -3
  29. package/src/mcp-install-interactive.test.ts +33 -71
  30. package/src/mcp-install-interactive.ts +23 -76
  31. package/src/mcp-install.test.ts +156 -55
  32. package/src/mcp-install.ts +109 -3
  33. package/src/mcp-tools.ts +249 -74
  34. package/src/mirror-config.test.ts +107 -0
  35. package/src/mirror-config.ts +275 -9
  36. package/src/mirror-credentials.test.ts +168 -17
  37. package/src/mirror-credentials.ts +155 -32
  38. package/src/mirror-deps.ts +25 -16
  39. package/src/mirror-import.test.ts +122 -16
  40. package/src/mirror-import.ts +50 -16
  41. package/src/mirror-manager.test.ts +51 -0
  42. package/src/mirror-manager.ts +116 -22
  43. package/src/mirror-per-vault.test.ts +519 -0
  44. package/src/mirror-registry.ts +91 -14
  45. package/src/mirror-routes.test.ts +81 -21
  46. package/src/mirror-routes.ts +90 -16
  47. package/src/routes.ts +39 -2
  48. package/src/routing.test.ts +203 -118
  49. package/src/routing.ts +46 -59
  50. package/src/scopes.test.ts +0 -86
  51. package/src/scopes.ts +9 -97
  52. package/src/server.ts +102 -34
  53. package/src/storage.test.ts +132 -7
  54. package/src/token-store.test.ts +88 -169
  55. package/src/token-store.ts +123 -249
  56. package/src/vault-create.test.ts +12 -4
  57. package/src/vault.test.ts +408 -103
  58. package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
  59. package/web/ui/dist/index.html +1 -1
  60. package/src/tokens-routes.test.ts +0 -727
  61. package/src/tokens-routes.ts +0 -392
  62. package/web/ui/dist/assets/index-Degr8snN.js +0 -60
package/src/auth.ts CHANGED
@@ -1,27 +1,30 @@
1
1
  /**
2
2
  * Authentication and authorization for the vault server.
3
3
  *
4
- * Token-based auth with two permission levels:
5
- * - "full" unrestricted access (CRUD + delete + token management)
6
- * - "read" read-only (query, list, find-path, vault-info)
4
+ * As of 0.5.0 vault is a PURE HUB RESOURCE-SERVER (vault#282 Stage 2). The
5
+ * opaque `pvt_*` vault-DB token was dropped vault no longer mints or
6
+ * validates it. Three auth paths survive:
7
7
  *
8
- * Tokens live in each vault's SQLite database (tokens table, schema v7).
8
+ * 1. Hub-issued JWT the user-credential path. JWT-shaped bearers are
9
+ * validated against the hub's JWKS (`authenticateHubJwt` → hub-jwt.ts →
10
+ * scope-guard), audience-pinned to `vault.<name>`, scope-narrowed.
11
+ * 2. VAULT_AUTH_TOKEN — the server-wide operator bearer (env var,
12
+ * constant-time compare). Short-circuits both auth entry points.
13
+ * 3. Legacy YAML api_keys — hashed keys in vault.yaml / config.yaml. A
14
+ * separate deprecation axis from pvt_*; still validated here.
9
15
  *
10
- * Backward compatibility: config.yaml API keys are still checked as a fallback.
11
- * Those keys resolve as full-access tokens. Legacy "admin" and "write" values
12
- * in the DB are normalized to "full" at read time.
16
+ * Permission levels remain "full" / "read"; legacy "admin"/"write" DB values
17
+ * normalize to "full" at read time.
13
18
  *
14
- * The unified /mcp endpoint uses only legacy global config.yaml keys, since
15
- * tokens are per-vault and the unified endpoint spans all vaults.
19
+ * The unified /mcp endpoint accepts only VAULT_AUTH_TOKEN + global config.yaml
20
+ * keys — hub JWTs are vault-bound (aud=vault.<name>) and have no single
21
+ * audience to strict-check on the cross-vault surface.
16
22
  */
17
23
 
18
- import { readGlobalConfig, writeVaultConfig, writeGlobalConfig, verifyKey, listVaults, readVaultConfig } from "./config.ts";
24
+ import { readGlobalConfig, writeVaultConfig, writeGlobalConfig, verifyKey } from "./config.ts";
19
25
  import type { VaultConfig, StoredKey } from "./config.ts";
20
- import { resolveToken } from "./token-store.ts";
21
26
  import type { TokenPermission } from "./token-store.ts";
22
- import type { Database } from "bun:sqlite";
23
27
  import crypto from "node:crypto";
24
- import { getVaultStore } from "./vault-store.ts";
25
28
  import {
26
29
  findBroadVaultScopes,
27
30
  hasScope,
@@ -71,8 +74,8 @@ function constantTimeEquals(a: string, b: string): boolean {
71
74
  *
72
75
  * The operator-channel auth shape for non-loopback deploys (Render,
73
76
  * sibling-container setups, vault#339). Hub uses this to call vault
74
- * across a container boundary; end-user OAuth tokens still take the
75
- * per-vault hub-JWT / pvt_* paths below. See `docs/auth-model.md` §2.
77
+ * across a container boundary; end-user OAuth tokens take the per-vault
78
+ * hub-JWT path below. See `docs/auth-model.md` §2.
76
79
  *
77
80
  * Scope set is broad (`vault:admin`) — the env-var bearer is an
78
81
  * operator credential, not a user credential. Tag-scoping doesn't
@@ -127,12 +130,11 @@ export interface AuthResult {
127
130
  */
128
131
  vault_name: string | null;
129
132
  /**
130
- * Session identifier (v19). For `pvt_*` tokens this is the display id
131
- * (`t_<hashprefix>`) of the presented token. For hub JWTs it's the
132
- * `jti` claim, when present. NULL for legacy YAML keys / server-wide
133
- * env-var tokens / hub JWTs without a `jti`. Used by the manage-token
134
- * MCP tool to stamp child tokens with `parent_jti` so list/revoke can
135
- * scope to this session's mints. See vault#376.
133
+ * Session identifier. For hub JWTs this is the `jti` claim, when present.
134
+ * NULL for legacy YAML keys / server-wide env-var tokens / hub JWTs
135
+ * without a `jti`. Used by the manage-token MCP tool to stamp child
136
+ * mints with `parent_jti` so list/revoke can scope to this session's
137
+ * mints. See vault#376.
136
138
  */
137
139
  caller_jti: string | null;
138
140
  }
@@ -225,21 +227,19 @@ function validateKey(keys: StoredKey[], providedKey: string): StoredKey | null {
225
227
  /**
226
228
  * Authenticate for a specific vault.
227
229
  *
228
- * Token shape decides the path:
230
+ * Token shape decides the path (vault#282 Stage 2 — vault is a pure hub
231
+ * resource-server, the `pvt_*` vault-DB lookup is GONE):
232
+ * - VAULT_AUTH_TOKEN match → server-wide operator bearer (checked first).
229
233
  * - JWT-shaped (`eyJ…`) → validate against the hub's JWKS. JWT-shaped tokens
230
- * commit to JWT validation; we don't fall through to `pvt_*` lookup on
231
- * failure, since a malformed JWT was never going to be a valid local
232
- * token anyway.
233
- * - Anything else try the vault's token DB, then legacy YAML keys.
234
- *
235
- * Dual-validate window: both paths are live during this release cycle so
236
- * existing `pvt_*` callers continue to work. A follow-up issue retires the
237
- * legacy path.
234
+ * commit to JWT validation; we don't fall through on failure, since a
235
+ * malformed JWT was never going to be a valid local credential anyway.
236
+ * - Anything else → legacy YAML api_keys (vault.yaml, then config.yaml).
237
+ * A `pvt_*`-prefixed bearer is not JWT-shaped and matches no `key_hash`,
238
+ * so it falls through to the 401 — pvt_* is unvalidatable post-DROP.
238
239
  */
239
240
  export async function authenticateVaultRequest(
240
241
  req: Request,
241
242
  vaultConfig: VaultConfig,
242
- vaultDb?: Database,
243
243
  ): Promise<{ error: Response } | AuthResult> {
244
244
  const key = extractApiKey(req);
245
245
  if (!key) {
@@ -250,8 +250,8 @@ export async function authenticateVaultRequest(
250
250
  // a matching bearer authenticates as full/admin against any vault. This
251
251
  // is the cross-container path for Render / sibling-service deployments
252
252
  // where hub talks to vault over HTTP. Checked first so it short-circuits
253
- // both JWT validation and per-vault DB lookups the operator token is
254
- // a credential the operator opts into, not one we'd ever fall through.
253
+ // JWT validation the operator token is a credential the operator opts
254
+ // into, not one we'd ever fall through.
255
255
  const serverWide = tryServerWideAuth(key);
256
256
  if (serverWide !== null) return serverWide;
257
257
 
@@ -269,46 +269,6 @@ export async function authenticateVaultRequest(
269
269
  });
270
270
  }
271
271
 
272
- // Try vault's token DB first
273
- if (vaultDb) {
274
- try {
275
- const resolved = resolveToken(vaultDb, key);
276
- if (resolved) {
277
- // Per-vault binding (v16): tokens minted via /vault/<name>/tokens
278
- // carry vault_name = <name>. Reject if presented at a different
279
- // vault. NULL = legacy / server-wide; accept anywhere. The DB
280
- // lookup itself already filters by per-vault DB scoping (resolve
281
- // only succeeds if the token row lives in this vault's DB), so
282
- // a mismatch here would only happen if a token row was copied
283
- // across vault DBs out-of-band — defense-in-depth.
284
- if (resolved.vault_name !== null && resolved.vault_name !== vaultConfig.name) {
285
- return {
286
- error: Response.json(
287
- {
288
- error: "Unauthorized",
289
- message: `token is bound to vault '${resolved.vault_name}'; cannot be used against vault '${vaultConfig.name}'`,
290
- },
291
- { status: 403 },
292
- ),
293
- };
294
- }
295
- if (resolved.legacyDerived) {
296
- warnLegacyOnce(`vault-token:${vaultConfig.name ?? ""}`, "vault token without scopes column");
297
- }
298
- return {
299
- permission: resolved.permission,
300
- scopes: resolved.scopes,
301
- legacyDerived: resolved.legacyDerived,
302
- scoped_tags: resolved.scoped_tags,
303
- vault_name: resolved.vault_name,
304
- caller_jti: resolved.jti,
305
- };
306
- }
307
- } catch {
308
- // Token table might not exist yet — fall through to legacy auth
309
- }
310
- }
311
-
312
272
  // Legacy: check per-vault keys from vault.yaml
313
273
  const vaultKey = validateKey(vaultConfig.api_keys, key);
314
274
  if (vaultKey) {
@@ -328,9 +288,102 @@ export async function authenticateVaultRequest(
328
288
  }
329
289
  }
330
290
 
291
+ if (looksLikeDroppedPvtToken(key)) {
292
+ return { error: droppedPvtTokenResponse() };
293
+ }
331
294
  return { error: Response.json({ error: "Unauthorized", message: "Invalid API key" }, { status: 401 }) };
332
295
  }
333
296
 
297
+ /**
298
+ * A bearer of the dropped `pvt_*` shape (vault#282 Stage 2 — vault no longer
299
+ * mints or validates the per-vault opaque token). Detected by prefix so a
300
+ * caller still presenting one gets a pointed 401 instead of the generic
301
+ * "Invalid API key" — the prefix is the user-meaningful signal, and a real
302
+ * hub JWT / `pvk_` / `VAULT_AUTH_TOKEN` never starts with `pvt_`.
303
+ */
304
+ function looksLikeDroppedPvtToken(key: string): boolean {
305
+ return key.startsWith("pvt_");
306
+ }
307
+
308
+ function droppedPvtTokenResponse(): Response {
309
+ return Response.json(
310
+ {
311
+ error: "Unauthorized",
312
+ message:
313
+ "pvt_* tokens are no longer supported (vault 0.5.0). Re-add this vault via your hub to get an access token.",
314
+ },
315
+ { status: 401 },
316
+ );
317
+ }
318
+
319
+ /**
320
+ * Sentinel thrown when a hub JWT carries a `permissions.scoped_tags` claim
321
+ * that is present but malformed (not an array of non-empty strings). See
322
+ * `parseScopedTagsFromPermissions` for why this MUST fail closed.
323
+ */
324
+ class MalformedScopedTagsError extends Error {}
325
+
326
+ /**
327
+ * Map a validated hub JWT's `permissions` claim into the `scoped_tags`
328
+ * allowlist for `AuthResult` (auth-unification arc, C0 — the READ side).
329
+ *
330
+ * Wire contract (the shape the SPA + manage-token mint sides target):
331
+ *
332
+ * permissions: { scoped_tags: string[] } // root tag names
333
+ *
334
+ * Each entry is a ROOT tag name; the token sees notes carrying that tag or a
335
+ * sub-tag thereof (hierarchy expansion happens in tag-scope.ts).
336
+ *
337
+ * Three outcomes, chosen for a strict FAIL-CLOSED invariant — tag-scoping is
338
+ * always a RESTRICTION, so a misread must NEVER widen access:
339
+ *
340
+ * 1. Claim absent (no `permissions`, or no `scoped_tags` key) → returns
341
+ * `null` = UNSCOPED = full vault. This is legitimate and is today's
342
+ * behavior for every hub JWT. Absence genuinely means "not tag-scoped".
343
+ *
344
+ * 2. `scoped_tags` is a non-empty array of non-empty strings → returns
345
+ * that array. The token is tag-scoped; the allowlist is enforced.
346
+ *
347
+ * 3. `scoped_tags` is present but MALFORMED — a string, a number, an
348
+ * object, an array containing a non-string / empty-string, or an empty
349
+ * array `[]` — throws `MalformedScopedTagsError`. The caller REJECTS
350
+ * the request (401). We do NOT coerce to `null` or `[]`:
351
+ *
352
+ * - Coercing to `null` would WIDEN a token that was MEANT to be scoped
353
+ * up to full-vault — a privilege leak. Forbidden.
354
+ * - Coercing to `[]` is NOT reliably fail-closed across enforcement
355
+ * paths. The MCP read-tool wrappers fast-path out on
356
+ * `scoped_tags.length === 0` (mcp-tools.ts ~L178) and the REST path
357
+ * collapses `[]` → null inside `expandTokenTagScope` (tag-scope.ts
358
+ * ~L35) — both treat `[]` as UNSCOPED = full vault. So `[]` would
359
+ * ALSO widen. (Note: `filterNotesByTagScope`/`noteWithinTagScope`
360
+ * would treat a raw `[]` as "matches nothing", but the call sites
361
+ * never reach them with `[]` because of the upstream short-circuits —
362
+ * the two enforcement paths disagree on `[]`, which is exactly why we
363
+ * refuse to manufacture it here.)
364
+ *
365
+ * The only correct fail-closed action for a present-but-unreadable
366
+ * scope is to reject the whole request — never serve it wide.
367
+ */
368
+ function parseScopedTagsFromPermissions(
369
+ permissions: Record<string, unknown> | undefined,
370
+ ): string[] | null {
371
+ if (!permissions || !("scoped_tags" in permissions)) return null;
372
+ const raw = permissions.scoped_tags;
373
+ if (raw === null || raw === undefined) return null; // explicit "unscoped"
374
+ if (
375
+ Array.isArray(raw) &&
376
+ raw.length > 0 &&
377
+ raw.every((t) => typeof t === "string" && t.length > 0)
378
+ ) {
379
+ return raw as string[];
380
+ }
381
+ // Present but malformed (incl. `[]`): fail closed — never widen.
382
+ throw new MalformedScopedTagsError(
383
+ "hub JWT permissions.scoped_tags is present but not a non-empty array of tag names",
384
+ );
385
+ }
386
+
334
387
  /**
335
388
  * Validate a JWT-shaped bearer and convert the result into an `AuthResult`.
336
389
  * The token's scope claim becomes the granted scopes; permission is derived
@@ -353,6 +406,12 @@ export async function authenticateVaultRequest(
353
406
  * unchanged. The 403 (not 401) signals "your credential is valid but
354
407
  * doesn't grant access to this vault" — distinct from authentication
355
408
  * failures upstream. See hub#283 + scope-guard 0.3.0 for the mint side.
409
+ *
410
+ * Tag-scoping (auth-unification arc, C0): the token's `permissions.scoped_tags`
411
+ * claim (when present and well-formed) maps into `AuthResult.scoped_tags`,
412
+ * restricting which notes the token sees. A present-but-malformed claim FAILS
413
+ * CLOSED with a 401 rather than widening to full-vault — see
414
+ * `parseScopedTagsFromPermissions`.
356
415
  */
357
416
  async function authenticateHubJwt(
358
417
  token: string,
@@ -413,11 +472,15 @@ async function authenticateHubJwt(
413
472
  hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
414
473
  ? "full"
415
474
  : "read";
475
+ // C0: read tag-scoping from the validated token's `permissions` claim.
476
+ // Throws MalformedScopedTagsError (caught below → 401) on a present-but-
477
+ // malformed claim so we never widen access on a misread.
478
+ const scoped_tags = parseScopedTagsFromPermissions(claims.permissions);
416
479
  return {
417
480
  permission,
418
481
  scopes: claims.scopes,
419
482
  legacyDerived: false,
420
- scoped_tags: null,
483
+ scoped_tags,
421
484
  vault_name: null,
422
485
  // claims.jti is `undefined` when the issuer didn't stamp one. Pass it
423
486
  // through verbatim — manage-token's session-pin will be null in that
@@ -425,6 +488,19 @@ async function authenticateHubJwt(
425
488
  caller_jti: claims.jti ?? null,
426
489
  };
427
490
  } catch (err) {
491
+ if (err instanceof MalformedScopedTagsError) {
492
+ // Fail-closed (C0): a present-but-malformed `permissions.scoped_tags`
493
+ // means the token wanted to be tag-scoped but we can't read the
494
+ // allowlist. Reject rather than widen to full-vault. The audit log
495
+ // carries the diagnostic; the client gets a generic 401.
496
+ console.warn(`[auth] hub JWT rejected: ${err.message}`);
497
+ return {
498
+ error: Response.json(
499
+ { error: "Unauthorized", message: "token has a malformed tag-scope claim" },
500
+ { status: 401 },
501
+ ),
502
+ };
503
+ }
428
504
  if (err instanceof HubJwtError) {
429
505
  // Revocation-related codes get sanitized client messages: server-side
430
506
  // audit log carries the full diagnostic (jti for `revoked`,
@@ -464,10 +540,12 @@ async function authenticateHubJwt(
464
540
  }
465
541
 
466
542
  /**
467
- * Authenticate for the unified /mcp endpoint.
468
- * Checks legacy global config.yaml keys first, then falls back to checking
469
- * each vault's token DB. This allows OAuth-minted pvt_ tokens to work on
470
- * the unified endpoint.
543
+ * Authenticate for the unified /mcp endpoint (cross-vault: /vaults metadata,
544
+ * /health detail). Accepts only VAULT_AUTH_TOKEN + global config.yaml keys
545
+ * (vault#282 Stage 2 the per-vault pvt_* DB fallback is GONE). Hub JWTs are
546
+ * vault-bound (aud=vault.<name>) and have no single audience to strict-check
547
+ * across every vault, so they're rejected here with a redirect hint to the
548
+ * per-vault `/vault/<name>/*` surface.
471
549
  */
472
550
  export async function authenticateGlobalRequest(
473
551
  req: Request,
@@ -516,35 +594,8 @@ export async function authenticateGlobalRequest(
516
594
  }
517
595
  }
518
596
 
519
- // Fall through to vault token DBs — check each vault for the token.
520
- // This enables OAuth-minted pvt_ tokens and CLI-created tokens to
521
- // authenticate against the unified /mcp endpoint. The token's vault
522
- // binding (if any) is propagated via AuthResult.vault_name; downstream
523
- // handlers that operate on a specific vault are responsible for
524
- // checking that binding matches their target. The unified surface
525
- // itself doesn't reject here — a vault-bound token authenticating to
526
- // call back into its own vault via /mcp is legitimate.
527
- for (const vaultName of listVaults()) {
528
- try {
529
- const store = getVaultStore(vaultName);
530
- const resolved = resolveToken(store.db, key);
531
- if (resolved) {
532
- if (resolved.legacyDerived) {
533
- warnLegacyOnce(`vault-token:${vaultName}`, "vault token without scopes column");
534
- }
535
- return {
536
- permission: resolved.permission,
537
- scopes: resolved.scopes,
538
- legacyDerived: resolved.legacyDerived,
539
- scoped_tags: resolved.scoped_tags,
540
- vault_name: resolved.vault_name,
541
- caller_jti: resolved.jti,
542
- };
543
- }
544
- } catch {
545
- // Skip vaults that can't be opened
546
- }
597
+ if (looksLikeDroppedPvtToken(key)) {
598
+ return { error: droppedPvtTokenResponse() };
547
599
  }
548
-
549
600
  return { error: Response.json({ error: "Unauthorized", message: "Invalid API key" }, { status: 401 }) };
550
601
  }