@openparachute/vault 0.5.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/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,
@@ -102,6 +103,13 @@ import { listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
102
103
  import { VAULT_SCOPES } from "./scopes.ts";
103
104
  import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
104
105
  import { getVaultStore } from "./vault-store.ts";
106
+ import {
107
+ defaultMirrorConfig,
108
+ resolveMirrorPath,
109
+ writeMirrorConfigForVault,
110
+ type MirrorConfig,
111
+ } from "./mirror-config.ts";
112
+ import { bootstrapInternalMirror } from "./mirror-manager.ts";
105
113
  import { selfRegister } from "./self-register.ts";
106
114
  import {
107
115
  hasOwnerPassword,
@@ -471,11 +479,16 @@ async function cmdInit(args: string[] = []) {
471
479
  addMcp = true; // non-interactive: preserve the installable-via-pipe default
472
480
  }
473
481
 
474
- // 7b. Surface an API token for other clients? (Codex, Goose, OpenCode,
475
- // Cursor, Zed, Cline, scripts, curl.) Same flag/TTY precedence as MCP.
476
- // Note: a token is always minted when addMcp is true (it gets baked into
477
- // the ~/.claude.json entry); this prompt controls whether that token is
478
- // 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.
479
492
  let addToken: boolean;
480
493
  if (flagTokenOff) {
481
494
  addToken = false;
@@ -483,25 +496,22 @@ async function cmdInit(args: string[] = []) {
483
496
  addToken = true;
484
497
  } else if (process.stdin.isTTY) {
485
498
  addToken = await confirm(
486
- "Generate an API token for other MCP clients (Codex, Goose, OpenCode, Cursor, Zed, Cline), scripts, or curl?",
487
- true,
499
+ "Also mint a header-auth API token for non-OAuth clients / scripts (Codex, Goose, curl)? (OAuth works without one)",
500
+ false,
488
501
  );
489
502
  } else {
490
- addToken = true; // non-interactive: default-yes matches addMcp default
503
+ addToken = false; // non-interactive default: OAuth-first, no auto-mint
491
504
  }
492
505
 
493
- // Mint a token if we need one (for the claude.json entry and/or for
494
- // prominent display) and don't already have one from vault creation
495
- // e.g. a re-run of init against an existing vault. vault#282 Stage 2: vault
496
- // no longer mints pvt_* tokens, so this is a hub JWT via the operator.token
497
- // hub mint-token path (`mintBootstrapCredential`). When no hub is
498
- // reachable, `apiKey` stays undefined and we carry the guidance to the
499
- // summary — the operator runs `mcp-install` once a hub is up, or sets
500
- // 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.
501
512
  const defaultVault = globalConfig.default_vault || "default";
502
- const needToken = addMcp || addToken;
503
- if (needToken && !apiKey) {
504
- const credential = await mintBootstrapCredential(defaultVault);
513
+ if (addToken && !apiKey) {
514
+ const credential = await mintBootstrapCredential(defaultVault, "read");
505
515
  apiKey = credential.token ?? undefined;
506
516
  credentialGuidance = credential.guidance;
507
517
  if (!apiKey) console.log(` ${credential.guidance}`);
@@ -510,10 +520,9 @@ async function cmdInit(args: string[] = []) {
510
520
  if (addMcp) {
511
521
  // Goes through `buildMcpEntryPlan` for entryKey + url so this path shares
512
522
  // the writer-side invariant with `executeMcpInstall` — a future URL-shape
513
- // change can't drift between init and mcp-install. The bearer is the hub
514
- // JWT minted above (omitted when no hub was reachable the entry is then
515
- // written unauthenticated, and the operator re-runs `mcp-install` once a
516
- // 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.
517
526
  const target = resolveInstallTarget("user");
518
527
  const { entryKey, url, source } = buildMcpEntryPlan({
519
528
  vaultName: defaultVault,
@@ -528,6 +537,9 @@ async function cmdInit(args: string[] = []) {
528
537
  });
529
538
  console.log(`MCP URL: ${url} (${source})`);
530
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
+ }
531
543
  } else {
532
544
  console.log(" Skipped adding MCP to ~/.claude.json.");
533
545
  console.log(" Run `parachute-vault mcp-install` later if you want it.");
@@ -544,6 +556,7 @@ async function cmdInit(args: string[] = []) {
544
556
  bindHost,
545
557
  port,
546
558
  mcpUrl,
559
+ vaultName: defaultVault,
547
560
  noTokenGuidance: credentialGuidance,
548
561
  });
549
562
  for (const line of lines) console.log(line);
@@ -814,20 +827,71 @@ async function cmdCreate(args: string[]) {
814
827
  // POST /vaults shells out to this CLI and parses stdout). Errors still go
815
828
  // to stderr as plain text and exit nonzero — callers branch on exit code.
816
829
  const jsonMode = args.includes("--json");
817
- // Greedy strip of any `--*` token to recover the positional vault name.
818
- // Today only `--json` is recognized; any other `--foo` is silently dropped.
819
- // If a future flag (e.g. `--force`, `--dry-run`) is added, the parsing
820
- // here needs to whitelist it — otherwise an invalid flag becomes a silent
821
- // no-op rather than a usage error.
822
- const positional = args.filter((a) => !a.startsWith("--"));
830
+ // `--no-mirror` opts THIS create out of the default internal live mirror
831
+ // even when the server-wide `default_mirror` knob is `internal`. Parity for
832
+ // operators who want one bare vault without flipping the global default.
833
+ const noMirror = args.includes("--no-mirror");
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
+ }
823
881
  const name = positional[0];
824
882
  if (!name) {
825
883
  console.error("Usage: parachute-vault create <name> [--json]");
826
884
  process.exit(1);
827
885
  }
828
886
 
829
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
830
- console.error("Vault name must contain only letters, numbers, hyphens, and underscores.");
887
+ // Lowercase-only (security review — multi-user hardening). An uppercase
888
+ // vault name flips the audience case (`vault.<Name>` vs `vault.<name>`)
889
+ // and drifts from hub-side / init-path lowercasing, breaking JWT
890
+ // audience matching. `init` already enforces lowercase via
891
+ // `validateVaultName`; mirror that rule here so uppercase can't enter
892
+ // through `create` either.
893
+ if (!/^[a-z0-9_-]+$/.test(name)) {
894
+ console.error("Vault name must be lowercase alphanumeric with hyphens or underscores (no uppercase).");
831
895
  process.exit(1);
832
896
  }
833
897
  if (name === "list") {
@@ -846,7 +910,18 @@ async function cmdCreate(args: string[]) {
846
910
 
847
911
  ensureConfigDirSync();
848
912
  const wasFirst = listVaults().length === 0;
849
- const credential = await createVault(name);
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;
850
925
 
851
926
  // If this is the only vault now, make it the default so unscoped routes
852
927
  // (/mcp, /api/*, /oauth/*) target it. Avoids the "single vault named
@@ -880,17 +955,19 @@ async function cmdCreate(args: string[]) {
880
955
  log: () => {}, // CLI create has its own status lines.
881
956
  });
882
957
 
958
+ const endpoint = chooseMcpUrl(name, globalConfig.port || DEFAULT_PORT).url;
959
+
883
960
  if (jsonMode) {
884
961
  // Contract (hub's admin-vaults.ts requires `typeof token === "string"`):
885
- // emit the minted hub JWT when present. When no hub was reachable
886
- // (standalone create — no operator.token / no hub origin), `token` is the
887
- // empty string and `token_guidance` carries the operator's next step. Hub
888
- // only shells out to `create --json` while IT is the orchestrator (a hub is
889
- // 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.)
890
967
  const payload = {
891
968
  name,
892
- token: credential.token ?? "",
893
- token_guidance: credential.guidance,
969
+ token: effectiveToken ?? "",
970
+ token_guidance: effectiveGuidance,
894
971
  paths: {
895
972
  vault_dir: vaultDir(name),
896
973
  vault_db: vaultDbPath(name),
@@ -904,18 +981,20 @@ async function cmdCreate(args: string[]) {
904
981
 
905
982
  console.log(`Vault "${name}" created.`);
906
983
  console.log(` Path: ${vaultDir(name)}`);
907
- if (credential.token) {
908
- console.log(` API token: ${credential.token}`);
909
- console.log(` ${credential.guidance}`);
984
+ if (effectiveToken) {
985
+ console.log(` API token: ${effectiveToken}`);
986
+ console.log(` ${effectiveGuidance}`);
910
987
  console.log(` Save this — it will not be shown again.`);
911
988
  } else {
912
- console.log(` ${credential.guidance}`);
989
+ console.log(` ${effectiveGuidance}`);
913
990
  }
914
991
  if (defaultNote) {
915
992
  console.log(` ${defaultNote}`);
916
993
  }
917
994
  console.log();
918
- 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`);
919
998
  }
920
999
 
921
1000
  function cmdList() {
@@ -3257,30 +3336,40 @@ async function firstChangedNoteTitle(
3257
3336
  /**
3258
3337
  * Outcome of bootstrapping a fresh vault's first credential (vault#282 Stage 2).
3259
3338
  *
3260
- * Vault no longer mints `pvt_*` tokens. The first credential for a new vault is
3261
- * a hub-issued JWT, minted via the same operator.token → hub mint-token path
3262
- * `mcp-install --mint` uses (cli.ts ~`cmdMcpInstall`). When no hub is reachable
3263
- * (standalone install, no operator.token, or no real hub origin), `token` is
3264
- * 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.
3265
3345
  */
3266
3346
  interface VaultCredential {
3267
- /** 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. */
3268
3348
  token: string | null;
3269
3349
  /** Human-readable note: how the token was issued, or why it wasn't. */
3270
3350
  guidance: string;
3271
3351
  }
3272
3352
 
3273
3353
  /**
3274
- * Mint the first credential for a freshly-created vault.
3354
+ * Mint a scope-narrow credential for a vault (explicit opt-in vault#442).
3275
3355
  *
3276
- * Decision (vault#282 Stage 2): when a hub is reachable (operator.token present
3277
- * AND a real hub origin resolves), mint a `vault:<name>:admin` hub JWT and
3278
- * return it as the bootstrap credential — preserving the `create --json`
3279
- * `token` string contract hub's admin-vaults.ts requires. When no hub is
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.)
3363
+ *
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
3280
3366
  * reachable, return `token: null` plus explicit standalone guidance. There is
3281
3367
  * no local pvt_* fallback anymore.
3282
3368
  */
3283
- async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
3369
+ async function mintBootstrapCredential(
3370
+ name: string,
3371
+ verb: "read" | "write" = "read",
3372
+ ): Promise<VaultCredential> {
3284
3373
  const operatorToken = readOperatorToken();
3285
3374
  if (!operatorToken) {
3286
3375
  return {
@@ -3305,7 +3394,7 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
3305
3394
  const result = await mintHubJwt({
3306
3395
  hubOrigin: hub.url,
3307
3396
  operatorToken,
3308
- scope: `vault:${name}:admin`,
3397
+ scope: `vault:${name}:${verb}`,
3309
3398
  subject: "parachute-vault-bootstrap",
3310
3399
  });
3311
3400
  if ("kind" in result) {
@@ -3316,7 +3405,7 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
3316
3405
  return {
3317
3406
  token: null,
3318
3407
  guidance:
3319
- `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, ` +
3320
3409
  "then run `parachute-vault mcp-install`, or set VAULT_AUTH_TOKEN.",
3321
3410
  };
3322
3411
  }
@@ -3327,13 +3416,76 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
3327
3416
  }
3328
3417
 
3329
3418
  /**
3330
- * Create a vault's config + DB and mint its first credential.
3419
+ * Create a vault's config + DB.
3331
3420
  *
3332
- * Returns the bootstrap credential (a hub JWT, or null + guidance when no hub
3333
- * is reachable vault#282 Stage 2). The DB is created lazily via
3334
- * `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.
3335
3428
  */
3336
- async function createVault(name: string): Promise<VaultCredential> {
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";
3437
+ /**
3438
+ * Override the server-wide `default_mirror` knob for this one create.
3439
+ * `--no-mirror` on `parachute-vault create` sets this to `false` so the
3440
+ * vault is created with no mirror config even when the knob is `internal`.
3441
+ * Unset → fall back to the `default_mirror` global config knob (default
3442
+ * `internal`).
3443
+ */
3444
+ enableMirror?: boolean;
3445
+ /**
3446
+ * Test seam threaded straight into `bootstrapInternalMirror` (default
3447
+ * `Bun.which`). Inject a fn returning `null` to exercise the
3448
+ * git-not-installed best-effort path without uninstalling git from the
3449
+ * test host.
3450
+ */
3451
+ which?: (cmd: string) => string | null;
3452
+ }
3453
+
3454
+ /**
3455
+ * The History / "Live Mirror" preset, written at create time when the
3456
+ * `default_mirror` knob resolves to `internal`. Matches the History preset
3457
+ * the admin SPA's VaultMirror page applies:
3458
+ * `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
3459
+ * auto_push:false}`.
3460
+ * Built on top of `defaultMirrorConfig()` so the non-preset fields
3461
+ * (commit_template, safety_net_seconds) stay canonical.
3462
+ */
3463
+ function historyPresetMirrorConfig(): MirrorConfig {
3464
+ return {
3465
+ ...defaultMirrorConfig(),
3466
+ enabled: true,
3467
+ location: "internal",
3468
+ sync_mode: "events",
3469
+ auto_commit: true,
3470
+ auto_push: false,
3471
+ };
3472
+ }
3473
+
3474
+ /**
3475
+ * Resolve whether a freshly created vault should get the internal mirror.
3476
+ * Precedence: explicit per-create override (`--no-mirror`) → server-wide
3477
+ * `default_mirror` knob (default `internal`).
3478
+ */
3479
+ function shouldEnableCreateTimeMirror(opts: CreateVaultOptions): boolean {
3480
+ if (opts.enableMirror !== undefined) return opts.enableMirror;
3481
+ // Default to "internal" when the knob is unset — backup-on-by-default.
3482
+ return (readGlobalConfig().default_mirror ?? "internal") === "internal";
3483
+ }
3484
+
3485
+ async function createVault(
3486
+ name: string,
3487
+ opts: CreateVaultOptions = {},
3488
+ ): Promise<VaultCredential> {
3337
3489
  const config: VaultConfig = {
3338
3490
  name,
3339
3491
  api_keys: [],
@@ -3344,7 +3496,60 @@ async function createVault(name: string): Promise<VaultCredential> {
3344
3496
  // Touch the store so the vault's SQLite DB + schema are created. No token
3345
3497
  // row is written — vault is a pure hub resource-server post-0.5.0.
3346
3498
  getVaultStore(name);
3347
- return mintBootstrapCredential(name);
3499
+
3500
+ // Default new vaults to an internal live mirror (local git backup of the
3501
+ // markdown projection). Backup-on-by-default; GitHub off-site backup is an
3502
+ // opt-in upgrade layered on top later. Opt out via the `default_mirror: off`
3503
+ // global knob (operators on git-less / disk-constrained / cloud boxes) or
3504
+ // the `--no-mirror` flag (this one create only).
3505
+ //
3506
+ // BEST-EFFORT, NON-FATAL: write the mirror config first (so the operator's
3507
+ // intent persists even if git is absent), then attempt the bootstrap. A
3508
+ // git-less box leaves the config written but inactive + logs an actionable
3509
+ // hint — it must NEVER fail the vault create. Create-time ONLY: existing
3510
+ // vaults are never retroactively migrated.
3511
+ if (shouldEnableCreateTimeMirror(opts)) {
3512
+ const mirrorConfig = historyPresetMirrorConfig();
3513
+ writeMirrorConfigForVault(name, mirrorConfig);
3514
+ const mirrorPath = resolveMirrorPath(vaultDir(name), mirrorConfig);
3515
+ if (mirrorPath) {
3516
+ try {
3517
+ const result = await bootstrapInternalMirror(mirrorPath, opts.which);
3518
+ if (!result.ok) {
3519
+ // git-not-installed (or refuse-to-clobber) — config stays written,
3520
+ // mirror just isn't active yet. Surface an actionable line; the
3521
+ // vault create succeeds regardless.
3522
+ console.error(
3523
+ `Note: local git backup configured but not yet active — ${result.error} ` +
3524
+ `Install git to activate; the backup turns on automatically on the next vault restart.`,
3525
+ );
3526
+ }
3527
+ } catch (err) {
3528
+ // Defense-in-depth: bootstrapInternalMirror already converts the
3529
+ // git-missing case into a non-throwing { ok:false } result, but a
3530
+ // truly unexpected throw must still not fail the create.
3531
+ console.error(
3532
+ `Note: local git backup configured but bootstrap hit an unexpected error ` +
3533
+ `(${(err as Error).message ?? err}). The vault was still created; ` +
3534
+ `the backup will retry on the next vault restart.`,
3535
+ );
3536
+ }
3537
+ }
3538
+ }
3539
+
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
+ };
3348
3553
  }
3349
3554
 
3350
3555
  interface InstallMcpConfigOpts {
@@ -3426,9 +3631,11 @@ Setup:
3426
3631
  parachute-vault init [--mcp|--no-mcp] [--token|--no-token] [--vault-name <name>]
3427
3632
  [--autostart|--no-autostart]
3428
3633
  Set up everything (one command, idempotent).
3429
- --mcp/--no-mcp controls the Claude Code MCP entry;
3430
- --token/--no-token controls whether an API token is
3431
- 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.
3432
3639
  --vault-name skips the prompt and names the vault
3433
3640
  (lowercase alphanumeric, hyphens, underscores;
3434
3641
  omit to be prompted interactively, default "default").
@@ -3448,7 +3655,18 @@ Setup:
3448
3655
  parachute --version Print the installed version (alias: -v, version)
3449
3656
 
3450
3657
  Vaults:
3451
- parachute-vault create <name> [--json] Create a new vault (--json: emit { name, token, paths, set_as_default })
3658
+ parachute-vault create <name> [--json] [--no-mirror] [--mint [--scope read|write]] [--token <bearer>]
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.
3665
+ New vaults default to an internal live mirror — a local git backup of
3666
+ the markdown projection (backup on by default; GitHub off-site is an
3667
+ opt-in upgrade). --no-mirror creates a bare vault with no mirror config.
3668
+ Operators can flip the server-wide default with 'default_mirror: off' in
3669
+ config.yaml (recommended for cloud / disk-constrained boxes).
3452
3670
  parachute-vault list List all vaults
3453
3671
  parachute-vault remove <name> [--yes] Remove a vault
3454
3672
  parachute-vault mcp-install [--mint|--token <t>]
@@ -250,6 +250,22 @@ describe("config", () => {
250
250
  writeGlobalConfig({ port: 1940, autostart: false });
251
251
  expect(readGlobalConfig().autostart).toBe(false);
252
252
  });
253
+
254
+ test("round-trips default_mirror: internal|off", () => {
255
+ // Absent: createVault falls back to the in-code default ("internal" —
256
+ // backup-on-by-default). The knob is only persisted when explicitly set.
257
+ writeGlobalConfig({ port: 1940 });
258
+ expect(readGlobalConfig().default_mirror).toBeUndefined();
259
+
260
+ // Explicit internal — new vaults get the History-preset local git mirror.
261
+ writeGlobalConfig({ port: 1940, default_mirror: "internal" });
262
+ expect(readGlobalConfig().default_mirror).toBe("internal");
263
+
264
+ // Explicit off — the opt-out operators set on git-less / disk-constrained
265
+ // / cloud boxes so new vaults are created with no mirror config.
266
+ writeGlobalConfig({ port: 1940, default_mirror: "off" });
267
+ expect(readGlobalConfig().default_mirror).toBe("off");
268
+ });
253
269
  });
254
270
 
255
271
  // ---------------------------------------------------------------------------
package/src/config.ts CHANGED
@@ -115,6 +115,17 @@ export function vaultConfigPath(name: string): string {
115
115
  return join(vaultDir(name), "vault.yaml");
116
116
  }
117
117
 
118
+ /**
119
+ * Per-vault attachments directory: `<vaultDir>/assets`, or the `ASSETS_DIR`
120
+ * env override when set (single-assets-root deployments). Lives here next to
121
+ * the other path helpers — neutral ground that both `routes.ts` (upload/serve)
122
+ * and `usage.ts` (footprint dir-walk) import without a cycle. `routes.ts`
123
+ * re-exports it for the existing callers (mirror-deps, server, triggers, …).
124
+ */
125
+ export function assetsDir(name: string): string {
126
+ return process.env.ASSETS_DIR ?? join(vaultDir(name), "assets");
127
+ }
128
+
118
129
  // ---------------------------------------------------------------------------
119
130
  // Types
120
131
  // ---------------------------------------------------------------------------
@@ -280,6 +291,29 @@ export interface GlobalConfig {
280
291
  * resolved path. See `./mirror-config.ts`.
281
292
  */
282
293
  mirror?: MirrorConfigType;
294
+ /**
295
+ * Server-wide DEFAULT for newly created vaults' backup posture. Decides
296
+ * whether `createVault` writes the History-preset internal mirror
297
+ * (local git backup of the markdown projection) at create time.
298
+ *
299
+ * - `"internal"` (default) — new vaults get a local git mirror enabled
300
+ * out of the box (backup-on-by-default). The History preset:
301
+ * `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
302
+ * auto_push:false}`. GitHub off-site backup remains an opt-in upgrade.
303
+ * - `"off"` — new vaults are created with no mirror config (the historical
304
+ * pre-default behavior). The escape hatch for git-less / disk-constrained
305
+ * boxes and cloud deploys, where doubling disk per vault is unwanted.
306
+ * Cloud / container deploys SHOULD set this to `off`.
307
+ *
308
+ * Create-time ONLY — this knob does NOT retroactively enable mirrors on
309
+ * already-created vaults (that would ~double disk across every existing
310
+ * vault). Existing-vault opt-in is a separate, deliberate follow-up.
311
+ *
312
+ * The container/cloud first-boot auto-create path in `server.ts` does NOT
313
+ * funnel through `createVault`, so it is unaffected by this knob and stays
314
+ * mirror-off regardless — matching the recommended cloud posture.
315
+ */
316
+ default_mirror?: "internal" | "off";
283
317
  /**
284
318
  * Auto-transcribe configuration for the vault↔scribe handoff (vault#353,
285
319
  * design 2026-05-21 Part 2). When `enabled: true` AND scribe is discoverable
@@ -1162,6 +1196,7 @@ export function readGlobalConfig(): GlobalConfig {
1162
1196
  const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
1163
1197
  const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
1164
1198
  const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
1199
+ const defaultMirrorMatch = yaml.match(/^default_mirror:\s*(internal|off)/m);
1165
1200
  // auto_transcribe block — currently single boolean `enabled` (vault#353).
1166
1201
  // Parsed as a nested 2-space-indent block so future fields can grow under
1167
1202
  // it without breaking the regex; only `enabled` is read for v0.6.
@@ -1190,6 +1225,9 @@ export function readGlobalConfig(): GlobalConfig {
1190
1225
  if (autostartMatch) {
1191
1226
  config.autostart = autostartMatch[1]! === "true";
1192
1227
  }
1228
+ if (defaultMirrorMatch) {
1229
+ config.default_mirror = defaultMirrorMatch[1]! as "internal" | "off";
1230
+ }
1193
1231
  if (autoTranscribeEnabled !== undefined) {
1194
1232
  config.auto_transcribe = { enabled: autoTranscribeEnabled };
1195
1233
  }
@@ -1259,6 +1297,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1259
1297
  if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
1260
1298
  if (config.discovery) lines.push(`discovery: ${config.discovery}`);
1261
1299
  if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
1300
+ if (config.default_mirror) lines.push(`default_mirror: ${config.default_mirror}`);
1262
1301
  if (config.owner_password_hash) {
1263
1302
  lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
1264
1303
  }