@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
@@ -190,9 +190,9 @@ export async function runInteractiveInstall(
190
190
  "Choices:",
191
191
  " Enter → mint a hub JWT with vault:read scope (recommended).",
192
192
  " 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).",
193
+ " admin → mint a hub JWT with vault:<name>:admin (schema management).",
194
+ " Requires parachute:host:admin on the operator token (the",
195
+ " default operator.token carries it). NOT legacy-pat.",
196
196
  " paste → use an existing token instead of minting.",
197
197
  " legacy → mint a vault-DB pvt_* (self-hosted-without-hub).",
198
198
  ].join("\n"),
@@ -212,22 +212,13 @@ export async function runInteractiveInstall(
212
212
  // JWT's scope. (vault#292 review F2.)
213
213
  scope = await askScope(io);
214
214
  } 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";
215
+ // `vault:<name>:admin` is now mintable via hub mint-token when the
216
+ // operator's bearer carries `parachute:host:admin` (hub PR-A, hub#449).
217
+ // The default operator.token carries host-admin, so this is the
218
+ // canonical admin path — no more legacy-pat fallback. The verb
219
+ // extraction downstream narrows `vault:admin` `vault:<name>:admin`.
220
+ io.log(" → admin will be minted as a scope-narrowed hub JWT (vault:" + vaultName + ":admin).");
221
+ mode = "mint";
231
222
  scope = "vault:admin";
232
223
  } else {
233
224
  mode = "mint";
@@ -243,7 +234,7 @@ export async function runInteractiveInstall(
243
234
  const answer = await askPersistent(io, "Which? [paste / legacy]", "paste", {
244
235
  help: [
245
236
  " paste → use an existing bearer (hub JWT, pvt_*, anything).",
246
- " legacy → mint a vault-DB pvt_* token (deprecated, vault#288).",
237
+ " legacy → mint a vault-DB pvt_* token (deprecated, vault#282).",
247
238
  ].join("\n"),
248
239
  validate: (s) => (s === "paste" || s === "legacy" ? null : "expected: paste or legacy"),
249
240
  });
@@ -288,7 +279,7 @@ export async function runInteractiveInstall(
288
279
  if (mode === "mint") {
289
280
  io.log(` Scope: ${scope} → narrowed to vault:${vaultName}:${scope.split(":")[1]}.`);
290
281
  } else if (mode === "legacy-pat") {
291
- io.log(` Scope: ${scope}. The pvt_* token is vault-DB-resident (vault#288 deprecation).`);
282
+ io.log(` Scope: ${scope}. The pvt_* token is vault-DB-resident (vault#282 deprecation).`);
292
283
  } else {
293
284
  // mode === "token" (paste). The pasted bearer carries its own scope
294
285
  // claim — we don't inspect or override it; whatever scope the issuer
@@ -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
  // ---------------------------------------------------------------------------
@@ -523,44 +625,53 @@ describe("mcp-install flag parsing", () => {
523
625
  expect(res.stderr).toMatch(/No hub origin configured/);
524
626
  });
525
627
 
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.
628
+ test("--mint --scope vault:admin is no longer pre-flight-rejected — it reaches the hub mint call (hub PR-A / hub#449)", () => {
629
+ // Before hub PR-A (hub#449), vault rejected `--mint --scope vault:admin`
630
+ // pre-flight because hub's mint-token endpoint refused per-vault admin
631
+ // by policy. PR-A made hub mint `vault:<name>:admin` when the operator
632
+ // bearer carries `parachute:host:admin` (the default operator.token
633
+ // does), so admin is now a normal mint. This test confirms the
634
+ // pre-flight reject is GONE: admin passes the operator-token + hub-origin
635
+ // checks and reaches the actual `POST /api/auth/mint-token` call.
541
636
  //
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).
637
+ // We point at an unreachable loopback origin so the mint fails fast in
638
+ // the network branch the assertion is on what's ABSENT (the old
639
+ // "not requestable" / legacy-pat-remediation copy), proving admin is no
640
+ // longer special-cased before the network hit.
546
641
  setupBareVault(tmp, "default");
547
642
  fs.writeFileSync(path.join(tmp, "operator.token"), "operator-bearer-stub");
548
643
  const res = runCli(
549
644
  ["mcp-install", "--mint", "--scope", "vault:admin"],
550
645
  tmp,
551
- { PARACHUTE_HUB_ORIGIN: "https://hub.example.org" },
646
+ { PARACHUTE_HUB_ORIGIN: "http://127.0.0.1:1" },
552
647
  );
648
+ // Still exits 1 (the loopback hub is unreachable), but via the network
649
+ // branch — not the old admin pre-flight reject.
553
650
  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.
651
+ // The old policy-reject copy must be gone.
652
+ expect(res.stderr).not.toMatch(/not requestable via mint-token/);
653
+ expect(res.stderr).not.toMatch(/--legacy-pat --scope vault:admin/);
654
+ // It got past the operator-token + hub-origin pre-flight checks.
655
+ expect(res.stderr).not.toMatch(/No operator token found/);
562
656
  expect(res.stderr).not.toMatch(/No hub origin configured/);
563
- expect(res.stderr).not.toMatch(/Hub unreachable/);
657
+ // It actually attempted the mint and hit the network failure branch.
658
+ expect(res.stderr).toMatch(/Hub unreachable/);
659
+ });
660
+
661
+ test("`--help` text says vault:admin is mintable via --mint (regression guard for hub#449)", () => {
662
+ // The P0 on vault#397 review: the `usage()` mcp-install block still said
663
+ // admin "requires --legacy-pat" / "rejected pre-flight" after hub#449 made
664
+ // it mintable. Three comment blocks were updated but this literal slipped.
665
+ // This test pins the help string so the regression can't recur silently.
666
+ const res = runCli(["--help"], tmp);
667
+ expect(res.exitCode).toBe(0);
668
+ // Help text wraps across lines, so collapse whitespace before matching.
669
+ const help = res.stdout.replace(/\s+/g, " ");
670
+ // Admin is mintable via --mint now.
671
+ expect(help).toMatch(/--scope vault:admin IS mintable via --mint/);
672
+ // The old false claims must be gone.
673
+ expect(help).not.toMatch(/rejected pre-flight/);
674
+ expect(help).not.toMatch(/the only path for --scope vault:admin/);
564
675
  });
565
676
  });
566
677
 
@@ -835,7 +946,7 @@ describe("mcp-install end-to-end", () => {
835
946
  // see what they're opting into.
836
947
  expect(res.stderr).toMatch(/--legacy-pat mints a vault-DB pvt_/);
837
948
  expect(res.stderr).toMatch(/canonical install going forward/);
838
- expect(res.stderr).toMatch(/vault#288/);
949
+ expect(res.stderr).toMatch(/vault#282/);
839
950
  expect(res.stderr).toMatch(/planned removal 0\.6\.0/);
840
951
  const config = readJson(path.join(tmp, ".claude.json"));
841
952
  const bearer = config.mcpServers["parachute-vault"].headers.Authorization;
@@ -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
  // ---------------------------------------------------------------------------