@openparachute/vault 0.4.8 → 0.4.9-rc.11

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 (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/src/mcp-tools.ts CHANGED
@@ -14,14 +14,23 @@ 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 } 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,
21
21
  noteWithinTagScope,
22
22
  tagsWithinScope,
23
23
  } from "./tag-scope.ts";
24
- import { findTokensReferencingTag } from "./token-store.ts";
24
+ import {
25
+ findTokensReferencingTag,
26
+ recordMcpMintLedger,
27
+ listMcpMintedHubJwts,
28
+ findMcpMintLedgerEntry,
29
+ markMcpMintLedgerRevoked,
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";
25
34
 
26
35
  /**
27
36
  * Filter a vault projection to entries an in-scope tag contributes to.
@@ -102,7 +111,11 @@ export async function getServerInstruction(
102
111
  * When omitted (internal callers that only inspect the tool list — no execute
103
112
  * path exercised), the description-update branch is disabled entirely.
104
113
  */
105
- export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): McpToolDef[] {
114
+ export function generateScopedMcpTools(
115
+ vaultName: string,
116
+ auth?: AuthResult,
117
+ callerBearer?: string | null,
118
+ ): McpToolDef[] {
106
119
  const store = getVaultStore(vaultName);
107
120
  const tools = generateMcpTools(store);
108
121
 
@@ -110,6 +123,13 @@ export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): Mc
110
123
  applyTagDependencyGuards(tools, vaultName);
111
124
  applyTagScopeWrappers(tools, vaultName, auth);
112
125
 
126
+ // manage-token is server-only (needs token-store + auth context), so it
127
+ // lives here rather than in core. Always appended to the surface; the
128
+ // `requiredVerb: "admin"` filter in mcp-http.ts hides it from non-admin
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));
132
+
113
133
  return tools;
114
134
  }
115
135
 
@@ -404,3 +424,440 @@ function overrideVaultInfo(
404
424
  return result;
405
425
  };
406
426
  }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // manage-token (vault#376) — single MCP tool with mint/revoke/list actions
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /**
433
+ * TTL bounds for `manage-token` action=mint, in seconds. Short by design:
434
+ * the design doc (vault#376) calls the tool out as the "AI mints a token
435
+ * for one-shot scripted work, then revokes immediately" surface. A long
436
+ * TTL would defeat the safety story — if revoke fails (network blip,
437
+ * model error), the cap is the backstop. Operators wanting long-lived
438
+ * tokens still use the REST /vault/<name>/tokens endpoint.
439
+ */
440
+ const MANAGE_TOKEN_DEFAULT_TTL_SECONDS = 900; // 15 minutes
441
+ const MANAGE_TOKEN_MAX_TTL_SECONDS = 3600; // 1 hour
442
+
443
+ /**
444
+ * Resolve the bare hub origin for the mint/revoke proxy calls. Reuses
445
+ * `chooseHubOrigin` (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback) so
446
+ * the manage-token proxy targets the same hub the rest of vault talks to.
447
+ * The port is read from global config (same source the server binds on).
448
+ */
449
+ function resolveHubOrigin(): { url: string; source: string } {
450
+ let port = DEFAULT_PORT;
451
+ try {
452
+ port = readGlobalConfig().port || DEFAULT_PORT;
453
+ } catch {
454
+ // Config unreadable (fresh / test fixture) — fall back to the default
455
+ // port; chooseHubOrigin still honors PARACHUTE_HUB_ORIGIN / expose-state.
456
+ }
457
+ return chooseHubOrigin(port);
458
+ }
459
+
460
+ /**
461
+ * Build the manage-token MCP tool, wired to the calling session's auth.
462
+ *
463
+ * After the auth-unification arc (vault#403, MGT) the tool is a thin proxy to
464
+ * hub's mint-token attenuation endpoint: it mints short-TTL HUB JWTs, not
465
+ * deprecated `pvt_*` vault-DB tokens. The DROP step removes the pvt_* mint
466
+ * infra entirely once every consumer has migrated.
467
+ *
468
+ * Closure-captured context:
469
+ * - `vaultName`: every mint requests `vault:<vaultName>:<verb>`; cross-vault
470
+ * and over-scope requests are rejected locally by `validateMintedScopes`
471
+ * (fail-fast) AND by hub's attenuation guard (authoritative).
472
+ * - `auth.scopes`: the caller must hold `vault:<vaultName>:admin` to see the
473
+ * tool (mcp-http.ts visibleTools filter) and to mint; `validateMintedScopes`
474
+ * enforces the requested scope is a same-vault subset of what's held.
475
+ * - `auth.caller_jti`: the minting MCP session's id, recorded as the
476
+ * `parent_jti` in the local ledger so list/revoke stay session-scoped.
477
+ * When NULL (env-var operator / hub JWT without jti) there's no stable
478
+ * session id → list returns empty + revoke returns not_found.
479
+ * - `callerBearer`: the RAW credential the session presented. Only forwarded
480
+ * to hub when JWT-shaped (a hub JWT carrying `vault:<name>:admin`). A
481
+ * non-forwardable credential (env-var secret, legacy pvt_*) yields a clear
482
+ * "mint requires a hub-JWT session" error rather than a fabricated bearer.
483
+ *
484
+ * The execute function is async (mint/revoke do an HTTP round-trip to hub) and
485
+ * returns a discriminated-union response shape: `{action, …}` with `action`
486
+ * matching the requested action. The MCP HTTP layer serializes the result
487
+ * via `JSON.stringify`, so caller-side parsing keys off the action field.
488
+ */
489
+ function buildManageTokenTool(
490
+ vaultName: string,
491
+ auth: AuthResult | undefined,
492
+ callerBearer: string | null,
493
+ ): McpToolDef {
494
+ return {
495
+ name: "manage-token",
496
+ requiredVerb: "admin",
497
+ description:
498
+ "Mint, revoke, or list short-TTL hub JWTs within this MCP session. " +
499
+ "Designed for one-shot AI-driven workflows: mint a narrow token, run a " +
500
+ "script with it, revoke immediately. Minted tokens are short-lived hub " +
501
+ "JWTs (revocable via the hub's token registry), not legacy vault-DB " +
502
+ "tokens. Lifetime defaults to 15 min (max 1 hour). Mints are pinned to " +
503
+ "this vault and attenuated to a subset of the caller's scope — you cannot " +
504
+ "escalate. Minting requires a hub-JWT session holding 'vault:" + vaultName +
505
+ ":admin'. List + revoke are scoped to tokens this session minted; " +
506
+ "CLI/REST-minted tokens are not surfaced here.\n\n" +
507
+ "Actions (discriminator: `action`):\n" +
508
+ "- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
509
+ "- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }\n" +
510
+ "- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
511
+ inputSchema: {
512
+ type: "object",
513
+ properties: {
514
+ action: {
515
+ type: "string",
516
+ enum: ["mint", "revoke", "list"],
517
+ description: "Which action to perform. Required.",
518
+ },
519
+ scope: {
520
+ oneOf: [
521
+ { type: "string" },
522
+ { type: "array", items: { type: "string" } },
523
+ ],
524
+ description:
525
+ "(action=mint) Scope to grant. String like \"vault:write\" or array. Must be a subset of the caller's scope; cross-vault scopes are rejected.",
526
+ },
527
+ ttl_seconds: {
528
+ type: "number",
529
+ description: `(action=mint) Token lifetime in seconds. Default ${MANAGE_TOKEN_DEFAULT_TTL_SECONDS} (15 min), max ${MANAGE_TOKEN_MAX_TTL_SECONDS} (1 hour). Values outside (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}] are rejected.`,
530
+ },
531
+ description: {
532
+ type: "string",
533
+ description: "(action=mint, optional) Free-text label surfaced in the token list + audit trail.",
534
+ },
535
+ jti: {
536
+ type: "string",
537
+ description: "(action=revoke) The jti (e.g. `t_abc123…`) returned by a prior mint. Revoke is idempotent — second revoke also returns ok=true.",
538
+ },
539
+ },
540
+ required: ["action"],
541
+ },
542
+ execute: async (params) => {
543
+ const action = params.action;
544
+
545
+ // Defense-in-depth: the outer filter (mcp-http.ts visibleTools)
546
+ // already requires vault:admin for this vault to see manage-token,
547
+ // so reaching execute means the gate passed. A hand-crafted
548
+ // tools/call bypassing list would still hit the dispatch verb-check
549
+ // in handleScopedMcp. The block below is a third belt-and-suspenders
550
+ // check so a refactor of either layer can't lose the invariant
551
+ // silently.
552
+ if (!auth || !hasScopeForVault(auth.scopes, vaultName, "admin")) {
553
+ return {
554
+ action,
555
+ error: "Forbidden",
556
+ message: `manage-token requires the 'vault:admin' scope (or 'vault:${vaultName}:admin'). Granted: ${auth?.scopes.join(" ") || "(none)"}.`,
557
+ };
558
+ }
559
+
560
+ if (action === "mint") return await mintAction(params, vaultName, auth, callerBearer);
561
+ if (action === "revoke") return await revokeAction(params, vaultName, auth, callerBearer);
562
+ if (action === "list") return listAction(vaultName, auth);
563
+
564
+ return {
565
+ error: "invalid_request",
566
+ message: `manage-token: unknown action "${String(action)}" — expected "mint" | "revoke" | "list".`,
567
+ };
568
+ },
569
+ };
570
+ }
571
+
572
+ /**
573
+ * Normalize a requested scope to the resource-narrowed `vault:<name>:<verb>`
574
+ * shape hub expects. Callers may pass either the broad `vault:<verb>` form
575
+ * (the manage-token v1 surface accepted this) or the explicit
576
+ * `vault:<name>:<verb>` form. We rewrite the broad form to name THIS vault so
577
+ * hub's attenuation guard — which only knows resource-narrowed scopes — sees a
578
+ * `vault:<vaultName>:<verb>` request. A scope already naming a different vault
579
+ * is left untouched (validateMintedScopes rejects it before we get here).
580
+ */
581
+ function narrowScopeForVault(scope: string, vaultName: string): string {
582
+ const parts = scope.split(":");
583
+ // `vault:<verb>` (2 parts) → `vault:<name>:<verb>`.
584
+ if (parts.length === 2 && parts[0] === "vault") {
585
+ return `vault:${vaultName}:${parts[1]}`;
586
+ }
587
+ return scope;
588
+ }
589
+
590
+ async function mintAction(
591
+ params: Record<string, unknown>,
592
+ vaultName: string,
593
+ auth: AuthResult,
594
+ callerBearer: string | null,
595
+ ): Promise<Record<string, unknown>> {
596
+ // Scope parsing: accept string or string[]. Empty/missing is rejected
597
+ // explicitly (no implicit "full scope" default — manage-token always
598
+ // narrows). The validateMintedScopes call then enforces:
599
+ // - shape (recognized vault scope)
600
+ // - vault-pin (cross-vault rejected)
601
+ // - subset of caller's scope on this vault.
602
+ let requested: string[];
603
+ if (typeof params.scope === "string") {
604
+ requested = parseScopes(params.scope);
605
+ } else if (Array.isArray(params.scope)) {
606
+ requested = params.scope.filter((s): s is string => typeof s === "string" && s.length > 0);
607
+ } else {
608
+ return {
609
+ action: "mint",
610
+ error: "invalid_request",
611
+ message: "manage-token mint: `scope` is required (string or string[]).",
612
+ };
613
+ }
614
+ if (requested.length === 0) {
615
+ return {
616
+ action: "mint",
617
+ error: "invalid_request",
618
+ message: "manage-token mint: at least one scope required.",
619
+ };
620
+ }
621
+
622
+ // Fail-fast local guard (defense-in-depth — hub's attenuation is
623
+ // authoritative): cross-vault + over-scope requests are rejected here with a
624
+ // clear message before any HTTP round-trip. The caller cannot request a
625
+ // scope outside their own vault/authority.
626
+ const validation = validateMintedScopes(requested, vaultName, auth.scopes);
627
+ if (!validation.ok) {
628
+ return {
629
+ action: "mint",
630
+ error: "forbidden",
631
+ message: "manage-token mint: scope rejected (must be a subset of the caller's scope on this vault).",
632
+ rejected: validation.rejected,
633
+ };
634
+ }
635
+
636
+ // Forwardability: minting is a proxy to hub's attenuation endpoint, so the
637
+ // caller must present a forwardable hub-JWT bearer carrying
638
+ // `vault:<name>:admin`. A non-JWT credential (env-var operator secret,
639
+ // legacy pvt_*) can't be forwarded — and wouldn't carry mint authority at
640
+ // hub anyway — so fail with a clear, actionable error rather than
641
+ // fabricating a bearer.
642
+ //
643
+ // `looksLikeJwt` is a SYNTACTIC hint only (startsWith("eyJ") — the base64url
644
+ // of a JWS header `{"`). It does NOT verify the signature, issuer, scopes,
645
+ // or that the bearer actually grants mint authority. That's intentional:
646
+ // hub's mint-token attenuation guard is the authoritative gate (it validates
647
+ // the bearer and rejects anything it couldn't have minted). This check just
648
+ // avoids forwarding a credential we already know can't be a hub JWT.
649
+ if (!callerBearer || !looksLikeJwt(callerBearer)) {
650
+ return {
651
+ action: "mint",
652
+ error: "forbidden",
653
+ message:
654
+ `manage-token mint requires a hub-JWT session holding 'vault:${vaultName}:admin'. ` +
655
+ "This session authenticated with a non-forwardable credential (operator " +
656
+ "env-var token or legacy vault-DB token); mint a token via the hub admin " +
657
+ "UI / CLI instead, or reconnect MCP with a hub-issued JWT.",
658
+ };
659
+ }
660
+
661
+ // TTL bounds. Default 900 (15 min); explicit values must satisfy
662
+ // `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS`. Zero, negative, NaN, and
663
+ // beyond-max all reject — the cap is the safety backstop if revoke fails,
664
+ // so it must be strict.
665
+ let ttl = MANAGE_TOKEN_DEFAULT_TTL_SECONDS;
666
+ if (params.ttl_seconds !== undefined && params.ttl_seconds !== null) {
667
+ if (typeof params.ttl_seconds !== "number" || !Number.isFinite(params.ttl_seconds)) {
668
+ return {
669
+ action: "mint",
670
+ error: "invalid_request",
671
+ message: "manage-token mint: ttl_seconds must be a finite number.",
672
+ };
673
+ }
674
+ if (params.ttl_seconds <= 0 || params.ttl_seconds > MANAGE_TOKEN_MAX_TTL_SECONDS) {
675
+ return {
676
+ action: "mint",
677
+ error: "invalid_request",
678
+ message: `manage-token mint: ttl_seconds must be in (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}]; got ${params.ttl_seconds}.`,
679
+ };
680
+ }
681
+ ttl = params.ttl_seconds;
682
+ }
683
+
684
+ const description = typeof params.description === "string" && params.description.length > 0
685
+ ? params.description
686
+ : null;
687
+ const label = description ?? `mcp-mint (parent=${auth.caller_jti ?? "unknown"})`;
688
+
689
+ // Resolve hub origin (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback).
690
+ const hub = resolveHubOrigin();
691
+
692
+ // Build the mint-token request. Scopes are narrowed to the resource-named
693
+ // `vault:<name>:<verb>` form hub's attenuation guard requires. Tag-scoping
694
+ // (when the caller is tag-scoped) rides along as `permissions.scoped_tags`
695
+ // so the minted hub JWT carries the same restriction — vault enforces it on
696
+ // read via C0 (vault#403). Unscoped callers omit `permissions`.
697
+ const narrowedScopes = requested.map((s) => narrowScopeForVault(s, vaultName));
698
+ const permissions =
699
+ auth.scoped_tags && auth.scoped_tags.length > 0
700
+ ? { scoped_tags: auth.scoped_tags }
701
+ : undefined;
702
+
703
+ const minted = await mintHubJwt({
704
+ hubOrigin: hub.url,
705
+ operatorToken: callerBearer,
706
+ scope: narrowedScopes.join(" "),
707
+ expiresInSeconds: ttl,
708
+ ...(permissions !== undefined ? { permissions } : {}),
709
+ });
710
+
711
+ if ("kind" in minted) {
712
+ // Surface a clear, action-keyed error. Network → "hub unreachable";
713
+ // api-error → hub's own error_description (e.g. attenuation rejection).
714
+ if (minted.kind === "network") {
715
+ return {
716
+ action: "mint",
717
+ error: "hub_unreachable",
718
+ message: `manage-token mint: could not reach hub at ${minted.origin} (${minted.cause}). Check PARACHUTE_HUB_ORIGIN / that the hub is running.`,
719
+ };
720
+ }
721
+ return {
722
+ action: "mint",
723
+ error: "hub_rejected",
724
+ message: `manage-token mint: hub rejected the request (${minted.error}: ${minted.description}).`,
725
+ hub_status: minted.status,
726
+ };
727
+ }
728
+
729
+ // Record in the session-pinned ledger so list/revoke can scope to this
730
+ // session's mints. The signed JWT is never stored — only its jti (the
731
+ // revocation handle) + display metadata. NULL caller_jti (env-var / no-jti
732
+ // sessions) can't pass the forwardability gate above, so by here caller_jti
733
+ // is effectively the JWT's jti; we still guard defensively.
734
+ const store = getVaultStore(vaultName);
735
+ if (auth.caller_jti) {
736
+ recordMcpMintLedger(store.db, {
737
+ jti: minted.jti,
738
+ parentJti: auth.caller_jti,
739
+ vaultName,
740
+ label,
741
+ scopes: narrowedScopes,
742
+ scopedTags: auth.scoped_tags,
743
+ expiresAt: minted.expires_at,
744
+ });
745
+ }
746
+
747
+ return {
748
+ action: "mint",
749
+ token: minted.token,
750
+ jti: minted.jti,
751
+ expires_at: minted.expires_at,
752
+ scopes: narrowedScopes,
753
+ scoped_tags: auth.scoped_tags,
754
+ vault_name: vaultName,
755
+ };
756
+ }
757
+
758
+ async function revokeAction(
759
+ params: Record<string, unknown>,
760
+ vaultName: string,
761
+ auth: AuthResult,
762
+ callerBearer: string | null,
763
+ ): Promise<Record<string, unknown>> {
764
+ if (typeof params.jti !== "string" || params.jti.length === 0) {
765
+ return {
766
+ action: "revoke",
767
+ ok: false,
768
+ error: "invalid_request",
769
+ message: "manage-token revoke: `jti` is required (string).",
770
+ };
771
+ }
772
+ const jti = params.jti;
773
+
774
+ // Session-pin: revoke is restricted to hub JWTs THIS MCP session minted.
775
+ // When auth.caller_jti is null (no stable session id — env-var operator,
776
+ // legacy YAML key, hub JWT without jti), there are no attributable mints,
777
+ // so revoke returns not_found.
778
+ if (!auth.caller_jti) {
779
+ return {
780
+ action: "revoke",
781
+ ok: false,
782
+ error: "not_found",
783
+ message: "manage-token revoke: this session has no stable id; revoke via the hub admin UI / CLI.",
784
+ };
785
+ }
786
+
787
+ const store = getVaultStore(vaultName);
788
+ const entry = findMcpMintLedgerEntry(store.db, jti, auth.caller_jti, vaultName);
789
+ if (!entry) {
790
+ // Idempotency: not-in-this-session's-ledger returns ok=true so the AI's
791
+ // "mint → run → revoke" loop doesn't surface a confusing failure on a
792
+ // duplicate revoke or a network-blip retry. The "minted by another
793
+ // session" case also lands here; we don't differentiate (no information
794
+ // leak about other sessions' jti space).
795
+ return { action: "revoke", ok: true, note: "no matching token in this session" };
796
+ }
797
+ if (entry.revoked_at) {
798
+ // Already revoked locally — idempotent success, no second hub round-trip.
799
+ return { action: "revoke", ok: true, already_revoked: true };
800
+ }
801
+
802
+ // Forward the revoke to hub's token registry (the authoritative revocation
803
+ // surface — vault is resource-server-only). The caller's `vault:<N>:admin`
804
+ // bearer is forwarded, same as on mint. As of hub#454 this is the
805
+ // expected-SUCCESS path: hub's revoke-token applies capability attenuation
806
+ // symmetric to mint, so a `vault:<N>:admin` bearer may revoke any jti whose
807
+ // scopes it could have minted (and these are exactly the tokens this session
808
+ // minted within that vault's authority). Hub's revoke-token is idempotent.
809
+ //
810
+ // The `"kind" in revoked` branch below is now the EXCEPTION, not the norm —
811
+ // it only fires on a genuine edge (network blip, or a hub-side rejection
812
+ // that shouldn't happen for an in-authority jti). When it does, we still
813
+ // flip the local ledger marker so list reflects the operator's intent, and
814
+ // surface the hub failure so the caller knows the registry-side revoke may
815
+ // not have landed (the short TTL is the backstop either way).
816
+ if (callerBearer && looksLikeJwt(callerBearer)) {
817
+ const hub = resolveHubOrigin();
818
+ const revoked = await revokeHubJwt({
819
+ hubOrigin: hub.url,
820
+ operatorToken: callerBearer,
821
+ jti,
822
+ });
823
+ if ("kind" in revoked) {
824
+ // Unexpected hub failure. Local ledger still flips (operator asked to
825
+ // revoke), but report the hub-side failure so a network blip / scope
826
+ // gap is visible.
827
+ markMcpMintLedgerRevoked(store.db, jti, auth.caller_jti, vaultName);
828
+ if (revoked.kind === "network") {
829
+ return {
830
+ action: "revoke",
831
+ ok: false,
832
+ error: "hub_unreachable",
833
+ 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.`,
834
+ };
835
+ }
836
+ return {
837
+ action: "revoke",
838
+ ok: false,
839
+ error: "hub_rejected",
840
+ message: `manage-token revoke: hub rejected the request (${revoked.error}: ${revoked.description}); local ledger marked revoked. The token's short TTL is the backstop.`,
841
+ hub_status: revoked.status,
842
+ };
843
+ }
844
+ }
845
+
846
+ markMcpMintLedgerRevoked(store.db, jti, auth.caller_jti, vaultName);
847
+ return { action: "revoke", ok: true, already_revoked: false };
848
+ }
849
+
850
+ function listAction(vaultName: string, auth: AuthResult): Record<string, unknown> {
851
+ if (!auth.caller_jti) {
852
+ // No session id → no attributable mints. Return empty list rather
853
+ // than erroring, so callers can branch on tokens.length without
854
+ // exception handling.
855
+ return { action: "list", tokens: [] };
856
+ }
857
+ const store = getVaultStore(vaultName);
858
+ // Read from the hub-JWT mint ledger (vault#403, MGT) — mints now live in
859
+ // hub's registry, not the pvt_* tokens table; the ledger is the local
860
+ // session-attribution index.
861
+ const tokens = listMcpMintedHubJwts(store.db, auth.caller_jti, vaultName);
862
+ return { action: "list", tokens };
863
+ }