@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.
- package/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- 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
|
|
194
|
-
"
|
|
195
|
-
"
|
|
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
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// `
|
|
220
|
-
|
|
221
|
-
|
|
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#
|
|
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#
|
|
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
|
package/src/mcp-install.test.ts
CHANGED
|
@@ -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("
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
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
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
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: "
|
|
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
|
-
//
|
|
555
|
-
|
|
556
|
-
expect(res.stderr).toMatch(
|
|
557
|
-
//
|
|
558
|
-
expect(res.stderr).toMatch(
|
|
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
|
-
|
|
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#
|
|
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;
|
package/src/mcp-install.ts
CHANGED
|
@@ -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
|
|
270
|
-
* `parachute:host:auth` (
|
|
271
|
-
*
|
|
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
|
// ---------------------------------------------------------------------------
|