@openparachute/vault 0.5.2-rc.1 → 0.5.2-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.5.2-rc.1",
3
+ "version": "0.5.2-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  buildMcpConfigJson,
57
57
  buildMcpEntryPlan,
58
58
  chooseHubOrigin,
59
+ chooseMcpUrl,
59
60
  detectInstallContext,
60
61
  mintHubJwt,
61
62
  readOperatorToken,
@@ -478,11 +479,16 @@ async function cmdInit(args: string[] = []) {
478
479
  addMcp = true; // non-interactive: preserve the installable-via-pipe default
479
480
  }
480
481
 
481
- // 7b. Surface an API token for other clients? (Codex, Goose, OpenCode,
482
- // Cursor, Zed, Cline, scripts, curl.) Same flag/TTY precedence as MCP.
483
- // Note: a token is always minted when addMcp is true (it gets baked into
484
- // the ~/.claude.json entry); this prompt controls whether that token is
485
- // printed prominently at the end so the user can paste it elsewhere.
482
+ // 7b. Mint an API token for the header-auth / script use case? (Codex,
483
+ // Goose, OpenCode, Cursor, Zed, Cline, scripts, curl.)
484
+ //
485
+ // vault#442: default vault auth is per-user OAuth the Claude Code MCP entry
486
+ // is written WITHOUT a baked bearer, so the first connection does browser
487
+ // sign-in. We mint a token ONLY when the operator explicitly opts in
488
+ // (`--token`, or "yes" at the prompt), and then it's scope-narrow
489
+ // (`vault:<name>:read`), NEVER admin. `--no-token` (and the non-interactive
490
+ // default) skips minting entirely — no auto-mint, no noisy mint-failure on a
491
+ // fresh vault.
486
492
  let addToken: boolean;
487
493
  if (flagTokenOff) {
488
494
  addToken = false;
@@ -490,25 +496,22 @@ async function cmdInit(args: string[] = []) {
490
496
  addToken = true;
491
497
  } else if (process.stdin.isTTY) {
492
498
  addToken = await confirm(
493
- "Generate an API token for other MCP clients (Codex, Goose, OpenCode, Cursor, Zed, Cline), scripts, or curl?",
494
- true,
499
+ "Also mint a header-auth API token for non-OAuth clients / scripts (Codex, Goose, curl)? (OAuth works without one)",
500
+ false,
495
501
  );
496
502
  } else {
497
- addToken = true; // non-interactive: default-yes matches addMcp default
503
+ addToken = false; // non-interactive default: OAuth-first, no auto-mint
498
504
  }
499
505
 
500
- // Mint a token if we need one (for the claude.json entry and/or for
501
- // prominent display) and don't already have one from vault creation
502
- // e.g. a re-run of init against an existing vault. vault#282 Stage 2: vault
503
- // no longer mints pvt_* tokens, so this is a hub JWT via the operator.token
504
- // hub mint-token path (`mintBootstrapCredential`). When no hub is
505
- // reachable, `apiKey` stays undefined and we carry the guidance to the
506
- // summary — the operator runs `mcp-install` once a hub is up, or sets
507
- // VAULT_AUTH_TOKEN.
506
+ // Mint a scope-narrow token ONLY when explicitly opted in and we don't
507
+ // already have one from vault creation. vault#282 Stage 2: vault no longer
508
+ // mints pvt_* tokens this is a hub JWT via the operator.token → hub
509
+ // mint-token path (`mintBootstrapCredential`), scoped `vault:<name>:read`.
510
+ // When no hub is reachable, `apiKey` stays undefined and we carry the
511
+ // guidance to the summary. The default (OAuth) path never reaches here.
508
512
  const defaultVault = globalConfig.default_vault || "default";
509
- const needToken = addMcp || addToken;
510
- if (needToken && !apiKey) {
511
- const credential = await mintBootstrapCredential(defaultVault);
513
+ if (addToken && !apiKey) {
514
+ const credential = await mintBootstrapCredential(defaultVault, "read");
512
515
  apiKey = credential.token ?? undefined;
513
516
  credentialGuidance = credential.guidance;
514
517
  if (!apiKey) console.log(` ${credential.guidance}`);
@@ -517,10 +520,9 @@ async function cmdInit(args: string[] = []) {
517
520
  if (addMcp) {
518
521
  // Goes through `buildMcpEntryPlan` for entryKey + url so this path shares
519
522
  // the writer-side invariant with `executeMcpInstall` — a future URL-shape
520
- // change can't drift between init and mcp-install. The bearer is the hub
521
- // JWT minted above (omitted when no hub was reachable the entry is then
522
- // written unauthenticated, and the operator re-runs `mcp-install` once a
523
- // hub is up).
523
+ // change can't drift between init and mcp-install. By default NO bearer is
524
+ // baked (vault#442 OAuth on first connect); a bearer is embedded only
525
+ // when the operator explicitly opted into a scope-narrow token above.
524
526
  const target = resolveInstallTarget("user");
525
527
  const { entryKey, url, source } = buildMcpEntryPlan({
526
528
  vaultName: defaultVault,
@@ -535,6 +537,9 @@ async function cmdInit(args: string[] = []) {
535
537
  });
536
538
  console.log(`MCP URL: ${url} (${source})`);
537
539
  console.log(` MCP server added to ~/.claude.json`);
540
+ if (!apiKey) {
541
+ console.log(` No token baked in — you'll sign in via OAuth on first connect.`);
542
+ }
538
543
  } else {
539
544
  console.log(" Skipped adding MCP to ~/.claude.json.");
540
545
  console.log(" Run `parachute-vault mcp-install` later if you want it.");
@@ -551,6 +556,7 @@ async function cmdInit(args: string[] = []) {
551
556
  bindHost,
552
557
  port,
553
558
  mcpUrl,
559
+ vaultName: defaultVault,
554
560
  noTokenGuidance: credentialGuidance,
555
561
  });
556
562
  for (const line of lines) console.log(line);
@@ -825,12 +831,53 @@ async function cmdCreate(args: string[]) {
825
831
  // even when the server-wide `default_mirror` knob is `internal`. Parity for
826
832
  // operators who want one bare vault without flipping the global default.
827
833
  const noMirror = args.includes("--no-mirror");
828
- // Greedy strip of any `--*` token to recover the positional vault name.
829
- // `--json` and `--no-mirror` are recognized; any other `--foo` is silently
830
- // dropped. If a future flag (e.g. `--force`, `--dry-run`) is added, the
831
- // parsing here needs to whitelist it otherwise an invalid flag becomes a
832
- // silent no-op rather than a usage error.
833
- const positional = args.filter((a) => !a.startsWith("--"));
834
+
835
+ // --- Auth opt-in (vault#442). Default = per-user OAuth, NO token minted. ---
836
+ // `--mint` opts into a scope-narrow hub JWT for the header-auth / script
837
+ // use case; `--scope read|write` (default read) picks the verb. `:admin` is
838
+ // intentionally NOT accepted from the create flow. `--token <bearer>` is the
839
+ // paste path — use an existing bearer instead of minting. The two are
840
+ // mutually exclusive.
841
+ const wantMint = args.includes("--mint");
842
+ const createTokenArg = takeArgValue(args, "--token");
843
+ if (createTokenArg.missingValue) {
844
+ console.error("--token requires a value (the bearer token to embed).");
845
+ process.exit(1);
846
+ }
847
+ const pastedToken = createTokenArg.value;
848
+ if (wantMint && pastedToken !== undefined) {
849
+ console.error("--mint and --token are mutually exclusive.");
850
+ process.exit(1);
851
+ }
852
+ const createScopeArg = takeArgValue(args, "--scope");
853
+ if (createScopeArg.missingValue) {
854
+ console.error("--scope requires a value: read or write.");
855
+ process.exit(1);
856
+ }
857
+ const rawCreateVerb = createScopeArg.value ?? "read";
858
+ if (rawCreateVerb !== "read" && rawCreateVerb !== "write") {
859
+ console.error(
860
+ `--scope must be "read" or "write" for create (admin is minted out-of-band via \`mcp-install --scope vault:admin\`). Got: ${rawCreateVerb}.`,
861
+ );
862
+ process.exit(1);
863
+ }
864
+ const mintVerb: "read" | "write" | undefined = wantMint ? rawCreateVerb : undefined;
865
+
866
+ // Greedy strip of any `--*` token (and any `--flag value` pairs we consumed
867
+ // above) to recover the positional vault name. `--json`, `--no-mirror`,
868
+ // `--mint`, `--token <v>`, `--scope <v>` are recognized; any other `--foo` is
869
+ // silently dropped, and the value following a recognized value-flag is
870
+ // skipped so it can't be mistaken for the vault name.
871
+ const VALUE_FLAGS = new Set(["--token", "--scope"]);
872
+ const positional: string[] = [];
873
+ for (let i = 0; i < args.length; i++) {
874
+ const a = args[i]!;
875
+ if (a.startsWith("--")) {
876
+ if (VALUE_FLAGS.has(a)) i++; // skip the flag's value
877
+ continue;
878
+ }
879
+ positional.push(a);
880
+ }
834
881
  const name = positional[0];
835
882
  if (!name) {
836
883
  console.error("Usage: parachute-vault create <name> [--json]");
@@ -863,7 +910,18 @@ async function cmdCreate(args: string[]) {
863
910
 
864
911
  ensureConfigDirSync();
865
912
  const wasFirst = listVaults().length === 0;
866
- const credential = await createVault(name, noMirror ? { enableMirror: false } : {});
913
+ const credential = await createVault(name, {
914
+ ...(noMirror ? { enableMirror: false } : {}),
915
+ ...(mintVerb ? { mintVerb } : {}),
916
+ });
917
+
918
+ // `--token <bearer>` paste path: the operator supplied their own bearer
919
+ // instead of minting one. Surface it as the credential token so the
920
+ // downstream JSON / human / summary copy treats it the same as a minted one.
921
+ const effectiveToken = pastedToken ?? credential.token;
922
+ const effectiveGuidance = pastedToken
923
+ ? "Using the bearer you supplied via --token."
924
+ : credential.guidance;
867
925
 
868
926
  // If this is the only vault now, make it the default so unscoped routes
869
927
  // (/mcp, /api/*, /oauth/*) target it. Avoids the "single vault named
@@ -897,17 +955,19 @@ async function cmdCreate(args: string[]) {
897
955
  log: () => {}, // CLI create has its own status lines.
898
956
  });
899
957
 
958
+ const endpoint = chooseMcpUrl(name, globalConfig.port || DEFAULT_PORT).url;
959
+
900
960
  if (jsonMode) {
901
961
  // Contract (hub's admin-vaults.ts requires `typeof token === "string"`):
902
- // emit the minted hub JWT when present. When no hub was reachable
903
- // (standalone create — no operator.token / no hub origin), `token` is the
904
- // empty string and `token_guidance` carries the operator's next step. Hub
905
- // only shells out to `create --json` while IT is the orchestrator (a hub is
906
- // running, operator.token present), so that path always mints a real JWT.
962
+ // emit a token string when one was minted/pasted. vault#442 default is
963
+ // per-user OAuth — no token is minted so `token` is the empty string and
964
+ // `token_guidance` carries the OAuth-first connect path. (Hub's admin SPA
965
+ // already handles the empty-token case + has its own session-cookie admin
966
+ // mint path, so it doesn't depend on create minting a token.)
907
967
  const payload = {
908
968
  name,
909
- token: credential.token ?? "",
910
- token_guidance: credential.guidance,
969
+ token: effectiveToken ?? "",
970
+ token_guidance: effectiveGuidance,
911
971
  paths: {
912
972
  vault_dir: vaultDir(name),
913
973
  vault_db: vaultDbPath(name),
@@ -921,18 +981,20 @@ async function cmdCreate(args: string[]) {
921
981
 
922
982
  console.log(`Vault "${name}" created.`);
923
983
  console.log(` Path: ${vaultDir(name)}`);
924
- if (credential.token) {
925
- console.log(` API token: ${credential.token}`);
926
- console.log(` ${credential.guidance}`);
984
+ if (effectiveToken) {
985
+ console.log(` API token: ${effectiveToken}`);
986
+ console.log(` ${effectiveGuidance}`);
927
987
  console.log(` Save this — it will not be shown again.`);
928
988
  } else {
929
- console.log(` ${credential.guidance}`);
989
+ console.log(` ${effectiveGuidance}`);
930
990
  }
931
991
  if (defaultNote) {
932
992
  console.log(` ${defaultNote}`);
933
993
  }
934
994
  console.log();
935
- console.log(`To add MCP to Claude: parachute-vault mcp-install ${name}`);
995
+ console.log(`Connect your AI: claude mcp add --transport http parachute-${name} ${endpoint}`);
996
+ console.log(` (no token needed — you'll sign in on first use)`);
997
+ console.log(`Need a header-auth token for a script? parachute auth mint-token --scope vault:${name}:read`);
936
998
  }
937
999
 
938
1000
  function cmdList() {
@@ -3274,30 +3336,40 @@ async function firstChangedNoteTitle(
3274
3336
  /**
3275
3337
  * Outcome of bootstrapping a fresh vault's first credential (vault#282 Stage 2).
3276
3338
  *
3277
- * Vault no longer mints `pvt_*` tokens. The first credential for a new vault is
3278
- * a hub-issued JWT, minted via the same operator.token → hub mint-token path
3279
- * `mcp-install --mint` uses (cli.ts ~`cmdMcpInstall`). When no hub is reachable
3280
- * (standalone install, no operator.token, or no real hub origin), `token` is
3281
- * null and `guidance` carries the operator's next step.
3339
+ * Vault no longer mints `pvt_*` tokens. When a token IS minted (explicit opt-in
3340
+ * only — vault#442), it's a hub-issued JWT scoped narrow (`vault:<name>:read`
3341
+ * or `:write`, NEVER `:admin`), minted via the same operator.token → hub
3342
+ * mint-token path `mcp-install --mint` uses (cli.ts ~`cmdMcpInstall`). When no
3343
+ * hub is reachable (standalone install, no operator.token, or no real hub
3344
+ * origin), `token` is null and `guidance` carries the operator's next step.
3282
3345
  */
3283
3346
  interface VaultCredential {
3284
- /** Hub-issued JWT scoped to `vault:<name>:admin`, or null when no hub is reachable. */
3347
+ /** Hub-issued JWT scoped narrow (read/write), or null when not minted / no hub reachable. */
3285
3348
  token: string | null;
3286
3349
  /** Human-readable note: how the token was issued, or why it wasn't. */
3287
3350
  guidance: string;
3288
3351
  }
3289
3352
 
3290
3353
  /**
3291
- * Mint the first credential for a freshly-created vault.
3354
+ * Mint a scope-narrow credential for a vault (explicit opt-in vault#442).
3355
+ *
3356
+ * Default vault auth is per-user OAuth (browser sign-in on first MCP connect);
3357
+ * tokens are only for the header-auth / script use case and are minted ONLY
3358
+ * when explicitly requested. Decision (vault#442): the create/init flow NEVER
3359
+ * auto-mints, and when a token IS requested it's scope-narrow — `verb` is
3360
+ * `read` (default) or `write`, NEVER `admin`. (Admin tokens, when truly
3361
+ * needed, are minted out-of-band via `mcp-install --scope vault:admin` against
3362
+ * a hub running hub#449, or the hub admin SPA's own session-cookie path.)
3292
3363
  *
3293
- * Decision (vault#282 Stage 2): when a hub is reachable (operator.token present
3294
- * AND a real hub origin resolves), mint a `vault:<name>:admin` hub JWT and
3295
- * return it as the bootstrap credential — preserving the `create --json`
3296
- * `token` string contract hub's admin-vaults.ts requires. When no hub is
3364
+ * When a hub is reachable (operator.token present AND a real hub origin
3365
+ * resolves), mint a `vault:<name>:<verb>` hub JWT and return it. When no hub is
3297
3366
  * reachable, return `token: null` plus explicit standalone guidance. There is
3298
3367
  * no local pvt_* fallback anymore.
3299
3368
  */
3300
- async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
3369
+ async function mintBootstrapCredential(
3370
+ name: string,
3371
+ verb: "read" | "write" = "read",
3372
+ ): Promise<VaultCredential> {
3301
3373
  const operatorToken = readOperatorToken();
3302
3374
  if (!operatorToken) {
3303
3375
  return {
@@ -3322,7 +3394,7 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
3322
3394
  const result = await mintHubJwt({
3323
3395
  hubOrigin: hub.url,
3324
3396
  operatorToken,
3325
- scope: `vault:${name}:admin`,
3397
+ scope: `vault:${name}:${verb}`,
3326
3398
  subject: "parachute-vault-bootstrap",
3327
3399
  });
3328
3400
  if ("kind" in result) {
@@ -3333,7 +3405,7 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
3333
3405
  return {
3334
3406
  token: null,
3335
3407
  guidance:
3336
- `No token issued — ${detail}. Verify the hub is running (hub#449 for vault:admin mint), ` +
3408
+ `No token issued — ${detail}. Verify the hub is running, ` +
3337
3409
  "then run `parachute-vault mcp-install`, or set VAULT_AUTH_TOKEN.",
3338
3410
  };
3339
3411
  }
@@ -3344,13 +3416,24 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
3344
3416
  }
3345
3417
 
3346
3418
  /**
3347
- * Create a vault's config + DB and mint its first credential.
3419
+ * Create a vault's config + DB.
3348
3420
  *
3349
- * Returns the bootstrap credential (a hub JWT, or null + guidance when no hub
3350
- * is reachable vault#282 Stage 2). The DB is created lazily via
3351
- * `getVaultStore` so migrations + schema run; we no longer write any pvt_* row.
3421
+ * Default vault auth is per-user OAuth (vault#442) create does NOT mint or
3422
+ * bake in any token. Returns a `VaultCredential` whose `token` is null and
3423
+ * whose `guidance` points at the OAuth-first connect path. A scope-narrow
3424
+ * token is minted only when the caller passes `mintVerb` (the explicit
3425
+ * header-auth / script opt-in: `read` default or `write`, NEVER `admin`). The
3426
+ * DB is created lazily via `getVaultStore` so migrations + schema run; we never
3427
+ * write any pvt_* row.
3352
3428
  */
3353
3429
  interface CreateVaultOptions {
3430
+ /**
3431
+ * Opt-in token mint (vault#442). Unset → no token is minted; the vault uses
3432
+ * per-user OAuth on first MCP connect. Set to `read`/`write` → mint a
3433
+ * scope-narrow `vault:<name>:<verb>` hub JWT for the header-auth / script
3434
+ * use case. `admin` is intentionally NOT accepted here.
3435
+ */
3436
+ mintVerb?: "read" | "write";
3354
3437
  /**
3355
3438
  * Override the server-wide `default_mirror` knob for this one create.
3356
3439
  * `--no-mirror` on `parachute-vault create` sets this to `false` so the
@@ -3454,7 +3537,19 @@ async function createVault(
3454
3537
  }
3455
3538
  }
3456
3539
 
3457
- return mintBootstrapCredential(name);
3540
+ // vault#442: default to per-user OAuth — do NOT auto-mint or bake in a
3541
+ // shared token. Only mint when the caller explicitly opted in (header-auth /
3542
+ // script use case), and then scope-narrow (read/write, never admin).
3543
+ if (opts.mintVerb) {
3544
+ return mintBootstrapCredential(name, opts.mintVerb);
3545
+ }
3546
+ return {
3547
+ token: null,
3548
+ guidance:
3549
+ "No token minted — this vault uses per-user OAuth (sign in on first connect). " +
3550
+ "Need a header-auth token for a script? Run " +
3551
+ `\`parachute auth mint-token --scope vault:${name}:read\` (or \`:write\`).`,
3552
+ };
3458
3553
  }
3459
3554
 
3460
3555
  interface InstallMcpConfigOpts {
@@ -3536,9 +3631,11 @@ Setup:
3536
3631
  parachute-vault init [--mcp|--no-mcp] [--token|--no-token] [--vault-name <name>]
3537
3632
  [--autostart|--no-autostart]
3538
3633
  Set up everything (one command, idempotent).
3539
- --mcp/--no-mcp controls the Claude Code MCP entry;
3540
- --token/--no-token controls whether an API token is
3541
- printed for pasting into other MCP clients / scripts.
3634
+ --mcp/--no-mcp controls the Claude Code MCP entry (written
3635
+ for per-user OAuth by default no baked token; sign in on
3636
+ first connect). --token opts into ALSO minting a scope-narrow
3637
+ header-auth token (vault:<name>:read) for non-OAuth clients /
3638
+ scripts; --no-token (the default) skips minting entirely.
3542
3639
  --vault-name skips the prompt and names the vault
3543
3640
  (lowercase alphanumeric, hyphens, underscores;
3544
3641
  omit to be prompted interactively, default "default").
@@ -3558,8 +3655,13 @@ Setup:
3558
3655
  parachute --version Print the installed version (alias: -v, version)
3559
3656
 
3560
3657
  Vaults:
3561
- parachute-vault create <name> [--json] [--no-mirror]
3658
+ parachute-vault create <name> [--json] [--no-mirror] [--mint [--scope read|write]] [--token <bearer>]
3562
3659
  Create a new vault (--json: emit { name, token, paths, set_as_default }).
3660
+ Default auth is per-user OAuth — NO token is minted; connect with
3661
+ "claude mcp add --transport http parachute-<name> <endpoint>" and sign in
3662
+ on first use. --mint opts into a scope-narrow hub JWT for the header-auth /
3663
+ script case (--scope read [default] | write — admin is NOT mintable from
3664
+ create); --token <bearer> pastes an existing bearer instead of minting.
3563
3665
  New vaults default to an internal live mirror — a local git backup of
3564
3666
  the markdown projection (backup on by default; GitHub off-site is an
3565
3667
  opt-in upgrade). --no-mirror creates a bare vault with no mirror config.
@@ -13,6 +13,7 @@ const baseInput = {
13
13
  bindHost: "127.0.0.1",
14
14
  port: 1940,
15
15
  mcpUrl: "http://127.0.0.1:1940/vault/default/mcp",
16
+ vaultName: "default",
16
17
  };
17
18
 
18
19
  function lines(addMcp: boolean, addToken: boolean, apiKey: string | undefined) {
@@ -96,16 +97,70 @@ describe("buildInitSummaryLines", () => {
96
97
  });
97
98
  });
98
99
 
99
- describe("MCP=N + token=N (unreachable)", () => {
100
+ // vault#442: the DEFAULT init path — MCP wired, NO token minted (per-user
101
+ // OAuth). The summary must LEAD with the OAuth connect path, never mint, and
102
+ // never surface the old "no token issued" failure copy.
103
+ describe("MCP=Y + no token (vault#442 OAuth default)", () => {
104
+ const out = lines(true, false, undefined).join("\n");
105
+
106
+ test("leads with the OAuth connect message — no token needed", () => {
107
+ expect(out).toContain("no token needed, you'll sign in on first use");
108
+ });
109
+
110
+ test("tells the user Claude Code is already wired in", () => {
111
+ expect(out).toContain("Claude Code is already wired in");
112
+ });
113
+
114
+ test("shows the OAuth `claude mcp add` command for other clients", () => {
115
+ expect(out).toContain(
116
+ "claude mcp add --transport http parachute-vault http://127.0.0.1:1940/vault/default/mcp",
117
+ );
118
+ });
119
+
120
+ test("offers the scope-narrow opt-in mint for scripts (full vault:<name>:read, never admin)", () => {
121
+ // Must be the three-segment named-resource form the hub mint-token model
122
+ // requires — a bare `vault:read` would mint a malformed scope (vault#443).
123
+ expect(out).toContain("parachute auth mint-token --scope vault:default:read");
124
+ expect(out).not.toContain("--scope vault:read ");
125
+ expect(out).not.toMatch(/--scope vault:read$/m);
126
+ expect(out).not.toContain("vault:admin");
127
+ });
128
+
129
+ test("does NOT print or imply any minted token", () => {
130
+ expect(out).not.toContain("Your API token:");
131
+ expect(out).not.toContain("Baked into ~/.claude.json");
132
+ expect(out).not.toContain("Authorization: Bearer");
133
+ });
134
+
135
+ test("does NOT surface the old no-token-issued failure copy", () => {
136
+ expect(out).not.toContain("No token issued");
137
+ });
138
+
139
+ test("threads a non-default vault name into the mint-token scope", () => {
140
+ const out2 = buildInitSummaryLines({
141
+ ...baseInput,
142
+ vaultName: "journal",
143
+ mcpUrl: "http://127.0.0.1:1940/vault/journal/mcp",
144
+ addMcp: true,
145
+ addToken: false,
146
+ apiKey: undefined,
147
+ }).join("\n");
148
+ expect(out2).toContain("parachute auth mint-token --scope vault:journal:read");
149
+ expect(out2).not.toContain("vault:default:read");
150
+ });
151
+ });
152
+
153
+ describe("MCP=N + token=N (OAuth default, Claude Code not wired)", () => {
100
154
  const out = lines(false, false, undefined).join("\n");
101
155
 
102
- test("warns the vault is unreachable", () => {
103
- expect(out).toContain("your vault isn't reachable by any client");
156
+ test("frames skipping the MCP entry as OAuth-first, not 'unreachable'", () => {
157
+ expect(out).toContain("uses per-user OAuth, no token needed");
158
+ expect(out).not.toContain("your vault isn't reachable by any client");
104
159
  });
105
160
 
106
- test("points to the mcp-install recovery path (hub JWT)", () => {
161
+ test("points to mcp-install (no token-minting framing)", () => {
107
162
  expect(out).toContain("parachute-vault mcp-install");
108
- expect(out).toContain("mints a hub JWT");
163
+ expect(out).not.toContain("mints a hub JWT");
109
164
  });
110
165
 
111
166
  test("does not print any token", () => {
@@ -118,6 +173,23 @@ describe("buildInitSummaryLines", () => {
118
173
  });
119
174
  });
120
175
 
176
+ // Explicit opt-in but no hub reachable to mint (vault#282 Stage 2 path,
177
+ // reached only when the operator passes --token without a hub).
178
+ describe("MCP=N + token=Y but no hub (opt-in mint failed)", () => {
179
+ const out = buildInitSummaryLines({
180
+ ...baseInput,
181
+ addMcp: false,
182
+ addToken: true,
183
+ apiKey: undefined,
184
+ noTokenGuidance: "No token issued — hub unreachable.",
185
+ }).join("\n");
186
+
187
+ test("surfaces the no-token-issued guidance + recovery", () => {
188
+ expect(out).toContain("No token issued");
189
+ expect(out).toContain("parachute-vault mcp-install");
190
+ });
191
+ });
192
+
121
193
  test("always prints Config: and Server: lines", () => {
122
194
  for (const [addMcp, addToken] of [
123
195
  [true, true],
@@ -12,6 +12,13 @@ export type InitSummaryInput = {
12
12
  bindHost: string;
13
13
  port: number;
14
14
  mcpUrl: string;
15
+ /**
16
+ * The default vault's name — used to emit the three-segment
17
+ * `vault:<vaultName>:read` scope in the OAuth-first mint-token suggestion
18
+ * (the hub mint-token model requires the named-resource form;
19
+ * a bare `vault:read` would mint a malformed scope). vault#442/#443.
20
+ */
21
+ vaultName: string;
15
22
  /**
16
23
  * Guidance from the bootstrap-credential step when no token could be issued
17
24
  * (standalone install, no hub reachable — vault#282 Stage 2). Surfaced when
@@ -23,44 +30,54 @@ export type InitSummaryInput = {
23
30
 
24
31
  /**
25
32
  * Build the post-install summary lines for `vault init`, branched on the
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`):
33
+ * (addMcp, addToken, apiKey) decision matrix.
34
+ *
35
+ * vault#442: the DEFAULT is per-user OAuth no token is minted, and the
36
+ * Claude Code MCP entry is written without a baked bearer (browser sign-in on
37
+ * first connect). A token is minted only on explicit opt-in (`addToken`), and
38
+ * then scope-narrow. Branches:
29
39
  *
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
40
+ * addMcp, !apiKey OAuth-first: connect, sign in on first use
41
+ * addMcp, addToken, apiKey → token baked into claude.json + printed
42
+ * addMcp, !addToken, apiKey → token baked into claude.json, hint
43
+ * !addMcp, addToken, apiKey → token printed prominently
44
+ * !addMcp, addToken, !apiKey opted into a token but no hub reachable
45
+ * !addMcp, !addToken → OAuth-first: add Claude Code later
35
46
  */
36
47
  export function buildInitSummaryLines(input: InitSummaryInput): string[] {
37
- const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, noTokenGuidance } = input;
48
+ const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, vaultName, noTokenGuidance } = input;
38
49
  const lines: string[] = [];
39
50
  lines.push("");
40
51
  lines.push("---");
41
52
 
42
- const wantedToken = addMcp || addToken;
43
-
44
- if (addMcp && addToken && apiKey) {
53
+ if (addMcp && apiKey && addToken) {
45
54
  lines.push("");
46
55
  lines.push(`Your API token: ${apiKey}`);
47
56
  lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
48
57
  lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
49
58
  lines.push(` - Won't be shown again — save it now.`);
50
- } else if (addMcp && !addToken && apiKey) {
59
+ } else if (addMcp && apiKey && !addToken) {
51
60
  lines.push("");
52
61
  lines.push(
53
62
  "Token in ~/.claude.json; run `parachute-vault mcp-install` later if you need one for other clients.",
54
63
  );
64
+ } else if (addMcp && !apiKey) {
65
+ // vault#442 default: OAuth-first. The MCP entry is wired without a bearer —
66
+ // Claude Code signs in via browser OAuth on first connect. No token needed.
67
+ lines.push("");
68
+ lines.push("Connect your AI — no token needed, you'll sign in on first use:");
69
+ lines.push(` Claude Code is already wired in (~/.claude.json) — just start a session.`);
70
+ lines.push(` Other clients: claude mcp add --transport http parachute-vault ${mcpUrl}`);
71
+ lines.push(` Need a header-auth token for a script? parachute auth mint-token --scope vault:${vaultName}:read`);
55
72
  } else if (!addMcp && addToken && apiKey) {
56
73
  lines.push("");
57
74
  lines.push(`Your API token: ${apiKey}`);
58
75
  lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
59
76
  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.
77
+ } else if (!addMcp && addToken && !apiKey) {
78
+ // Explicitly opted into a token but no hub was reachable to mint one
79
+ // (vault#282 Stage 2 — vault no longer mints local pvt_* tokens). Surface
80
+ // why and the recovery paths.
64
81
  lines.push("");
65
82
  lines.push(
66
83
  noTokenGuidance ??
@@ -73,12 +90,13 @@ export function buildInitSummaryLines(input: InitSummaryInput): string[] {
73
90
  " or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
74
91
  );
75
92
  } else if (!addMcp && !addToken) {
93
+ // OAuth-first, but the operator skipped wiring Claude Code too.
76
94
  lines.push("");
77
95
  lines.push(
78
- "You've skipped both MCP install and token generationyour vault isn't reachable by any client.",
96
+ "Skipped the Claude Code MCP entry. Add it anytimeit uses per-user OAuth, no token needed:",
79
97
  );
80
98
  lines.push(
81
- " Add Claude Code later with `parachute-vault mcp-install`, which mints a hub JWT (needs a hub running).",
99
+ " parachute-vault mcp-install",
82
100
  );
83
101
  }
84
102
 
@@ -85,13 +85,15 @@ describe("vault create --json", () => {
85
85
  expect(lines).toHaveLength(1);
86
86
  const payload = JSON.parse(lines[0]!);
87
87
  expect(payload.name).toBe("myvault");
88
- // vault#282 Stage 2: vault no longer mints pvt_* tokens. The contract
89
- // hub's admin-vaults.ts requires still holds (`token` is a string). In
90
- // this sandbox there's no hub/operator.token, so no token is issued: the
91
- // token field is the empty string and `token_guidance` explains why.
88
+ // vault#442: default auth is per-user OAuth `create` does NOT mint a
89
+ // token. The contract hub's admin-vaults.ts requires still holds (`token`
90
+ // is a string); it's the empty string and `token_guidance` carries the
91
+ // OAuth-first connect path (the hub SPA handles the empty-token case and
92
+ // mints admin via its own session-cookie path).
92
93
  expect(typeof payload.token).toBe("string");
93
94
  expect(payload.token).toBe("");
94
- expect(payload.token_guidance).toContain("No token issued");
95
+ expect(payload.token_guidance).toContain("No token minted");
96
+ expect(payload.token_guidance).toContain("per-user OAuth");
95
97
  expect(payload.set_as_default).toBe(true);
96
98
  expect(payload.paths.vault_dir).toBe(join(home, "vault", "data", "myvault"));
97
99
  expect(payload.paths.vault_db).toBe(join(home, "vault", "data", "myvault", "vault.db"));
@@ -160,6 +162,95 @@ describe("vault create --json", () => {
160
162
  });
161
163
  });
162
164
 
165
+ /**
166
+ * vault#442: default to per-user OAuth — `create` must NOT auto-mint or bake in
167
+ * a shared `vault:<name>:admin` token. Token-minting is explicit opt-in
168
+ * (`--mint`) and scope-narrow (read/write, NEVER admin); `--token <bearer>` is
169
+ * the paste path. These tests pin the behavioral contract.
170
+ */
171
+ describe("vault create — OAuth-first auth (vault#442)", () => {
172
+ test("default create does NOT mint a token — empty token + OAuth guidance (--json)", () => {
173
+ const { exitCode, stdout } = runCli(["create", "oauthy", "--json"], {
174
+ PARACHUTE_HOME: home,
175
+ });
176
+ expect(exitCode).toBe(0);
177
+ const payload = JSON.parse(stdout.trim());
178
+ // No token baked in — OAuth on first connect.
179
+ expect(payload.token).toBe("");
180
+ expect(payload.token_guidance).toContain("No token minted");
181
+ expect(payload.token_guidance).toContain("per-user OAuth");
182
+ // Never the admin-mint failure copy.
183
+ expect(payload.token_guidance).not.toContain("No token issued");
184
+ expect(payload.token_guidance).not.toContain("admin");
185
+ });
186
+
187
+ test("default create leads the human summary with the OAuth connect command", () => {
188
+ const { exitCode, stdout } = runCli(["create", "connectme"], {
189
+ PARACHUTE_HOME: home,
190
+ });
191
+ expect(exitCode).toBe(0);
192
+ expect(stdout).toContain(
193
+ "Connect your AI: claude mcp add --transport http parachute-connectme",
194
+ );
195
+ expect(stdout).toContain("no token needed");
196
+ // Scope-narrow opt-in pointer, never admin.
197
+ expect(stdout).toContain("parachute auth mint-token --scope vault:connectme:read");
198
+ expect(stdout).not.toContain("vault:connectme:admin");
199
+ });
200
+
201
+ test("--scope admin is rejected from the create flow (admin never mintable here)", () => {
202
+ const { exitCode, stdout, stderr } = runCli(
203
+ ["create", "noadmin", "--mint", "--scope", "admin", "--json"],
204
+ { PARACHUTE_HOME: home },
205
+ );
206
+ expect(exitCode).not.toBe(0);
207
+ expect(stdout).toBe("");
208
+ expect(stderr).toContain('--scope must be "read" or "write"');
209
+ // The vault must not have been created (rejected before createVault).
210
+ expect(existsSync(join(home, "vault", "data", "noadmin", "vault.db"))).toBe(false);
211
+ });
212
+
213
+ test("--mint and --token are mutually exclusive", () => {
214
+ const { exitCode, stderr } = runCli(
215
+ ["create", "conflict", "--mint", "--token", "abc.def.ghi", "--json"],
216
+ { PARACHUTE_HOME: home },
217
+ );
218
+ expect(exitCode).not.toBe(0);
219
+ expect(stderr).toContain("mutually exclusive");
220
+ });
221
+
222
+ test("--token <bearer> paste path surfaces the supplied bearer (no mint attempted)", () => {
223
+ const { exitCode, stdout } = runCli(
224
+ ["create", "pasted", "--token", "header.auth.bearer", "--json"],
225
+ { PARACHUTE_HOME: home },
226
+ );
227
+ expect(exitCode).toBe(0);
228
+ const payload = JSON.parse(stdout.trim());
229
+ // The pasted bearer is surfaced verbatim — vault never minted one.
230
+ expect(payload.token).toBe("header.auth.bearer");
231
+ expect(payload.token_guidance).toContain("--token");
232
+ // No admin scope, no mint-failure copy.
233
+ expect(payload.token_guidance).not.toContain("No token issued");
234
+ });
235
+
236
+ test("--mint (no hub reachable) opts in but mints scope-narrow read, never admin", () => {
237
+ // In this sandbox there's no hub/operator.token, so the mint can't complete
238
+ // — but the request is scope-narrow read by default and must NEVER ask for
239
+ // admin. We assert the create still succeeds and the guidance points at the
240
+ // mint-token recovery (the scope requested is read, per mintBootstrapCredential).
241
+ const { exitCode, stdout } = runCli(
242
+ ["create", "wantmint", "--mint", "--json"],
243
+ { PARACHUTE_HOME: home },
244
+ );
245
+ expect(exitCode).toBe(0);
246
+ const payload = JSON.parse(stdout.trim());
247
+ // No hub here → no token, but the guidance is the standalone mint path, not
248
+ // an admin grant.
249
+ expect(payload.token).toBe("");
250
+ expect(payload.token_guidance).not.toContain("admin");
251
+ });
252
+ });
253
+
163
254
  /**
164
255
  * Regression tests for #208: `vault create` was not updating
165
256
  * `~/.parachute/services.json`, so vaults created after init were invisible
@@ -229,10 +320,14 @@ describe("vault create (human mode)", () => {
229
320
  );
230
321
  expect(exitCode).toBe(0);
231
322
  expect(stdout).toContain('Vault "human" created.');
232
- // vault#282 Stage 2: with no hub reachable in this sandbox, no token is
233
- // issued the human output prints the guidance instead of "API token:".
234
- expect(stdout).toContain("No token issued");
235
- expect(stdout).toContain("Install the hub");
323
+ // vault#442: default auth is per-user OAuth NO token is minted, even when
324
+ // a hub would have been reachable. The human output leads with the OAuth
325
+ // connect command and never prints an "API token:" line.
326
+ expect(stdout).toContain("No token minted");
327
+ expect(stdout).toContain("Connect your AI: claude mcp add --transport http parachute-human");
328
+ expect(stdout).not.toContain("API token:");
329
+ // The old admin auto-mint failure copy must NOT fire on a default create.
330
+ expect(stdout).not.toContain("No token issued");
236
331
  // Human output should NOT be valid JSON.
237
332
  expect(() => JSON.parse(stdout.trim())).toThrow();
238
333
  });