@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/mcp-tools.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  } from "../core/src/vault-projection.ts";
15
15
  import { readVaultConfig, writeVaultConfig } from "./config.ts";
16
16
  import { getVaultStore } from "./vault-store.ts";
17
- import { hasScopeForVault, parseScopes, validateMintedScopes, hasScope, SCOPE_WRITE, SCOPE_ADMIN } from "./scopes.ts";
17
+ import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts";
18
18
  import type { AuthResult } from "./auth.ts";
19
19
  import {
20
20
  expandTokenTagScope,
@@ -23,12 +23,14 @@ import {
23
23
  } from "./tag-scope.ts";
24
24
  import {
25
25
  findTokensReferencingTag,
26
- generateToken,
27
- createToken,
28
- listMcpMintedTokens,
29
- softRevokeMcpToken,
30
- type TokenPermission,
26
+ recordMcpMintLedger,
27
+ listMcpMintedHubJwts,
28
+ findMcpMintLedgerEntry,
29
+ markMcpMintLedgerRevoked,
31
30
  } from "./token-store.ts";
31
+ import { chooseHubOrigin, mintHubJwt, revokeHubJwt } from "./mcp-install.ts";
32
+ import { looksLikeJwt } from "./hub-jwt.ts";
33
+ import { readGlobalConfig, DEFAULT_PORT } from "./config.ts";
32
34
 
33
35
  /**
34
36
  * Filter a vault projection to entries an in-scope tag contributes to.
@@ -109,7 +111,11 @@ export async function getServerInstruction(
109
111
  * When omitted (internal callers that only inspect the tool list — no execute
110
112
  * path exercised), the description-update branch is disabled entirely.
111
113
  */
112
- export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): McpToolDef[] {
114
+ export function generateScopedMcpTools(
115
+ vaultName: string,
116
+ auth?: AuthResult,
117
+ callerBearer?: string | null,
118
+ ): McpToolDef[] {
113
119
  const store = getVaultStore(vaultName);
114
120
  const tools = generateMcpTools(store);
115
121
 
@@ -120,8 +126,9 @@ export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): Mc
120
126
  // manage-token is server-only (needs token-store + auth context), so it
121
127
  // lives here rather than in core. Always appended to the surface; the
122
128
  // `requiredVerb: "admin"` filter in mcp-http.ts hides it from non-admin
123
- // callers. See vault#376.
124
- tools.push(buildManageTokenTool(vaultName, auth));
129
+ // callers. See vault#376. The raw caller bearer (vault#403, MGT) is
130
+ // forwarded to hub's mint-token attenuation proxy on mint.
131
+ tools.push(buildManageTokenTool(vaultName, auth, callerBearer ?? null));
125
132
 
126
133
  return tools;
127
134
  }
@@ -428,50 +435,77 @@ function overrideVaultInfo(
428
435
  * for one-shot scripted work, then revokes immediately" surface. A long
429
436
  * TTL would defeat the safety story — if revoke fails (network blip,
430
437
  * model error), the cap is the backstop. Operators wanting long-lived
431
- * tokens still use the REST /vault/<name>/tokens endpoint.
438
+ * tokens mint a hub-issued JWT via the hub mint-token flow (the REST
439
+ * /vault/<name>/tokens endpoint was removed with the pvt_* drop, vault#282).
432
440
  */
433
441
  const MANAGE_TOKEN_DEFAULT_TTL_SECONDS = 900; // 15 minutes
434
442
  const MANAGE_TOKEN_MAX_TTL_SECONDS = 3600; // 1 hour
435
443
 
436
- function permissionForScopes(scopes: string[]): TokenPermission {
437
- return hasScope(scopes, SCOPE_WRITE) || hasScope(scopes, SCOPE_ADMIN) ? "full" : "read";
444
+ /**
445
+ * Resolve the bare hub origin for the mint/revoke proxy calls. Reuses
446
+ * `chooseHubOrigin` (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback) so
447
+ * the manage-token proxy targets the same hub the rest of vault talks to.
448
+ * The port is read from global config (same source the server binds on).
449
+ */
450
+ function resolveHubOrigin(): { url: string; source: string } {
451
+ let port = DEFAULT_PORT;
452
+ try {
453
+ port = readGlobalConfig().port || DEFAULT_PORT;
454
+ } catch {
455
+ // Config unreadable (fresh / test fixture) — fall back to the default
456
+ // port; chooseHubOrigin still honors PARACHUTE_HUB_ORIGIN / expose-state.
457
+ }
458
+ return chooseHubOrigin(port);
438
459
  }
439
460
 
440
461
  /**
441
462
  * Build the manage-token MCP tool, wired to the calling session's auth.
442
463
  *
464
+ * After the auth-unification arc (vault#403, MGT) the tool is a thin proxy to
465
+ * hub's mint-token attenuation endpoint: it mints short-TTL HUB JWTs. The
466
+ * `pvt_*` vault-DB mint infra it replaced was removed at 0.5.0 (vault#282
467
+ * Stage 2 — vault is a pure hub resource-server).
468
+ *
443
469
  * Closure-captured context:
444
- * - `vaultName`: every mint pins `vault_name` to this; cross-vault mints
445
- * are rejected by `validateMintedScopes` (it refuses any
446
- * `vault:<other>:<verb>` scope).
447
- * - `auth.scopes`: defense-in-depth subset check on mint. The outer
448
- * filter already required vault:admin to see the tool, but a hand-
449
- * crafted JSON-RPC `tools/call` of `manage-token` from a non-admin
450
- * session would bypass the visibility filter `validateMintedScopes`
451
- * plus the `hasScopeForVault(auth.scopes, vaultName, "admin")` guard
452
- * below catch that case.
453
- * - `auth.caller_jti`: stamped as `parent_jti` on each mint; list+revoke
454
- * scope to this jti so each MCP session sees only its own mints.
455
- * When NULL (legacy / env-var operator / hub JWT without jti), mints
456
- * still succeed but list/revoke return empty — the operator hits the
457
- * CLI / REST surface instead for revocation in that path.
470
+ * - `vaultName`: every mint requests `vault:<vaultName>:<verb>`; cross-vault
471
+ * and over-scope requests are rejected locally by `validateMintedScopes`
472
+ * (fail-fast) AND by hub's attenuation guard (authoritative).
473
+ * - `auth.scopes`: the caller must hold `vault:<vaultName>:admin` to see the
474
+ * tool (mcp-http.ts visibleTools filter) and to mint; `validateMintedScopes`
475
+ * enforces the requested scope is a same-vault subset of what's held.
476
+ * - `auth.caller_jti`: the minting MCP session's id, recorded as the
477
+ * `parent_jti` in the local ledger so list/revoke stay session-scoped.
478
+ * When NULL (env-var operator / hub JWT without jti) there's no stable
479
+ * session id list returns empty + revoke returns not_found.
480
+ * - `callerBearer`: the RAW credential the session presented. Only forwarded
481
+ * to hub when JWT-shaped (a hub JWT carrying `vault:<name>:admin`). A
482
+ * non-forwardable credential (the VAULT_AUTH_TOKEN env-var operator secret)
483
+ * yields a clear "mint requires a hub-JWT session" error rather than a
484
+ * fabricated bearer.
458
485
  *
459
- * The execute function is async (token mint touches the store + DB) and
486
+ * The execute function is async (mint/revoke do an HTTP round-trip to hub) and
460
487
  * returns a discriminated-union response shape: `{action, …}` with `action`
461
488
  * matching the requested action. The MCP HTTP layer serializes the result
462
489
  * via `JSON.stringify`, so caller-side parsing keys off the action field.
463
490
  */
464
- function buildManageTokenTool(vaultName: string, auth: AuthResult | undefined): McpToolDef {
491
+ function buildManageTokenTool(
492
+ vaultName: string,
493
+ auth: AuthResult | undefined,
494
+ callerBearer: string | null,
495
+ ): McpToolDef {
465
496
  return {
466
497
  name: "manage-token",
467
498
  requiredVerb: "admin",
468
499
  description:
469
- "Mint, revoke, or list short-TTL vault tokens within this MCP session. " +
500
+ "Mint, revoke, or list short-TTL hub JWTs within this MCP session. " +
470
501
  "Designed for one-shot AI-driven workflows: mint a narrow token, run a " +
471
- "script with it, revoke immediately. Token lifetime defaults to 15 min " +
472
- "(max 1 hour). Mints are pinned to this vault and to the caller's scope " +
473
- "subset you cannot escalate. List + revoke are scoped to tokens this " +
474
- "session minted; CLI/REST-minted tokens are not surfaced here.\n\n" +
502
+ "script with it, revoke immediately. Minted tokens are short-lived hub " +
503
+ "JWTs (revocable via the hub's token registry), not legacy vault-DB " +
504
+ "tokens. Lifetime defaults to 15 min (max 1 hour). Mints are pinned to " +
505
+ "this vault and attenuated to a subset of the caller's scope — you cannot " +
506
+ "escalate. Minting requires a hub-JWT session holding 'vault:" + vaultName +
507
+ ":admin'. List + revoke are scoped to tokens this session minted; " +
508
+ "CLI/REST-minted tokens are not surfaced here.\n\n" +
475
509
  "Actions (discriminator: `action`):\n" +
476
510
  "- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
477
511
  "- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }\n" +
@@ -525,8 +559,8 @@ function buildManageTokenTool(vaultName: string, auth: AuthResult | undefined):
525
559
  };
526
560
  }
527
561
 
528
- if (action === "mint") return await mintAction(params, vaultName, auth);
529
- if (action === "revoke") return revokeAction(params, vaultName, auth);
562
+ if (action === "mint") return await mintAction(params, vaultName, auth, callerBearer);
563
+ if (action === "revoke") return await revokeAction(params, vaultName, auth, callerBearer);
530
564
  if (action === "list") return listAction(vaultName, auth);
531
565
 
532
566
  return {
@@ -537,10 +571,29 @@ function buildManageTokenTool(vaultName: string, auth: AuthResult | undefined):
537
571
  };
538
572
  }
539
573
 
574
+ /**
575
+ * Normalize a requested scope to the resource-narrowed `vault:<name>:<verb>`
576
+ * shape hub expects. Callers may pass either the broad `vault:<verb>` form
577
+ * (the manage-token v1 surface accepted this) or the explicit
578
+ * `vault:<name>:<verb>` form. We rewrite the broad form to name THIS vault so
579
+ * hub's attenuation guard — which only knows resource-narrowed scopes — sees a
580
+ * `vault:<vaultName>:<verb>` request. A scope already naming a different vault
581
+ * is left untouched (validateMintedScopes rejects it before we get here).
582
+ */
583
+ function narrowScopeForVault(scope: string, vaultName: string): string {
584
+ const parts = scope.split(":");
585
+ // `vault:<verb>` (2 parts) → `vault:<name>:<verb>`.
586
+ if (parts.length === 2 && parts[0] === "vault") {
587
+ return `vault:${vaultName}:${parts[1]}`;
588
+ }
589
+ return scope;
590
+ }
591
+
540
592
  async function mintAction(
541
593
  params: Record<string, unknown>,
542
594
  vaultName: string,
543
595
  auth: AuthResult,
596
+ callerBearer: string | null,
544
597
  ): Promise<Record<string, unknown>> {
545
598
  // Scope parsing: accept string or string[]. Empty/missing is rejected
546
599
  // explicitly (no implicit "full scope" default — manage-token always
@@ -568,6 +621,10 @@ async function mintAction(
568
621
  };
569
622
  }
570
623
 
624
+ // Fail-fast local guard (defense-in-depth — hub's attenuation is
625
+ // authoritative): cross-vault + over-scope requests are rejected here with a
626
+ // clear message before any HTTP round-trip. The caller cannot request a
627
+ // scope outside their own vault/authority.
571
628
  const validation = validateMintedScopes(requested, vaultName, auth.scopes);
572
629
  if (!validation.ok) {
573
630
  return {
@@ -578,6 +635,31 @@ async function mintAction(
578
635
  };
579
636
  }
580
637
 
638
+ // Forwardability: minting is a proxy to hub's attenuation endpoint, so the
639
+ // caller must present a forwardable hub-JWT bearer carrying
640
+ // `vault:<name>:admin`. A non-JWT credential (the VAULT_AUTH_TOKEN env-var
641
+ // operator secret) can't be forwarded — and wouldn't carry mint authority at
642
+ // hub anyway — so fail with a clear, actionable error rather than
643
+ // fabricating a bearer.
644
+ //
645
+ // `looksLikeJwt` is a SYNTACTIC hint only (startsWith("eyJ") — the base64url
646
+ // of a JWS header `{"`). It does NOT verify the signature, issuer, scopes,
647
+ // or that the bearer actually grants mint authority. That's intentional:
648
+ // hub's mint-token attenuation guard is the authoritative gate (it validates
649
+ // the bearer and rejects anything it couldn't have minted). This check just
650
+ // avoids forwarding a credential we already know can't be a hub JWT.
651
+ if (!callerBearer || !looksLikeJwt(callerBearer)) {
652
+ return {
653
+ action: "mint",
654
+ error: "forbidden",
655
+ message:
656
+ `manage-token mint requires a hub-JWT session holding 'vault:${vaultName}:admin'. ` +
657
+ "This session authenticated with a non-forwardable credential (operator " +
658
+ "env-var token or legacy vault-DB token); mint a token via the hub admin " +
659
+ "UI / CLI instead, or reconnect MCP with a hub-issued JWT.",
660
+ };
661
+ }
662
+
581
663
  // TTL bounds. Default 900 (15 min); explicit values must satisfy
582
664
  // `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS`. Zero, negative, NaN, and
583
665
  // beyond-max all reject — the cap is the safety backstop if revoke fails,
@@ -600,49 +682,87 @@ async function mintAction(
600
682
  }
601
683
  ttl = params.ttl_seconds;
602
684
  }
603
- const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
604
685
 
605
686
  const description = typeof params.description === "string" && params.description.length > 0
606
687
  ? params.description
607
688
  : null;
608
689
  const label = description ?? `mcp-mint (parent=${auth.caller_jti ?? "unknown"})`;
609
690
 
610
- const store = getVaultStore(vaultName);
611
- const { fullToken } = generateToken();
612
- const created = createToken(store.db, fullToken, {
613
- label,
614
- permission: permissionForScopes(requested),
615
- scopes: requested,
616
- // Tag scoping: inherit the caller's allowlist verbatim. We don't expose
617
- // a `tags` param on manage-token yet the design doc keeps the v1
618
- // surface minimal. When the caller is tag-scoped, the minted token
619
- // carries the same allowlist (no narrowing, no widening); when the
620
- // caller is unscoped, the mint is unscoped. Future widening of the
621
- // surface should re-use tokens-routes.ts' validation path so the rules
622
- // stay in lockstep.
623
- scoped_tags: auth.scoped_tags,
624
- vault_name: vaultName,
625
- expires_at: expiresAt,
626
- created_via: "mcp_mint",
627
- parent_jti: auth.caller_jti,
691
+ // Resolve hub origin (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback).
692
+ const hub = resolveHubOrigin();
693
+
694
+ // Build the mint-token request. Scopes are narrowed to the resource-named
695
+ // `vault:<name>:<verb>` form hub's attenuation guard requires. Tag-scoping
696
+ // (when the caller is tag-scoped) rides along as `permissions.scoped_tags`
697
+ // so the minted hub JWT carries the same restriction vault enforces it on
698
+ // read via C0 (vault#403). Unscoped callers omit `permissions`.
699
+ const narrowedScopes = requested.map((s) => narrowScopeForVault(s, vaultName));
700
+ const permissions =
701
+ auth.scoped_tags && auth.scoped_tags.length > 0
702
+ ? { scoped_tags: auth.scoped_tags }
703
+ : undefined;
704
+
705
+ const minted = await mintHubJwt({
706
+ hubOrigin: hub.url,
707
+ operatorToken: callerBearer,
708
+ scope: narrowedScopes.join(" "),
709
+ expiresInSeconds: ttl,
710
+ ...(permissions !== undefined ? { permissions } : {}),
628
711
  });
629
712
 
713
+ if ("kind" in minted) {
714
+ // Surface a clear, action-keyed error. Network → "hub unreachable";
715
+ // api-error → hub's own error_description (e.g. attenuation rejection).
716
+ if (minted.kind === "network") {
717
+ return {
718
+ action: "mint",
719
+ error: "hub_unreachable",
720
+ message: `manage-token mint: could not reach hub at ${minted.origin} (${minted.cause}). Check PARACHUTE_HUB_ORIGIN / that the hub is running.`,
721
+ };
722
+ }
723
+ return {
724
+ action: "mint",
725
+ error: "hub_rejected",
726
+ message: `manage-token mint: hub rejected the request (${minted.error}: ${minted.description}).`,
727
+ hub_status: minted.status,
728
+ };
729
+ }
730
+
731
+ // Record in the session-pinned ledger so list/revoke can scope to this
732
+ // session's mints. The signed JWT is never stored — only its jti (the
733
+ // revocation handle) + display metadata. NULL caller_jti (env-var / no-jti
734
+ // sessions) can't pass the forwardability gate above, so by here caller_jti
735
+ // is effectively the JWT's jti; we still guard defensively.
736
+ const store = getVaultStore(vaultName);
737
+ if (auth.caller_jti) {
738
+ recordMcpMintLedger(store.db, {
739
+ jti: minted.jti,
740
+ parentJti: auth.caller_jti,
741
+ vaultName,
742
+ label,
743
+ scopes: narrowedScopes,
744
+ scopedTags: auth.scoped_tags,
745
+ expiresAt: minted.expires_at,
746
+ });
747
+ }
748
+
630
749
  return {
631
750
  action: "mint",
632
- token: fullToken,
633
- jti: `t_${created.token_hash.slice(7, 19)}`,
634
- expires_at: expiresAt,
635
- scopes: requested,
751
+ token: minted.token,
752
+ jti: minted.jti,
753
+ expires_at: minted.expires_at,
754
+ scopes: narrowedScopes,
636
755
  scoped_tags: auth.scoped_tags,
637
756
  vault_name: vaultName,
638
757
  };
639
758
  }
640
759
 
641
- function revokeAction(
760
+ async function revokeAction(
642
761
  params: Record<string, unknown>,
643
762
  vaultName: string,
644
763
  auth: AuthResult,
645
- ): Record<string, unknown> {
764
+ callerBearer: string | null,
765
+ ): Promise<Record<string, unknown>> {
646
766
  if (typeof params.jti !== "string" || params.jti.length === 0) {
647
767
  return {
648
768
  action: "revoke",
@@ -651,30 +771,82 @@ function revokeAction(
651
771
  message: "manage-token revoke: `jti` is required (string).",
652
772
  };
653
773
  }
654
- // Session-pin: revoke is restricted to tokens this MCP session minted.
774
+ const jti = params.jti;
775
+
776
+ // Session-pin: revoke is restricted to hub JWTs THIS MCP session minted.
655
777
  // When auth.caller_jti is null (no stable session id — env-var operator,
656
- // legacy YAML key, hub JWT without jti), there are no MCP-minted tokens
657
- // attributable to this session, so revoke returns not_found.
778
+ // legacy YAML key, hub JWT without jti), there are no attributable mints,
779
+ // so revoke returns not_found.
658
780
  if (!auth.caller_jti) {
659
781
  return {
660
782
  action: "revoke",
661
783
  ok: false,
662
784
  error: "not_found",
663
- message: "manage-token revoke: this session has no stable id; revoke via the CLI or REST surface.",
785
+ message: "manage-token revoke: this session has no stable id; revoke via the hub admin UI / CLI.",
664
786
  };
665
787
  }
788
+
666
789
  const store = getVaultStore(vaultName);
667
- const result = softRevokeMcpToken(store.db, params.jti, auth.caller_jti, vaultName);
668
- if (!result.ok) {
669
- // Idempotency: not-found returns ok=true so the AI's "mint → run →
670
- // revoke" loop doesn't surface a confusing failure when a network
671
- // blip causes a duplicate revoke call. The spec calls this out
672
- // explicitly (vault#376). The "already minted by another session"
673
- // case also lands here; we don't differentiate (no information leak
674
- // about other sessions' jti space).
790
+ const entry = findMcpMintLedgerEntry(store.db, jti, auth.caller_jti, vaultName);
791
+ if (!entry) {
792
+ // Idempotency: not-in-this-session's-ledger returns ok=true so the AI's
793
+ // "mint → run → revoke" loop doesn't surface a confusing failure on a
794
+ // duplicate revoke or a network-blip retry. The "minted by another
795
+ // session" case also lands here; we don't differentiate (no information
796
+ // leak about other sessions' jti space).
675
797
  return { action: "revoke", ok: true, note: "no matching token in this session" };
676
798
  }
677
- return { action: "revoke", ok: true, already_revoked: result.already_revoked };
799
+ if (entry.revoked_at) {
800
+ // Already revoked locally — idempotent success, no second hub round-trip.
801
+ return { action: "revoke", ok: true, already_revoked: true };
802
+ }
803
+
804
+ // Forward the revoke to hub's token registry (the authoritative revocation
805
+ // surface — vault is resource-server-only). The caller's `vault:<N>:admin`
806
+ // bearer is forwarded, same as on mint. As of hub#454 this is the
807
+ // expected-SUCCESS path: hub's revoke-token applies capability attenuation
808
+ // symmetric to mint, so a `vault:<N>:admin` bearer may revoke any jti whose
809
+ // scopes it could have minted (and these are exactly the tokens this session
810
+ // minted within that vault's authority). Hub's revoke-token is idempotent.
811
+ //
812
+ // The `"kind" in revoked` branch below is now the EXCEPTION, not the norm —
813
+ // it only fires on a genuine edge (network blip, or a hub-side rejection
814
+ // that shouldn't happen for an in-authority jti). When it does, we still
815
+ // flip the local ledger marker so list reflects the operator's intent, and
816
+ // surface the hub failure so the caller knows the registry-side revoke may
817
+ // not have landed (the short TTL is the backstop either way).
818
+ if (callerBearer && looksLikeJwt(callerBearer)) {
819
+ const hub = resolveHubOrigin();
820
+ const revoked = await revokeHubJwt({
821
+ hubOrigin: hub.url,
822
+ operatorToken: callerBearer,
823
+ jti,
824
+ });
825
+ if ("kind" in revoked) {
826
+ // Unexpected hub failure. Local ledger still flips (operator asked to
827
+ // revoke), but report the hub-side failure so a network blip / scope
828
+ // gap is visible.
829
+ markMcpMintLedgerRevoked(store.db, jti, auth.caller_jti, vaultName);
830
+ if (revoked.kind === "network") {
831
+ return {
832
+ action: "revoke",
833
+ ok: false,
834
+ error: "hub_unreachable",
835
+ message: `manage-token revoke: could not reach hub at ${revoked.origin} (${revoked.cause}); local ledger marked revoked but the hub registry may still list it. The token's short TTL is the backstop.`,
836
+ };
837
+ }
838
+ return {
839
+ action: "revoke",
840
+ ok: false,
841
+ error: "hub_rejected",
842
+ message: `manage-token revoke: hub rejected the request (${revoked.error}: ${revoked.description}); local ledger marked revoked. The token's short TTL is the backstop.`,
843
+ hub_status: revoked.status,
844
+ };
845
+ }
846
+ }
847
+
848
+ markMcpMintLedgerRevoked(store.db, jti, auth.caller_jti, vaultName);
849
+ return { action: "revoke", ok: true, already_revoked: false };
678
850
  }
679
851
 
680
852
  function listAction(vaultName: string, auth: AuthResult): Record<string, unknown> {
@@ -685,6 +857,9 @@ function listAction(vaultName: string, auth: AuthResult): Record<string, unknown
685
857
  return { action: "list", tokens: [] };
686
858
  }
687
859
  const store = getVaultStore(vaultName);
688
- const tokens = listMcpMintedTokens(store.db, auth.caller_jti, vaultName);
860
+ // Read from the hub-JWT mint ledger (vault#403, MGT) — mints now live in
861
+ // hub's registry, not the pvt_* tokens table; the ledger is the local
862
+ // session-attribution index.
863
+ const tokens = listMcpMintedHubJwts(store.db, auth.caller_jti, vaultName);
689
864
  return { action: "list", tokens };
690
865
  }
@@ -15,6 +15,7 @@ import {
15
15
  DEFAULT_SAFETY_NET_SECONDS,
16
16
  MAX_SAFETY_NET_SECONDS,
17
17
  MIN_SAFETY_NET_SECONDS,
18
+ commentOutMirrorBlock,
18
19
  defaultMirrorConfig,
19
20
  parseMirrorConfig,
20
21
  resolveMirrorPath,
@@ -22,6 +23,7 @@ import {
22
23
  validateExternalPath,
23
24
  validateMirrorConfigShape,
24
25
  } from "./mirror-config.ts";
26
+ import { GitNotInstalledError } from "./git-preflight.ts";
25
27
 
26
28
  function tmp(prefix: string): string {
27
29
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -495,4 +497,109 @@ describe("validateExternalPath", () => {
495
497
  expect(r.ok).toBe(true);
496
498
  if (r.ok) expect(r.resolved_path).toBe(dir);
497
499
  });
500
+
501
+ test("git not installed → throws GitNotInstalledError (route maps to 503)", async () => {
502
+ // vault#415 nit — the isGitRepo() check shells `git`. On a git-less
503
+ // server, throw the friendly error (handleMirrorPut maps it to 503
504
+ // git_not_installed) instead of a raw "Executable not found" crash.
505
+ // Force the preflight via the `which` seam; a real, valid git repo is
506
+ // used so the ONLY failure source is the preflight.
507
+ dir = tmp("mirror-validate-nogit-installed-");
508
+ initRepo(dir);
509
+ await expect(validateExternalPath(dir, () => null)).rejects.toBeInstanceOf(
510
+ GitNotInstalledError,
511
+ );
512
+ });
513
+ });
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // commentOutMirrorBlock — vault#400 migration YAML rewrite (extracted from
517
+ // server.ts per vault#408 review N3). Runs against the operator's real
518
+ // config.yaml, so it gets direct coverage here.
519
+ // ---------------------------------------------------------------------------
520
+
521
+ describe("commentOutMirrorBlock", () => {
522
+ test("comments out a real serializer-shaped mirror block; leaves other keys intact", () => {
523
+ // Build the block exactly as serializeMirrorConfig emits it — pins the
524
+ // real production shape rather than a hand-written approximation.
525
+ const block = serializeMirrorConfig({
526
+ ...defaultMirrorConfig(),
527
+ enabled: true,
528
+ location: "external",
529
+ external_path: "/home/aaron/mirrors/brain",
530
+ auto_push: true,
531
+ }).join("\n");
532
+ const yaml = `port: 1940
533
+ default_vault: brain
534
+ ${block}
535
+ auto_transcribe:
536
+ enabled: true
537
+ `;
538
+ const out = commentOutMirrorBlock(yaml);
539
+
540
+ // No LIVE mirror block survives (the parser anchor won't match).
541
+ expect(parseMirrorConfig(out)).toBeUndefined();
542
+ // Every mirror line is commented.
543
+ expect(out).toContain("# mirror:");
544
+ expect(out).toContain("# enabled: true");
545
+ expect(out).toContain("# external_path: /home/aaron/mirrors/brain");
546
+ expect(out).toContain("# auto_push: true");
547
+ // Provenance marker added.
548
+ expect(out).toContain("# [vault#400] migrated to per-vault");
549
+ // Non-mirror top-level keys untouched (byte-for-byte).
550
+ expect(out).toContain("port: 1940");
551
+ expect(out).toContain("default_vault: brain");
552
+ expect(out).toContain("auto_transcribe:");
553
+ expect(out).toContain(" enabled: true");
554
+ // The mirror block must NOT have swallowed the auto_transcribe block —
555
+ // its child line stays a live (uncommented) 2-space-indent field.
556
+ expect(out).not.toContain("# enabled: true\n# auto_transcribe");
557
+ const at = out.indexOf("auto_transcribe:");
558
+ expect(out.slice(at)).toContain("\n enabled: true");
559
+ });
560
+
561
+ test("idempotent — running on already-commented output is a no-op", () => {
562
+ const block = serializeMirrorConfig({
563
+ ...defaultMirrorConfig(),
564
+ enabled: true,
565
+ }).join("\n");
566
+ const yaml = `port: 1940\n${block}\ndiscovery: enabled\n`;
567
+ const once = commentOutMirrorBlock(yaml);
568
+ const twice = commentOutMirrorBlock(once);
569
+ expect(twice).toBe(once); // second pass changes nothing
570
+ });
571
+
572
+ test("no mirror block → returns input unchanged", () => {
573
+ const yaml = `port: 1940
574
+ default_vault: brain
575
+ discovery: enabled
576
+ `;
577
+ expect(commentOutMirrorBlock(yaml)).toBe(yaml);
578
+ });
579
+
580
+ test("mirror block at EOF (no trailing key) is fully commented", () => {
581
+ const block = serializeMirrorConfig({
582
+ ...defaultMirrorConfig(),
583
+ enabled: true,
584
+ auto_commit: false,
585
+ }).join("\n");
586
+ const yaml = `port: 1940\n${block}\n`;
587
+ const out = commentOutMirrorBlock(yaml);
588
+ expect(parseMirrorConfig(out)).toBeUndefined();
589
+ expect(out).toContain("# auto_commit: false");
590
+ expect(out).toContain("port: 1940"); // live, untouched
591
+ });
592
+
593
+ test("preserves a blank line between the mirror block and the next key", () => {
594
+ const block = serializeMirrorConfig({
595
+ ...defaultMirrorConfig(),
596
+ enabled: true,
597
+ }).join("\n");
598
+ // Blank line separates the block from `discovery:` — must stay blank
599
+ // (not commented) and `discovery:` must stay live.
600
+ const yaml = `port: 1940\n${block}\n\ndiscovery: enabled\n`;
601
+ const out = commentOutMirrorBlock(yaml);
602
+ expect(out).toContain("\n\ndiscovery: enabled"); // blank line preserved, key live
603
+ expect(parseMirrorConfig(out)).toBeUndefined();
604
+ });
498
605
  });