@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.
- package/README.md +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- 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"
|
|
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
|
-
//
|
|
180
|
-
//
|
|
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
|
|
194
|
-
"
|
|
195
|
-
"
|
|
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"
|
|
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
|
|
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";
|
|
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
|
|
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("
|
|
243
|
-
|
|
244
|
-
|
|
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
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -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("
|
|
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.
|
|
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
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
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: "
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
//
|
|
1029
|
-
// walkthrough mustn't fire when a flag is present, so its
|
|
1030
|
-
//
|
|
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", "--
|
|
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
|
|
1036
|
-
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|