@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
@@ -47,7 +47,7 @@ export interface InteractiveIO {
47
47
  * shared backend `installMcpConfig`.
48
48
  */
49
49
  export interface InstallDecision {
50
- mode: "mint" | "token" | "legacy-pat";
50
+ mode: "mint" | "token";
51
51
  scope: "vault:read" | "vault:write" | "vault:admin";
52
52
  installScope: InstallScope;
53
53
  vaultName: string;
@@ -175,9 +175,10 @@ export async function runInteractiveInstall(
175
175
  io.log("");
176
176
  }
177
177
 
178
- // 4. Auth mode + scope. The branching point: hub-mint available, or
179
- // not. When neither operator.token nor hub is configured, we fall
180
- // through to paste/legacy.
178
+ // 4. Auth mode + scope. The branching point: hub-mint available, or not.
179
+ // vault#282 Stage 2 dropped the legacy vault-DB pvt_* mint — vault is a
180
+ // pure hub resource-server. When no hub-mint path exists, the only option
181
+ // is pasting an existing bearer (hub JWT or VAULT_AUTH_TOKEN).
181
182
  const canMint = ctx.hubReachable && ctx.operatorTokenPresent;
182
183
  let mode: InstallDecision["mode"];
183
184
  let scope: InstallDecision["scope"] = "vault:read";
@@ -190,71 +191,43 @@ export async function runInteractiveInstall(
190
191
  "Choices:",
191
192
  " Enter → mint a hub JWT with vault:read scope (recommended).",
192
193
  " write → mint with vault:write (mutations).",
193
- " admin → mint a vault-DB pvt_* with vault:admin (schema management;",
194
- " hub policy reserves per-vault admin for operator-only paths,",
195
- " so this auto-routes to legacy-pat).",
194
+ " admin → mint a hub JWT with vault:<name>:admin (schema management).",
195
+ " Requires parachute:host:admin on the operator token (the",
196
+ " default operator.token carries it).",
196
197
  " paste → use an existing token instead of minting.",
197
- " legacy → mint a vault-DB pvt_* (self-hosted-without-hub).",
198
198
  ].join("\n"),
199
199
  validate: (s) => {
200
- const ok = ["mint", "write", "admin", "paste", "legacy"];
200
+ const ok = ["mint", "write", "admin", "paste"];
201
201
  return ok.includes(s) ? null : `expected one of: ${ok.join(", ")}`;
202
202
  },
203
203
  });
204
204
  if (answer === "paste") {
205
205
  mode = "token";
206
206
  pastedToken = await askToken(io);
207
- } else if (answer === "legacy") {
208
- mode = "legacy-pat";
209
- // Legacy path mints a vault-DB pvt_* with scope narrowing — same
210
- // verb choice as the mint path. Prompt for it explicitly so the
211
- // operator gets the same control they get when widening a hub
212
- // JWT's scope. (vault#292 review F2.)
213
- scope = await askScope(io);
214
207
  } else if (answer === "admin") {
215
- // `vault:<name>:admin` is non-requestable via hub mint-token by
216
- // policy only the session-cookie-gated `/admin/vault-admin-token/:name`
217
- // endpoint can mint per-vault admin scopes (see
218
- // `parachute-hub/src/scope-explanations.ts:VAULT_ADMIN_RE` and
219
- // `api-mint-token.ts`'s non-requestable guard). Hub returns
220
- // HTTP 400 invalid_scope: "scope vault:<name>:admin is not
221
- // requestable via mint-token; use OAuth flow or operator rotation".
222
- //
223
- // Auto-route to legacy-pat which mints a vault-DB pvt_* with full
224
- // permissions — that's the right shape for an operator who wants
225
- // admin scope on a local MCP entry. Print a one-line explanation
226
- // so the switch isn't silent.
227
- io.log(" → admin requires a vault-DB pvt_* (hub policy: per-vault admin");
228
- io.log(" is operator-only, not mintable via the public mint-token API).");
229
- io.log(" Switching to legacy-pat mode with vault:admin scope.");
230
- mode = "legacy-pat";
208
+ // `vault:<name>:admin` is mintable via hub mint-token when the
209
+ // operator's bearer carries `parachute:host:admin` (hub PR-A, hub#449).
210
+ // The default operator.token carries host-admin, so this is the
211
+ // canonical admin path. The verb extraction downstream narrows
212
+ // `vault:admin` `vault:<name>:admin`.
213
+ io.log(" → admin will be minted as a scope-narrowed hub JWT (vault:" + vaultName + ":admin).");
214
+ mode = "mint";
231
215
  scope = "vault:admin";
232
216
  } else {
233
217
  mode = "mint";
234
218
  if (answer === "write") scope = "vault:write";
235
219
  }
236
220
  } else {
237
- // No hub-mint path available — explain why and offer the alternatives.
221
+ // No hub-mint path available — explain why and fall back to paste. There
222
+ // is no local pvt_* mint anymore (vault#282 Stage 2): without a hub, the
223
+ // operator pastes an existing bearer or sets VAULT_AUTH_TOKEN.
238
224
  const reason = !ctx.hubReachable
239
225
  ? "no hub origin configured (PARACHUTE_HUB_ORIGIN unset, no active expose-state)"
240
226
  : "no operator token at ~/.parachute/operator.token";
241
227
  io.log(`Hub-mint isn't available — ${reason}.`);
242
- io.log("Two options: paste an existing token, or mint a vault-DB pvt_* (deprecated).");
243
- const answer = await askPersistent(io, "Which? [paste / legacy]", "paste", {
244
- help: [
245
- " paste → use an existing bearer (hub JWT, pvt_*, anything).",
246
- " legacy → mint a vault-DB pvt_* token (deprecated, vault#288).",
247
- ].join("\n"),
248
- validate: (s) => (s === "paste" || s === "legacy" ? null : "expected: paste or legacy"),
249
- });
250
- if (answer === "paste") {
251
- mode = "token";
252
- pastedToken = await askToken(io);
253
- } else {
254
- mode = "legacy-pat";
255
- // Same scope prompt as the canMint legacy branch (F2).
256
- scope = await askScope(io);
257
- }
228
+ io.log("Paste an existing bearer (a hub JWT, or your VAULT_AUTH_TOKEN operator bearer).");
229
+ mode = "token";
230
+ pastedToken = await askToken(io);
258
231
  }
259
232
  io.log("");
260
233
 
@@ -274,8 +247,7 @@ export async function runInteractiveInstall(
274
247
  env: ctx.env,
275
248
  ...(updateLocation?.entryKey ? { existingEntryKey: updateLocation.entryKey } : {}),
276
249
  });
277
- const bearerPreview =
278
- mode === "token" ? "<your token>" : mode === "mint" ? "<hub-jwt>" : "<pvt_*>";
250
+ const bearerPreview = mode === "token" ? "<your token>" : "<hub-jwt>";
279
251
 
280
252
  io.log(`Here's what I'll write to ${targetLabel}:`);
281
253
  io.log("");
@@ -287,8 +259,6 @@ export async function runInteractiveInstall(
287
259
  io.log("");
288
260
  if (mode === "mint") {
289
261
  io.log(` Scope: ${scope} → narrowed to vault:${vaultName}:${scope.split(":")[1]}.`);
290
- } else if (mode === "legacy-pat") {
291
- io.log(` Scope: ${scope}. The pvt_* token is vault-DB-resident (vault#288 deprecation).`);
292
262
  } else {
293
263
  // mode === "token" (paste). The pasted bearer carries its own scope
294
264
  // claim — we don't inspect or override it; whatever scope the issuer
@@ -348,29 +318,6 @@ function pathTail(p: string): string {
348
318
  return parts.slice(-2).join("/") || p;
349
319
  }
350
320
 
351
- /**
352
- * Prompt for scope when minting a vault-DB pvt_* (legacy-pat). Mirrors
353
- * the mint path's "widen with write/admin" wording so the legacy and
354
- * hub-mint branches surface scope as the same kind of choice. Mint's
355
- * own scope prompt is inline at the auth-mode step (legacy is the
356
- * extra round we couldn't fold there without ambiguity).
357
- */
358
- async function askScope(io: InteractiveIO): Promise<InstallDecision["scope"]> {
359
- const answer = await askPersistent(io, "Press Enter for vault:read (least privilege), or type 'write' or 'admin' to widen", "read", {
360
- help: [
361
- "Scopes for the legacy pvt_* token:",
362
- " read → vault:read (default — listing + reading only).",
363
- " write → vault:write (mutations: create, update, delete notes).",
364
- " admin → vault:admin (full, including schema management).",
365
- ].join("\n"),
366
- validate: (s) => {
367
- const ok = ["read", "write", "admin"];
368
- return ok.includes(s) ? null : `expected one of: ${ok.join(", ")}`;
369
- },
370
- });
371
- return `vault:${answer}` as InstallDecision["scope"];
372
- }
373
-
374
321
  /**
375
322
  * Prompt for a token. Uses `ask` rather than `askPassword` so the operator
376
323
  * can see what they pasted (most clients show the token in plain text in
@@ -27,6 +27,7 @@ import {
27
27
  readOperatorToken,
28
28
  removeMcpConfig,
29
29
  resolveInstallTarget,
30
+ revokeHubJwt,
30
31
  } from "./mcp-install.ts";
31
32
 
32
33
  const CLI = path.resolve(import.meta.dir, "cli.ts");
@@ -428,6 +429,107 @@ describe("mintHubJwt", () => {
428
429
  });
429
430
  expect("kind" in res && res.kind === "api-error").toBe(true);
430
431
  });
432
+
433
+ test("forwards permissions claim to hub when provided (vault#403, MGT)", async () => {
434
+ const calls: Array<{ body: any }> = [];
435
+ const mockFetch: typeof fetch = async (_url, init) => {
436
+ calls.push({ body: JSON.parse(String(init?.body)) });
437
+ return new Response(
438
+ JSON.stringify({
439
+ jti: "jti-tag",
440
+ token: "eyJ.signed.jwt",
441
+ expires_at: "2026-08-09T00:00:00.000Z",
442
+ scope: "vault:default:read",
443
+ }),
444
+ { status: 200, headers: { "Content-Type": "application/json" } },
445
+ );
446
+ };
447
+ await mintHubJwt({
448
+ hubOrigin: "https://hub.example",
449
+ operatorToken: "bearer",
450
+ scope: "vault:default:read",
451
+ permissions: { scoped_tags: ["task"] },
452
+ fetchImpl: mockFetch,
453
+ });
454
+ expect(calls[0]!.body.permissions).toEqual({ scoped_tags: ["task"] });
455
+ });
456
+
457
+ test("omits permissions from the body when not provided", async () => {
458
+ const calls: Array<{ body: any }> = [];
459
+ const mockFetch: typeof fetch = async (_url, init) => {
460
+ calls.push({ body: JSON.parse(String(init?.body)) });
461
+ return new Response(
462
+ JSON.stringify({
463
+ jti: "j",
464
+ token: "eyJ.x.y",
465
+ expires_at: "2026-08-09T00:00:00.000Z",
466
+ scope: "vault:default:read",
467
+ }),
468
+ { status: 200, headers: { "Content-Type": "application/json" } },
469
+ );
470
+ };
471
+ await mintHubJwt({
472
+ hubOrigin: "https://hub.example",
473
+ operatorToken: "bearer",
474
+ scope: "vault:default:read",
475
+ fetchImpl: mockFetch,
476
+ });
477
+ expect("permissions" in calls[0]!.body).toBe(false);
478
+ });
479
+ });
480
+
481
+ describe("revokeHubJwt (vault#403, MGT)", () => {
482
+ test("happy path posts jti + caller bearer, returns revoked_at", async () => {
483
+ const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
484
+ const mockFetch: typeof fetch = async (url, init) => {
485
+ calls.push({ url: String(url), init });
486
+ return new Response(
487
+ JSON.stringify({ jti: "hub_jti_1", revoked_at: "2026-05-28T00:00:00.000Z" }),
488
+ { status: 200, headers: { "Content-Type": "application/json" } },
489
+ );
490
+ };
491
+ const res = await revokeHubJwt({
492
+ hubOrigin: "https://hub.example",
493
+ operatorToken: "caller-bearer",
494
+ jti: "hub_jti_1",
495
+ fetchImpl: mockFetch,
496
+ });
497
+ expect("revoked_at" in res).toBe(true);
498
+ expect(calls[0]!.url).toBe("https://hub.example/api/auth/revoke-token");
499
+ expect(new Headers(calls[0]!.init?.headers).get("authorization")).toBe("Bearer caller-bearer");
500
+ expect(JSON.parse(String(calls[0]!.init?.body)).jti).toBe("hub_jti_1");
501
+ });
502
+
503
+ test("network error returns { kind: 'network' }", async () => {
504
+ const mockFetch: typeof fetch = async () => { throw new Error("connection refused"); };
505
+ const res = await revokeHubJwt({
506
+ hubOrigin: "https://hub.example",
507
+ operatorToken: "b",
508
+ jti: "j",
509
+ fetchImpl: mockFetch,
510
+ });
511
+ expect(res).toEqual({ kind: "network", cause: "connection refused", origin: "https://hub.example" });
512
+ });
513
+
514
+ test("API error surfaces hub error + description", async () => {
515
+ const mockFetch: typeof fetch = async () =>
516
+ new Response(
517
+ JSON.stringify({ error: "insufficient_scope", error_description: "bearer lacks parachute:host:auth" }),
518
+ { status: 403, headers: { "Content-Type": "application/json" } },
519
+ );
520
+ const res = await revokeHubJwt({
521
+ hubOrigin: "https://hub.example",
522
+ operatorToken: "b",
523
+ jti: "j",
524
+ fetchImpl: mockFetch,
525
+ });
526
+ expect(res).toEqual({
527
+ kind: "api-error",
528
+ status: 403,
529
+ error: "insufficient_scope",
530
+ description: "bearer lacks parachute:host:auth",
531
+ });
532
+ });
431
533
  });
432
534
 
433
535
  // ---------------------------------------------------------------------------
@@ -449,12 +551,6 @@ describe("mcp-install flag parsing", () => {
449
551
  expect(res.stderr).toMatch(/mutually exclusive/);
450
552
  });
451
553
 
452
- test("rejects mutually exclusive --token and --legacy-pat", () => {
453
- const res = runCli(["mcp-install", "--token", "abc", "--legacy-pat"], tmp);
454
- expect(res.exitCode).toBe(1);
455
- expect(res.stderr).toMatch(/mutually exclusive/);
456
- });
457
-
458
554
  test("rejects --token without a value", () => {
459
555
  const res = runCli(["mcp-install", "--token"], tmp);
460
556
  expect(res.exitCode).toBe(1);
@@ -523,44 +619,53 @@ describe("mcp-install flag parsing", () => {
523
619
  expect(res.stderr).toMatch(/No hub origin configured/);
524
620
  });
525
621
 
526
- test("rejects --mint --scope vault:admin pre-flight (hub policy: per-vault admin is non-requestable)", () => {
527
- // Regression for the symptom Aaron hit on hub 0.5.12-rc.2 / vault
528
- // 0.4.7-rc.1: `parachute vault mcp-install` with the "admin" mint
529
- // option sent `vault:default:admin` to `POST /api/auth/mint-token`,
530
- // and hub responded:
531
- //
532
- // Hub mint-token rejected (HTTP 400, invalid_scope):
533
- // scope vault:default:admin is not requestable via mint-token;
534
- // use OAuth flow or operator rotation
535
- //
536
- // The combination is invalid by hub policy (see
537
- // `parachute-hub/src/scope-explanations.ts:VAULT_ADMIN_RE` and
538
- // `api-mint-token.ts`'s non-requestable guard) — per-vault admin
539
- // is operator-only, mintable only through the session-cookie-gated
540
- // `/admin/vault-admin-token/:name` SPA path.
622
+ test("--mint --scope vault:admin is no longer pre-flight-rejected — it reaches the hub mint call (hub PR-A / hub#449)", () => {
623
+ // Before hub PR-A (hub#449), vault rejected `--mint --scope vault:admin`
624
+ // pre-flight because hub's mint-token endpoint refused per-vault admin
625
+ // by policy. PR-A made hub mint `vault:<name>:admin` when the operator
626
+ // bearer carries `parachute:host:admin` (the default operator.token
627
+ // does), so admin is now a normal mint. This test confirms the
628
+ // pre-flight reject is GONE: admin passes the operator-token + hub-origin
629
+ // checks and reaches the actual `POST /api/auth/mint-token` call.
541
630
  //
542
- // The fix rejects the combination pre-flight in vault's mcp-install
543
- // with a clear remediation pointing at `--legacy-pat --scope vault:admin`
544
- // (which mints a vault-DB pvt_* with admin scope — the right shape
545
- // for a local MCP entry needing schema management).
631
+ // We point at an unreachable loopback origin so the mint fails fast in
632
+ // the network branch the assertion is on what's ABSENT (the old
633
+ // "not requestable" / legacy-pat-remediation copy), proving admin is no
634
+ // longer special-cased before the network hit.
546
635
  setupBareVault(tmp, "default");
547
636
  fs.writeFileSync(path.join(tmp, "operator.token"), "operator-bearer-stub");
548
637
  const res = runCli(
549
638
  ["mcp-install", "--mint", "--scope", "vault:admin"],
550
639
  tmp,
551
- { PARACHUTE_HUB_ORIGIN: "https://hub.example.org" },
640
+ { PARACHUTE_HUB_ORIGIN: "http://127.0.0.1:1" },
552
641
  );
642
+ // Still exits 1 (the loopback hub is unreachable), but via the network
643
+ // branch — not the old admin pre-flight reject.
553
644
  expect(res.exitCode).toBe(1);
554
- // Surface the policy reason so the operator knows why this combo is
555
- // rejected (not a transient bug).
556
- expect(res.stderr).toMatch(/not requestable via mint-token/);
557
- // Point at the working remediation.
558
- expect(res.stderr).toMatch(/--legacy-pat --scope vault:admin/);
559
- // Pre-flight must fire BEFORE the operator-token / hub-origin checks
560
- // pass the request to the network — no "Hub unreachable" / "No hub
561
- // origin configured" leak.
645
+ // The old policy-reject copy must be gone.
646
+ expect(res.stderr).not.toMatch(/not requestable via mint-token/);
647
+ expect(res.stderr).not.toMatch(/--legacy-pat --scope vault:admin/);
648
+ // It got past the operator-token + hub-origin pre-flight checks.
649
+ expect(res.stderr).not.toMatch(/No operator token found/);
562
650
  expect(res.stderr).not.toMatch(/No hub origin configured/);
563
- expect(res.stderr).not.toMatch(/Hub unreachable/);
651
+ // It actually attempted the mint and hit the network failure branch.
652
+ expect(res.stderr).toMatch(/Hub unreachable/);
653
+ });
654
+
655
+ test("`--help` text says vault:admin is mintable via --mint (regression guard for hub#449)", () => {
656
+ // The P0 on vault#397 review: the `usage()` mcp-install block still said
657
+ // admin "requires --legacy-pat" / "rejected pre-flight" after hub#449 made
658
+ // it mintable. Three comment blocks were updated but this literal slipped.
659
+ // This test pins the help string so the regression can't recur silently.
660
+ const res = runCli(["--help"], tmp);
661
+ expect(res.exitCode).toBe(0);
662
+ // Help text wraps across lines, so collapse whitespace before matching.
663
+ const help = res.stdout.replace(/\s+/g, " ");
664
+ // Admin is mintable via --mint now.
665
+ expect(help).toMatch(/--scope vault:admin IS mintable via --mint/);
666
+ // The old false claims must be gone.
667
+ expect(help).not.toMatch(/rejected pre-flight/);
668
+ expect(help).not.toMatch(/the only path for --scope vault:admin/);
564
669
  });
565
670
  });
566
671
 
@@ -825,21 +930,17 @@ describe("mcp-install end-to-end", () => {
825
930
  expect(config.mcpServers["parachute-vault-default"]).toBeUndefined();
826
931
  });
827
932
 
828
- test("--legacy-pat mints a vault-DB pvt_* token and prints deprecation warning", () => {
933
+ test("--legacy-pat is gone (vault#282 Stage 2) — no pvt_* mint path remains", () => {
934
+ // The flag was removed: vault is a pure hub resource-server, so there's no
935
+ // local pvt_* mint. With no operator.token in this sandbox, the default
936
+ // --mint path can't reach a hub and exits non-zero with actionable
937
+ // guidance — and crucially no `pvt_*` bearer is ever written.
829
938
  setupBareVault(tmp, "default");
830
939
  const res = runCli(["mcp-install", "--install-scope", "user", "--legacy-pat"], tmp);
831
- expect(res.exitCode).toBe(0);
832
- // Deprecation warning lands on stderr (we used `console.error` for the
833
- // notice so it's visible without polluting stdout). The message names
834
- // the tracking issue + planned-removal milestone so operators can
835
- // see what they're opting into.
836
- expect(res.stderr).toMatch(/--legacy-pat mints a vault-DB pvt_/);
837
- expect(res.stderr).toMatch(/canonical install going forward/);
838
- expect(res.stderr).toMatch(/vault#288/);
839
- expect(res.stderr).toMatch(/planned removal 0\.6\.0/);
840
- const config = readJson(path.join(tmp, ".claude.json"));
841
- const bearer = config.mcpServers["parachute-vault"].headers.Authorization;
842
- expect(bearer).toMatch(/^Bearer pvt_/);
940
+ expect(res.exitCode).not.toBe(0);
941
+ expect(res.stderr).toMatch(/operator token|hub origin/i);
942
+ expect(res.stdout).not.toMatch(/pvt_/);
943
+ expect(res.stderr).not.toMatch(/pvt_/);
843
944
  });
844
945
 
845
946
  test("--dry-run describes the write without touching disk or hitting the hub", () => {
@@ -1025,16 +1126,16 @@ describe("mcp-install interactive dispatch", () => {
1025
1126
  });
1026
1127
 
1027
1128
  test("any install-shaping flag bypasses the walkthrough", () => {
1028
- // --legacy-pat triggers the flag-driven path even on a TTY. The
1029
- // walkthrough mustn't fire when a flag is present, so its
1030
- // "Setting up…" banner must not appear in output.
1129
+ // A recognized flag (`--token`) triggers the flag-driven path. The
1130
+ // walkthrough mustn't fire when a flag is present, so its "Setting up…"
1131
+ // banner must not appear in output. (vault#282 Stage 2 removed --legacy-pat
1132
+ // — --token is the deterministic no-hub-needed flag-driven path now.)
1031
1133
  setupBareVault(tmp, "default");
1032
- const res = runCli(["mcp-install", "--legacy-pat"], tmp);
1134
+ const res = runCli(["mcp-install", "--install-scope", "user", "--token", "hub.jwt.value"], tmp);
1033
1135
  expect(res.exitCode).toBe(0);
1034
1136
  expect(res.stdout).not.toMatch(/Setting up Parachute Vault/);
1035
- // The deprecation banner *should* show confirms flag-driven path
1036
- // ran end-to-end.
1037
- expect(res.stderr).toMatch(/--legacy-pat mints a vault-DB pvt_/);
1137
+ // The supplied-token path ran end-to-end (skips minting).
1138
+ expect(res.stdout).toMatch(/Using supplied token/);
1038
1139
  });
1039
1140
  });
1040
1141
 
@@ -257,18 +257,34 @@ export type MintHubJwtError =
257
257
 
258
258
  export interface MintHubJwtOpts {
259
259
  hubOrigin: string;
260
+ /**
261
+ * Bearer presented on the mint-token request. For the CLI install path this
262
+ * is `~/.parachute/operator.token`; for the manage-token MCP proxy
263
+ * (vault#403, MGT) it's the RAW caller bearer the MCP session presented (a
264
+ * hub JWT carrying `vault:<name>:admin`). Hub's capability-attenuation guard
265
+ * (hub#452) decides what the bearer may mint.
266
+ */
260
267
  operatorToken: string;
261
268
  scope: string;
262
269
  subject?: string;
263
270
  expiresInSeconds?: number;
271
+ /**
272
+ * Optional `permissions` claim forwarded verbatim to hub's mint-token body
273
+ * (e.g. `{ scoped_tags: [...] }` so the minted JWT carries the tag
274
+ * restriction vault enforces via C0). Omitted from the request body when
275
+ * undefined. See `parachute-hub/src/api-mint-token.ts` (permissions claim).
276
+ */
277
+ permissions?: Record<string, unknown>;
264
278
  /** Test seam — defaults to global fetch. */
265
279
  fetchImpl?: typeof fetch;
266
280
  }
267
281
 
268
282
  /**
269
- * POST to `<hub>/api/auth/mint-token`. The operator-token bearer must carry
270
- * `parachute:host:auth` (the admin scope-set covers it). Returns the minted
271
- * JWT or a discriminated error the caller turns into a clear message.
283
+ * POST to `<hub>/api/auth/mint-token`. The bearer must carry minting authority
284
+ * per hub's attenuation rules — `parachute:host:auth` (CLI operator token) or
285
+ * `vault:<name>:admin` (the manage-token MCP proxy's caller bearer). Returns
286
+ * the minted JWT or a discriminated error the caller turns into a clear
287
+ * message.
272
288
  *
273
289
  * Network errors are caught and returned as `{ kind: "network" }` rather
274
290
  * than bubbling — the CLI doesn't want stack traces, and the operator wants
@@ -281,6 +297,7 @@ export async function mintHubJwt(opts: MintHubJwtOpts): Promise<MintedHubJwt | M
281
297
  expires_in: opts.expiresInSeconds ?? HUB_MINT_DEFAULT_TTL_SECONDS,
282
298
  };
283
299
  if (opts.subject) body.subject = opts.subject;
300
+ if (opts.permissions !== undefined) body.permissions = opts.permissions;
284
301
 
285
302
  const fetchFn = opts.fetchImpl ?? fetch;
286
303
  let res: Response;
@@ -334,6 +351,95 @@ export async function mintHubJwt(opts: MintHubJwtOpts): Promise<MintedHubJwt | M
334
351
  };
335
352
  }
336
353
 
354
+ // ---------------------------------------------------------------------------
355
+ // Hub revoke-token client (vault#403, MGT)
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Discriminated failure modes from `revokeHubJwt`. `network` mirrors
360
+ * `mintHubJwt`; `api-error` carries hub's `{ error, error_description }`.
361
+ * Note: hub's revoke-token is idempotent (re-revoking an already-revoked
362
+ * jti returns 200), so a successful idempotent re-revoke is NOT an error.
363
+ */
364
+ export type RevokeHubJwtError =
365
+ | { kind: "network"; cause: string; origin: string }
366
+ | { kind: "api-error"; status: number; error: string; description: string };
367
+
368
+ export interface RevokeHubJwtOpts {
369
+ hubOrigin: string;
370
+ /**
371
+ * RAW caller bearer. As of hub#454, a `vault:<N>:admin` hub JWT is
372
+ * sufficient to revoke any jti whose scopes it could have minted (same-vault
373
+ * capability attenuation, symmetric to mint) — so the manage-token proxy
374
+ * forwards the caller's own `vault:<N>:admin` bearer here. A
375
+ * `parachute:host:auth` operator bearer also works (it can revoke anything).
376
+ */
377
+ operatorToken: string;
378
+ /** The hub jti to revoke. */
379
+ jti: string;
380
+ /** Test seam — defaults to global fetch. */
381
+ fetchImpl?: typeof fetch;
382
+ }
383
+
384
+ export interface RevokedHubJwt {
385
+ jti: string;
386
+ revoked_at: string;
387
+ }
388
+
389
+ /**
390
+ * POST to `<hub>/api/auth/revoke-token` with `{ jti }`.
391
+ *
392
+ * As of hub#454, hub's revoke-token applies capability attenuation symmetric
393
+ * to mint: a `vault:<N>:admin` bearer may revoke any jti whose scopes it could
394
+ * have minted (same-vault subsets), and `parachute:host:auth` may revoke
395
+ * anything. So the manage-token proxy's caller `vault:<N>:admin` bearer is the
396
+ * expected-success path for revoking tokens it minted within that vault's
397
+ * authority — `parachute:host:auth` is no longer required.
398
+ *
399
+ * Idempotent on hub's side (re-revoking an already-revoked jti returns 200).
400
+ * Network failures are caught and returned, not thrown.
401
+ */
402
+ export async function revokeHubJwt(opts: RevokeHubJwtOpts): Promise<RevokedHubJwt | RevokeHubJwtError> {
403
+ const url = `${opts.hubOrigin.replace(/\/$/, "")}/api/auth/revoke-token`;
404
+ const fetchFn = opts.fetchImpl ?? fetch;
405
+ let res: Response;
406
+ try {
407
+ res = await fetchFn(url, {
408
+ method: "POST",
409
+ headers: {
410
+ "Authorization": `Bearer ${opts.operatorToken}`,
411
+ "Content-Type": "application/json",
412
+ },
413
+ body: JSON.stringify({ jti: opts.jti }),
414
+ });
415
+ } catch (err) {
416
+ const cause = err instanceof Error ? err.message : String(err);
417
+ return { kind: "network", cause, origin: opts.hubOrigin };
418
+ }
419
+
420
+ if (!res.ok) {
421
+ let error = "unknown_error";
422
+ let description = `HTTP ${res.status}`;
423
+ try {
424
+ const payload = (await res.json()) as { error?: unknown; error_description?: unknown };
425
+ if (typeof payload.error === "string") error = payload.error;
426
+ if (typeof payload.error_description === "string") description = payload.error_description;
427
+ } catch {}
428
+ return { kind: "api-error", status: res.status, error, description };
429
+ }
430
+
431
+ const payload = (await res.json()) as Partial<RevokedHubJwt>;
432
+ if (typeof payload.jti !== "string" || typeof payload.revoked_at !== "string") {
433
+ return {
434
+ kind: "api-error",
435
+ status: res.status,
436
+ error: "malformed_response",
437
+ description: "hub revoke-token response is missing required fields (jti/revoked_at)",
438
+ };
439
+ }
440
+ return { jti: payload.jti, revoked_at: payload.revoked_at };
441
+ }
442
+
337
443
  // ---------------------------------------------------------------------------
338
444
  // `mcp-config` — emit the JSON shape `claude -p --mcp-config '<json>'` expects
339
445
  // ---------------------------------------------------------------------------