@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
package/src/config.ts CHANGED
@@ -34,8 +34,11 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSy
34
34
  import crypto from "node:crypto";
35
35
 
36
36
  import {
37
+ // vault#400: only the PARSE side is used here now — to detect a legacy
38
+ // server-wide `mirror:` block so the boot migration can relocate it to the
39
+ // owning vault's per-vault file. `writeGlobalConfig` no longer serializes a
40
+ // mirror block (per-vault writes live in `mirror-config.ts`).
37
41
  parseMirrorConfig as parseMirrorSectionFromYaml,
38
- serializeMirrorConfig as serializeMirrorSection,
39
42
  type MirrorConfig as MirrorConfigType,
40
43
  } from "./mirror-config.ts";
41
44
 
@@ -1330,9 +1333,14 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1330
1333
  lines.push(...serializeBackup(config.backup));
1331
1334
  }
1332
1335
 
1333
- if (config.mirror) {
1334
- lines.push(...serializeMirrorSection(config.mirror));
1335
- }
1336
+ // vault#400: mirror config is now PER-VAULT (`data/<vault>/mirror-config.yaml`),
1337
+ // not a server-wide block here. `writeGlobalConfig` deliberately does NOT
1338
+ // re-emit a `mirror:` block — doing so would resurrect the legacy
1339
+ // server-wide config the boot migration just commented out, re-introducing
1340
+ // the "same remote on every vault page" bug. `readGlobalConfig` still parses
1341
+ // a legacy block (below) so the one-time migration can detect + relocate it.
1342
+ // `serializeMirrorSection` remains used by the per-vault writer in
1343
+ // `mirror-config.ts`.
1336
1344
 
1337
1345
  if (config.auto_transcribe) {
1338
1346
  lines.push("auto_transcribe:");
@@ -35,6 +35,7 @@ import {
35
35
  runGitCommitCycle,
36
36
  shouldCommit,
37
37
  } from "./export-watch.ts";
38
+ import { GitNotInstalledError } from "./git-preflight.ts";
38
39
 
39
40
  const CLI = path.resolve(import.meta.dir, "cli.ts");
40
41
 
@@ -504,6 +505,28 @@ describe("runGitCommitCycle", () => {
504
505
  });
505
506
  expect(result.message).toBe("note: Inbox/DonorMeeting");
506
507
  });
508
+
509
+ test("git missing → throws GitNotInstalledError (sync surfaces friendly error, not raw spawn crash)", async () => {
510
+ // vault#415 — the sync/commit path must surface the actionable
511
+ // git-not-installed message (which the manager threads into
512
+ // status.last_error) instead of crashing with a raw "Executable not
513
+ // found in $PATH". Force the preflight to see no git via the `which`
514
+ // seam; no real spawn should be reached.
515
+ fs.writeFileSync(path.join(dir, "Note.md"), "# n\n");
516
+ await expect(
517
+ runGitCommitCycle({
518
+ repoDir: dir,
519
+ template: DEFAULT_COMMIT_TEMPLATE,
520
+ notesChanged: 1,
521
+ vaultName: "default",
522
+ firstNoteTitle: "Note",
523
+ push: false,
524
+ which: () => null,
525
+ }),
526
+ ).rejects.toBeInstanceOf(GitNotInstalledError);
527
+ // The commit cycle bailed at the preflight — no commit landed.
528
+ expect(gitLogOneline(dir)).toHaveLength(1); // only the seed
529
+ });
507
530
  });
508
531
 
509
532
  // ---------------------------------------------------------------------------
@@ -13,6 +13,8 @@
13
13
  * detection. See `parachute-patterns/cookbook/vault-portable-export.md`.
14
14
  */
15
15
 
16
+ import { ensureGitAvailable } from "./git-preflight.ts";
17
+
16
18
  // ---------------------------------------------------------------------------
17
19
  // Commit message templating
18
20
  // ---------------------------------------------------------------------------
@@ -269,11 +271,23 @@ export async function runGitCommitCycle(opts: {
269
271
  push: boolean;
270
272
  /** Override for tests — defaults to `new Date().toISOString()`. */
271
273
  now?: () => string;
274
+ /**
275
+ * Override the git-presence probe (test seam — defaults to `Bun.which`).
276
+ * Inject a fn returning `null` to exercise the git-not-installed path.
277
+ */
278
+ which?: (cmd: string) => string | null;
272
279
  }): Promise<{
273
280
  committed: boolean;
274
281
  message?: string;
275
282
  push?: { attempted: true; ok: boolean; error?: string };
276
283
  }> {
284
+ // Preflight: every step below shells `git`. On a git-less server the first
285
+ // `Bun.spawn(["git", ...])` would throw a raw "Executable not found" error;
286
+ // surface the friendly, actionable GitNotInstalledError so callers can
287
+ // thread it into mirror status (`last_error`) instead of crashing the
288
+ // watch loop with an opaque message.
289
+ ensureGitAvailable(opts.which);
290
+
277
291
  const now = opts.now ?? (() => new Date().toISOString());
278
292
 
279
293
  const add = await gitAddAll(opts.repoDir);
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tests for the shared git-availability preflight (vault#415).
3
+ *
4
+ * Found live: importing a repo on a git-less Amazon Linux EC2 box failed
5
+ * with a raw `Executable not found in $PATH: "git"` 500. The preflight gives
6
+ * every git entry point a fast, friendly, actionable failure instead.
7
+ */
8
+
9
+ import { describe, test, expect } from "bun:test";
10
+ import {
11
+ GitNotInstalledError,
12
+ ensureGitAvailable,
13
+ isGitNotFoundSpawnError,
14
+ } from "./git-preflight.ts";
15
+
16
+ describe("ensureGitAvailable", () => {
17
+ test("throws GitNotInstalledError when which returns null", () => {
18
+ expect(() => ensureGitAvailable(() => null)).toThrow(GitNotInstalledError);
19
+ });
20
+
21
+ test("does not throw when which resolves git", () => {
22
+ expect(() => ensureGitAvailable(() => "/usr/bin/git")).not.toThrow();
23
+ });
24
+
25
+ test("defaults to Bun.which (git is present in this test env)", () => {
26
+ // The test host has git; the default-arg path resolves it cleanly.
27
+ expect(() => ensureGitAvailable()).not.toThrow();
28
+ });
29
+ });
30
+
31
+ describe("GitNotInstalledError message", () => {
32
+ test("is OS-agnostic-but-helpful — names dnf, apt-get, and brew", () => {
33
+ const msg = new GitNotInstalledError().message;
34
+ expect(msg).toContain("git is required for this operation");
35
+ expect(msg).toContain("sudo dnf install git");
36
+ expect(msg).toContain("sudo apt-get install -y git");
37
+ expect(msg).toContain("brew install git");
38
+ });
39
+
40
+ test("carries the GitNotInstalledError name (instanceof + name both work)", () => {
41
+ const err = new GitNotInstalledError();
42
+ expect(err).toBeInstanceOf(GitNotInstalledError);
43
+ expect(err.name).toBe("GitNotInstalledError");
44
+ });
45
+ });
46
+
47
+ describe("isGitNotFoundSpawnError", () => {
48
+ test("matches Bun's executable-not-found message for git", () => {
49
+ expect(
50
+ isGitNotFoundSpawnError(
51
+ new Error('Executable not found in $PATH: "git"'),
52
+ ),
53
+ ).toBe(true);
54
+ });
55
+
56
+ test("matches an ENOENT spawn error mentioning git", () => {
57
+ const err = new Error("spawn git ENOENT") as Error & { code?: string };
58
+ err.code = "ENOENT";
59
+ expect(isGitNotFoundSpawnError(err)).toBe(true);
60
+ });
61
+
62
+ test("does not match an unrelated error", () => {
63
+ expect(isGitNotFoundSpawnError(new Error("network unreachable"))).toBe(false);
64
+ });
65
+
66
+ test("does not match a non-Error value", () => {
67
+ expect(isGitNotFoundSpawnError("git missing")).toBe(false);
68
+ expect(isGitNotFoundSpawnError(null)).toBe(false);
69
+ });
70
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared git-availability preflight.
3
+ *
4
+ * Every git-using entry point in vault (mirror import, mirror sync/commit/
5
+ * push, internal-mirror bootstrap) shells out to the `git` binary via
6
+ * `Bun.spawn(["git", ...])`. On a server where `git` isn't installed (a
7
+ * fresh Amazon Linux / minimal Docker image, etc.) Bun throws a raw
8
+ * `Executable not found in $PATH: "git"` error, which the import route only
9
+ * caught in its generic `internal` 500 branch — surfacing an unhelpful,
10
+ * un-actionable error to the operator.
11
+ *
12
+ * This module centralizes the preflight so every git entry point fails
13
+ * fast with a clear, actionable message that tells the operator HOW to
14
+ * fix it (install git via their distro's package manager).
15
+ *
16
+ * Found live on the gitcoin-parachute EC2 deploy (Amazon Linux, no git).
17
+ * See vault#415-era fix.
18
+ */
19
+
20
+ /**
21
+ * Thrown when `git` is required for an operation but isn't on PATH. Carries
22
+ * an OS-agnostic-but-helpful message with the common install commands so the
23
+ * operator can act without leaving the error surface.
24
+ */
25
+ export class GitNotInstalledError extends Error {
26
+ constructor() {
27
+ super(
28
+ "git is required for this operation but was not found on the server. " +
29
+ "Install git and retry — e.g. `sudo dnf install git` (Amazon Linux / Fedora), " +
30
+ "`sudo apt-get install -y git` (Debian / Ubuntu), or `brew install git` (macOS).",
31
+ );
32
+ this.name = "GitNotInstalledError";
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Throw `GitNotInstalledError` if `git` isn't resolvable on PATH.
38
+ *
39
+ * `which` is a TEST SEAM (default `Bun.which`) so tests can force the
40
+ * git-missing branch without uninstalling git from the test host. Production
41
+ * callers pass nothing and get the real `Bun.which`.
42
+ */
43
+ export function ensureGitAvailable(
44
+ which: (cmd: string) => string | null = Bun.which,
45
+ ): void {
46
+ if (which("git") === null) {
47
+ throw new GitNotInstalledError();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Heuristic: does this error look like the "git executable not found" failure
53
+ * Bun throws when it can't resolve the binary? Used as a belt-and-suspenders
54
+ * catch around `Bun.spawn(["git", ...])` so a spawn that slips past the
55
+ * preflight (race where git is removed between check and spawn, or a code path
56
+ * that didn't preflight) still surfaces the friendly error instead of the raw
57
+ * `Executable not found in $PATH: "git"` string.
58
+ */
59
+ export function isGitNotFoundSpawnError(err: unknown): boolean {
60
+ if (!(err instanceof Error)) return false;
61
+ const msg = err.message ?? "";
62
+ // Bun: `Executable not found in $PATH: "git"`.
63
+ // Node/posix: ENOENT spawn errors mention the missing file.
64
+ return (
65
+ (msg.includes("Executable not found") && msg.includes("git")) ||
66
+ ((err as { code?: string }).code === "ENOENT" && msg.includes("git"))
67
+ );
68
+ }
@@ -87,15 +87,19 @@ interface SignOpts {
87
87
  expiresAtSeconds?: number;
88
88
  omitKid?: boolean;
89
89
  kid?: string;
90
+ /** `permissions` claim (auth-unification C0). Undefined → omit. */
91
+ permissions?: unknown;
90
92
  }
91
93
 
92
94
  async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
93
95
  const iat = Math.floor(Date.now() / 1000);
94
96
  const exp = opts.expiresAtSeconds ?? iat + (opts.ttlSeconds ?? 60);
95
- const builder = new SignJWT({
97
+ const claims: Record<string, unknown> = {
96
98
  scope: opts.scope ?? "vault:read vault:write",
97
99
  client_id: opts.clientId ?? "test-client",
98
- })
100
+ };
101
+ if (opts.permissions !== undefined) claims.permissions = opts.permissions;
102
+ const builder = new SignJWT(claims)
99
103
  .setProtectedHeader(opts.omitKid ? { alg: "RS256" } : { alg: "RS256", kid: opts.kid ?? kp.kid })
100
104
  .setIssuer(opts.iss ?? "http://issuer.invalid")
101
105
  .setSubject(opts.sub ?? "user-1")
@@ -187,6 +191,27 @@ describe("validateHubJwt — happy path", () => {
187
191
  const claims = await validateHubJwt(token, { expectedAudience: "vault.work" });
188
192
  expect(claims.aud).toBe("vault.work");
189
193
  });
194
+
195
+ test("permissions claim surfaces on the validated result (C0)", async () => {
196
+ const token = await signJwt(kp, {
197
+ iss: fixture.origin,
198
+ permissions: { scoped_tags: ["health", "finance"] },
199
+ });
200
+ const claims = await validateHubJwt(token);
201
+ expect(claims.permissions).toEqual({ scoped_tags: ["health", "finance"] });
202
+ });
203
+
204
+ test("no permissions claim → permissions is undefined", async () => {
205
+ const token = await signJwt(kp, { iss: fixture.origin });
206
+ const claims = await validateHubJwt(token);
207
+ expect(claims.permissions).toBeUndefined();
208
+ });
209
+
210
+ test("non-object permissions claim → permissions is undefined (not surfaced)", async () => {
211
+ const token = await signJwt(kp, { iss: fixture.origin, permissions: "not-an-object" });
212
+ const claims = await validateHubJwt(token);
213
+ expect(claims.permissions).toBeUndefined();
214
+ });
190
215
  });
191
216
 
192
217
  describe("validateHubJwt — audience strict-check", () => {
package/src/hub-jwt.ts CHANGED
@@ -59,6 +59,16 @@ const guard = createScopeGuard({ hubOrigin: () => getHubOrigin() });
59
59
  * Scope-shape policy (e.g. "hub-issued tokens may not carry broad
60
60
  * `vault:<verb>` scopes") is enforced one layer up in `authenticateHubJwt`,
61
61
  * not here — this function stays focused on JWT-level concerns.
62
+ *
63
+ * The returned `HubJwtClaims` carries the native `permissions` claim
64
+ * (scope-guard ≥0.4.0-rc.2 parses + surfaces it; `undefined` when absent or
65
+ * not a JSON object). Tag-scope enforcement reads `permissions.scoped_tags`
66
+ * in `authenticateHubJwt` — see auth-unification arc C0.
67
+ *
68
+ * jti policy: scope-guard's `createScopeGuard` defaults `allowMissingJti:
69
+ * false` (per hub#218 / scope-guard #322), so a hub JWT lacking a `jti`
70
+ * claim is rejected here. Vault doesn't opt out — every hub mint stamps a
71
+ * jti, and revocation can't be enforced on tokens we can't index.
62
72
  */
63
73
  export async function validateHubJwt(
64
74
  token: string,
@@ -57,9 +57,9 @@ describe("buildInitSummaryLines", () => {
57
57
  expect(out).not.toContain("Baked into ~/.claude.json");
58
58
  });
59
59
 
60
- test("includes the tokens-create-later hint", () => {
60
+ test("includes the mcp-install-later hint", () => {
61
61
  expect(out).toContain("Token in ~/.claude.json");
62
- expect(out).toContain("parachute vault tokens create");
62
+ expect(out).toContain("parachute-vault mcp-install");
63
63
  });
64
64
 
65
65
  test("omits the Bearer curl example", () => {
@@ -103,9 +103,9 @@ describe("buildInitSummaryLines", () => {
103
103
  expect(out).toContain("your vault isn't reachable by any client");
104
104
  });
105
105
 
106
- test("points to both recovery paths", () => {
106
+ test("points to the mcp-install recovery path (hub JWT)", () => {
107
107
  expect(out).toContain("parachute-vault mcp-install");
108
- expect(out).toContain("parachute vault tokens create");
108
+ expect(out).toContain("mints a hub JWT");
109
109
  });
110
110
 
111
111
  test("does not print any token", () => {
@@ -12,46 +12,73 @@ export type InitSummaryInput = {
12
12
  bindHost: string;
13
13
  port: number;
14
14
  mcpUrl: string;
15
+ /**
16
+ * Guidance from the bootstrap-credential step when no token could be issued
17
+ * (standalone install, no hub reachable — vault#282 Stage 2). Surfaced when
18
+ * the operator wanted a token (`addMcp || addToken`) but `apiKey` is
19
+ * undefined, so they know why and how to make the vault reachable.
20
+ */
21
+ noTokenGuidance?: string | undefined;
15
22
  };
16
23
 
17
24
  /**
18
25
  * Build the post-install summary lines for `vault init`, branched on the
19
- * (addMcp, addToken) decision matrix:
26
+ * (addMcp, addToken, apiKey) decision matrix. Post-0.5.0 the token is a
27
+ * hub-issued JWT minted via operator.token; when no hub is reachable `apiKey`
28
+ * is undefined even though the operator opted in (`addToken`/`addMcp`):
20
29
  *
21
- * Y, Y → token baked into claude.json + printed prominently
22
- * Y, N → token baked into claude.json, hint about `tokens create`
23
- * N, Y → token printed prominently, no claude.json entry
24
- * N, N warning: vault unreachable; both recovery paths listed
30
+ * addMcp, addToken, apiKey → token baked into claude.json + printed
31
+ * addMcp, !addToken, apiKey → token baked into claude.json, hint
32
+ * !addMcp, addToken, apiKey → token printed prominently
33
+ * wanted-token-but-no-hub guidance: no token issued, recovery paths
34
+ * !addMcp, !addToken → warning: vault unreachable; recovery paths
25
35
  */
26
36
  export function buildInitSummaryLines(input: InitSummaryInput): string[] {
27
- const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl } = input;
37
+ const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, noTokenGuidance } = input;
28
38
  const lines: string[] = [];
29
39
  lines.push("");
30
40
  lines.push("---");
31
41
 
42
+ const wantedToken = addMcp || addToken;
43
+
32
44
  if (addMcp && addToken && apiKey) {
33
45
  lines.push("");
34
46
  lines.push(`Your API token: ${apiKey}`);
35
47
  lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
36
48
  lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
37
49
  lines.push(` - Won't be shown again — save it now.`);
38
- } else if (addMcp && !addToken) {
50
+ } else if (addMcp && !addToken && apiKey) {
39
51
  lines.push("");
40
52
  lines.push(
41
- "Token in ~/.claude.json; run `parachute vault tokens create` later if you need one for other clients.",
53
+ "Token in ~/.claude.json; run `parachute-vault mcp-install` later if you need one for other clients.",
42
54
  );
43
55
  } else if (!addMcp && addToken && apiKey) {
44
56
  lines.push("");
45
57
  lines.push(`Your API token: ${apiKey}`);
46
58
  lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
47
59
  lines.push(` - Won't be shown again — save it now.`);
60
+ } else if (wantedToken && !apiKey) {
61
+ // Opted into a token but no hub was reachable to mint one (vault#282
62
+ // Stage 2 — vault no longer mints local pvt_* tokens). Surface why and
63
+ // the recovery paths.
64
+ lines.push("");
65
+ lines.push(
66
+ noTokenGuidance ??
67
+ "No token issued — no hub was reachable to mint a hub JWT.",
68
+ );
69
+ lines.push(
70
+ " Once a hub is running, run `parachute-vault mcp-install` to mint + wire a token,",
71
+ );
72
+ lines.push(
73
+ " or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
74
+ );
48
75
  } else if (!addMcp && !addToken) {
49
76
  lines.push("");
50
77
  lines.push(
51
78
  "You've skipped both MCP install and token generation — your vault isn't reachable by any client.",
52
79
  );
53
80
  lines.push(
54
- " Add Claude Code later with `parachute-vault mcp-install`, or mint a token with `parachute vault tokens create`.",
81
+ " Add Claude Code later with `parachute-vault mcp-install`, which mints a hub JWT (needs a hub running).",
55
82
  );
56
83
  }
57
84
 
@@ -81,7 +108,6 @@ export function buildInitSummaryLines(input: InitSummaryInput): string[] {
81
108
  lines.push(` - Or add Claude Code back anytime: parachute-vault mcp-install`);
82
109
  } else {
83
110
  lines.push(` - Add Claude Code: parachute-vault mcp-install`);
84
- lines.push(` - Mint a token: parachute vault tokens create`);
85
111
  }
86
112
  lines.push(` - Check status: parachute-vault status`);
87
113
  lines.push(` - Edit config: parachute-vault config`);
@@ -189,8 +189,10 @@ describe("mcp-config CLI", () => {
189
189
  expect(res.stderr).toMatch(/--token/);
190
190
  expect(res.stderr).toMatch(/PARACHUTE_VAULT_TOKEN/);
191
191
  // The error message points operators at the workaround paths so they
192
- // can recover without re-reading the docs.
193
- expect(res.stderr).toMatch(/parachute-vault tokens create/);
192
+ // can recover without re-reading the docs. (vault#282 Stage 2: tokens are
193
+ // hub-issued JWTs now, so the hint names mcp-install, not the removed
194
+ // `tokens create`.)
195
+ expect(res.stderr).toMatch(/parachute-vault mcp-install/);
194
196
  expect(res.stderr).toMatch(/--env-vars/);
195
197
  });
196
198
 
package/src/mcp-http.ts CHANGED
@@ -45,13 +45,34 @@ function requiredVerbForTool(tool: { requiredVerb?: VaultVerb }): VaultVerb {
45
45
  return tool.requiredVerb ?? "write";
46
46
  }
47
47
 
48
- /** Handle scoped MCP at /vault/{name}/mcp (single vault). */
49
- export async function handleScopedMcp(req: Request, vaultName: string, auth: AuthResult): Promise<Response> {
48
+ /**
49
+ * Handle scoped MCP at /vault/{name}/mcp (single vault).
50
+ *
51
+ * `callerBearer` is the RAW credential the session presented (from
52
+ * `extractApiKey`). It's threaded into `generateScopedMcpTools` so the
53
+ * manage-token tool can forward it to hub's mint-token attenuation proxy
54
+ * (vault#403, MGT). NULL when the request carried no bearer (auth would have
55
+ * already rejected) — the tool treats a missing/non-JWT bearer as
56
+ * non-forwardable and returns a clear error on mint.
57
+ */
58
+ export async function handleScopedMcp(
59
+ req: Request,
60
+ vaultName: string,
61
+ auth: AuthResult,
62
+ callerBearer?: string | null,
63
+ ): Promise<Response> {
50
64
  // Auth flows through to getServerInstruction so the connect-time
51
65
  // markdown brief is filtered by `scoped_tags` — symmetric with the
52
66
  // JSON `vault-info` wrapper.
53
67
  const instruction = await getServerInstruction(vaultName, auth);
54
- return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, vaultName, auth, instruction);
68
+ return handleMcp(
69
+ req,
70
+ () => generateScopedMcpTools(vaultName, auth, callerBearer ?? null),
71
+ `parachute-vault/${vaultName}`,
72
+ vaultName,
73
+ auth,
74
+ instruction,
75
+ );
55
76
  }
56
77
 
57
78
  async function handleMcp(
@@ -237,12 +237,11 @@ describe("runInteractiveInstall — decision tree", () => {
237
237
  expect(state.logs.some((l) => /expected one of/.test(l))).toBe(true);
238
238
  });
239
239
 
240
- test("hub not reachable: walkthrough offers paste vs legacy (no mint option)", async () => {
240
+ test("hub not reachable: walkthrough falls back to paste-only (vault#282 Stage 2 — no legacy mint)", async () => {
241
241
  const { io, state } = mockIO([
242
- null, // accept install-scope default
243
- "legacy", // pick legacy-pat
244
- null, // accept default scope (read) on the F2 scope prompt
245
- true, // proceed
242
+ null, // accept install-scope default
243
+ "pasted-jwt", // the bearer (no auth-mode prompt — paste is the only path)
244
+ true, // proceed
246
245
  ]);
247
246
  const result = await runInteractiveInstall(
248
247
  baseCtx({ hubReachable: false }),
@@ -250,16 +249,15 @@ describe("runInteractiveInstall — decision tree", () => {
250
249
  );
251
250
  expect(result).not.toBe("abort");
252
251
  if (result === "abort") return;
253
- expect(result.mode).toBe("legacy-pat");
254
- expect(result.scope).toBe("vault:read");
252
+ expect(result.mode).toBe("token");
253
+ expect(result.pastedToken).toBe("pasted-jwt");
255
254
  // Auth prompt should explain the no-hub state.
256
255
  expect(state.logs.some((l) => /Hub-mint isn't available/.test(l))).toBe(true);
257
256
  });
258
257
 
259
- test("hub reachable but no operator.token: also offers paste vs legacy", async () => {
258
+ test("hub reachable but no operator.token: also falls back to paste-only", async () => {
260
259
  const { io, state } = mockIO([
261
260
  null, // accept install-scope default
262
- "paste", // pick paste
263
261
  "pasted-jwt", // the bearer
264
262
  true, // proceed
265
263
  ]);
@@ -287,22 +285,13 @@ describe("runInteractiveInstall — decision tree", () => {
287
285
  expect(result.scope).toBe("vault:write");
288
286
  });
289
287
 
290
- test("typing 'admin' auto-routes to legacy-pat with vault:admin scope (hub mint-token rejects admin)", async () => {
291
- // Regression for the symptom Aaron hit on hub 0.5.12-rc.2 / vault
292
- // 0.4.7-rc.1: picking "admin" in the mint prompt sent
293
- // `vault:default:admin` to `POST /api/auth/mint-token`, which hub
294
- // rejects by policy (per-vault admin is non-requestable; see
295
- // `parachute-hub/src/scope-explanations.ts:VAULT_ADMIN_RE` and
296
- // `api-mint-token.ts`'s non-requestable guard):
297
- //
298
- // Hub mint-token rejected (HTTP 400, invalid_scope):
299
- // scope vault:default:admin is not requestable via mint-token;
300
- // use OAuth flow or operator rotation
301
- //
302
- // Fix: auto-route "admin" in the interactive prompt to legacy-pat
303
- // mode (which mints a vault-DB pvt_* — the right shape for an MCP
304
- // entry needing admin permissions), with a printed explanation so
305
- // the switch isn't silent.
288
+ test("typing 'admin' mints a hub JWT with vault:admin scope (hub PR-A / hub#449)", async () => {
289
+ // As of hub PR-A (hub#449), `POST /api/auth/mint-token` mints
290
+ // `vault:<name>:admin` when the calling operator bearer carries
291
+ // `parachute:host:admin` (which the default operator.token does).
292
+ // So picking "admin" in the mint prompt now resolves to mint mode
293
+ // with vault:admin scope — the verb extraction downstream narrows
294
+ // it to `vault:<name>:admin`. No more legacy-pat auto-route.
306
295
  const { io, state } = mockIO([
307
296
  null, // accept install-scope default
308
297
  "admin",
@@ -311,13 +300,11 @@ describe("runInteractiveInstall — decision tree", () => {
311
300
  const result = await runInteractiveInstall(baseCtx(), io);
312
301
  expect(result).not.toBe("abort");
313
302
  if (result === "abort") return;
314
- expect(result.mode).toBe("legacy-pat");
303
+ expect(result.mode).toBe("mint");
315
304
  expect(result.scope).toBe("vault:admin");
316
- // The auto-route must surface the reason silent re-routing would
317
- // mislead operators who specifically want a hub JWT.
305
+ // The branch surfaces that admin mints a scope-narrowed hub JWT.
318
306
  const logged = state.logs.join("\n");
319
- expect(logged).toMatch(/admin requires a vault-DB pvt_\*/);
320
- expect(logged).toMatch(/hub policy/);
307
+ expect(logged).toMatch(/scope-narrowed hub JWT/);
321
308
  });
322
309
 
323
310
  test("typing 'paste' at the auth prompt switches to token mode + asks for token", async () => {
@@ -334,48 +321,23 @@ describe("runInteractiveInstall — decision tree", () => {
334
321
  expect(result.pastedToken).toBe("my-existing-jwt");
335
322
  });
336
323
 
337
- test("typing 'legacy' at the auth prompt switches to legacy-pat with default scope", async () => {
338
- const { io } = mockIO([
339
- null, // accept install-scope default
340
- "legacy",
341
- null, // accept default scope (read) on the F2 scope prompt
342
- true,
343
- ]);
344
- const result = await runInteractiveInstall(baseCtx(), io);
345
- expect(result).not.toBe("abort");
346
- if (result === "abort") return;
347
- expect(result.mode).toBe("legacy-pat");
348
- expect(result.scope).toBe("vault:read");
349
- });
350
-
351
- test("legacy-pat path: typing 'write' on the scope prompt widens to vault:write (F2)", async () => {
324
+ test("typing 'legacy' at the auth prompt is no longer accepted (vault#282 Stage 2)", async () => {
325
+ // The 'legacy' choice was removed — the only options are mint / write /
326
+ // admin / paste. An unrecognized input re-prompts; we then pick paste.
352
327
  const { io, state } = mockIO([
353
- null, // accept install-scope default
354
- "legacy", // pick legacy-pat
355
- "write", // widen scope
356
- true, // proceed
328
+ null, // accept install-scope default
329
+ "legacy", // no longer a valid choice → validation re-prompt
330
+ "paste", // pick paste
331
+ "my-existing-jwt", // the bearer
332
+ true,
357
333
  ]);
358
334
  const result = await runInteractiveInstall(baseCtx(), io);
359
335
  expect(result).not.toBe("abort");
360
336
  if (result === "abort") return;
361
- expect(result.mode).toBe("legacy-pat");
362
- expect(result.scope).toBe("vault:write");
363
- // Scope-prompt wording must match the mint path's "least privilege" framing.
364
- expect(state.prompts.some((p) => /least privilege/.test(p.question))).toBe(true);
365
- });
366
-
367
- test("legacy-pat path (no-hub branch): scope prompt also fires (F2)", async () => {
368
- const { io } = mockIO([
369
- null, // accept install-scope default
370
- "legacy", // pick legacy (no-hub branch)
371
- "admin", // widen scope
372
- true, // proceed
373
- ]);
374
- const result = await runInteractiveInstall(baseCtx({ hubReachable: false }), io);
375
- expect(result).not.toBe("abort");
376
- if (result === "abort") return;
377
- expect(result.mode).toBe("legacy-pat");
378
- expect(result.scope).toBe("vault:admin");
337
+ expect(result.mode).toBe("token");
338
+ expect(result.pastedToken).toBe("my-existing-jwt");
339
+ // The validator rejected 'legacy' with the expected-one-of message.
340
+ expect(state.logs.some((l) => /expected one of/.test(l))).toBe(true);
379
341
  });
380
342
 
381
343
  test("paste path: preview clarifies scope is determined by the pasted token (F2)", async () => {
@@ -477,10 +439,10 @@ describe("runInteractiveInstall — decision tree", () => {
477
439
  const result = await runInteractiveInstall(baseCtx(), io);
478
440
  expect(result).not.toBe("abort");
479
441
  // The help text should have been logged between the two ask calls.
480
- // It enumerates the choices (mint / write / admin / paste / legacy);
481
- // matching on the "Choices:" header keeps the assertion stable
482
- // against future re-wording of individual lines.
483
- const helpLogged = state.logs.some((l) => /Choices:/.test(l) && /paste/.test(l) && /legacy/.test(l));
442
+ // It enumerates the choices (mint / write / admin / paste vault#282
443
+ // Stage 2 dropped the legacy pvt_* option); matching on the "Choices:"
444
+ // header keeps the assertion stable against future re-wording.
445
+ const helpLogged = state.logs.some((l) => /Choices:/.test(l) && /paste/.test(l));
484
446
  expect(helpLogged).toBe(true);
485
447
  });
486
448