@openparachute/vault 0.6.0-rc.1 → 0.6.1

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 (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -10,7 +10,15 @@
10
10
 
11
11
  import { describe, test, expect, beforeEach, afterEach } from "bun:test";
12
12
  import { resolve } from "path";
13
- import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs";
13
+ import {
14
+ mkdtempSync,
15
+ rmSync,
16
+ existsSync,
17
+ readFileSync,
18
+ mkdirSync,
19
+ writeFileSync,
20
+ symlinkSync,
21
+ } from "fs";
14
22
  import { tmpdir } from "os";
15
23
  import { join } from "path";
16
24
 
@@ -20,11 +28,18 @@ function runCli(
20
28
  args: string[],
21
29
  env: Record<string, string>,
22
30
  ): { exitCode: number; stdout: string; stderr: string } {
31
+ // Hermetic: don't inherit the dev/CI box's PARACHUTE_HUB_ORIGIN. A leaked
32
+ // origin makes `detectHubPresence`'s rule-1 (configured-origin) short-circuit
33
+ // to "hub present" with no probe, flipping the no-hub guidance copy to the
34
+ // "admin wizard" variant — a CI flake when the runner env has it set. Tests
35
+ // that genuinely want a hub origin pass it explicitly via `env`.
36
+ const baseEnv: Record<string, string | undefined> = { ...process.env };
37
+ delete baseEnv.PARACHUTE_HUB_ORIGIN;
23
38
  const proc = Bun.spawnSync({
24
39
  cmd: ["bun", CLI, ...args],
25
40
  stdout: "pipe",
26
41
  stderr: "pipe",
27
- env: { ...process.env, ...env },
42
+ env: { ...baseEnv, ...env },
28
43
  });
29
44
  return {
30
45
  exitCode: proc.exitCode ?? -1,
@@ -33,6 +48,23 @@ function runCli(
33
48
  };
34
49
  }
35
50
 
51
+ /** Path to a vault's per-vault mirror-config file inside a temp PARACHUTE_HOME. */
52
+ function mirrorConfigPath(home: string, name: string): string {
53
+ return join(home, "vault", "data", name, "mirror-config.yaml");
54
+ }
55
+
56
+ /** Path to a vault's internal mirror git working tree. */
57
+ function mirrorRepoPath(home: string, name: string): string {
58
+ return join(home, "vault", "data", name, "mirror");
59
+ }
60
+
61
+ /** Write a minimal config.yaml carrying a `default_mirror` knob value. */
62
+ function writeDefaultMirrorKnob(home: string, value: "internal" | "off"): void {
63
+ const dir = join(home, "vault");
64
+ mkdirSync(dir, { recursive: true });
65
+ writeFileSync(join(dir, "config.yaml"), `port: 1940\ndefault_mirror: ${value}\n`);
66
+ }
67
+
36
68
  let home: string;
37
69
 
38
70
  beforeEach(() => {
@@ -60,13 +92,15 @@ describe("vault create --json", () => {
60
92
  expect(lines).toHaveLength(1);
61
93
  const payload = JSON.parse(lines[0]!);
62
94
  expect(payload.name).toBe("myvault");
63
- // vault#282 Stage 2: vault no longer mints pvt_* tokens. The contract
64
- // hub's admin-vaults.ts requires still holds (`token` is a string). In
65
- // this sandbox there's no hub/operator.token, so no token is issued: the
66
- // token field is the empty string and `token_guidance` explains why.
95
+ // vault#442: default auth is per-user OAuth `create` does NOT mint a
96
+ // token. The contract hub's admin-vaults.ts requires still holds (`token`
97
+ // is a string); it's the empty string and `token_guidance` carries the
98
+ // OAuth-first connect path (the hub SPA handles the empty-token case and
99
+ // mints admin via its own session-cookie path).
67
100
  expect(typeof payload.token).toBe("string");
68
101
  expect(payload.token).toBe("");
69
- expect(payload.token_guidance).toContain("No token issued");
102
+ expect(payload.token_guidance).toContain("No token minted");
103
+ expect(payload.token_guidance).toContain("per-user OAuth");
70
104
  expect(payload.set_as_default).toBe(true);
71
105
  expect(payload.paths.vault_dir).toBe(join(home, "vault", "data", "myvault"));
72
106
  expect(payload.paths.vault_db).toBe(join(home, "vault", "data", "myvault", "vault.db"));
@@ -107,7 +141,47 @@ describe("vault create --json", () => {
107
141
  );
108
142
  expect(exitCode).not.toBe(0);
109
143
  expect(stdout).toBe("");
110
- expect(stderr).toContain("letters, numbers");
144
+ expect(stderr).toContain("lowercase alphanumeric");
145
+ });
146
+
147
+ test("UPPERCASE vault name is rejected (security review — audience case-drift)", () => {
148
+ // An uppercase name would flip the JWT audience case (vault.<Name> vs
149
+ // vault.<name>) and drift from hub/init lowercasing. cmdCreate must
150
+ // reject it the same way `init` does.
151
+ const { exitCode, stdout, stderr } = runCli(
152
+ ["create", "MyVault", "--json"],
153
+ { PARACHUTE_HOME: home },
154
+ );
155
+ expect(exitCode).not.toBe(0);
156
+ expect(stdout).toBe("");
157
+ expect(stderr).toContain("lowercase");
158
+ });
159
+
160
+ test("reserved names (list/new/assets/admin) are rejected at create", () => {
161
+ // Consolidated reserved set (2026-06-09 hub-module-boundary B2). Before
162
+ // the consolidation cmdCreate hardcoded only "list" — `admin` could enter
163
+ // through `create` and capture the daemon-level /vault/admin mount.
164
+ for (const reserved of ["list", "new", "assets", "admin"]) {
165
+ const { exitCode, stdout, stderr } = runCli(
166
+ ["create", reserved, "--json"],
167
+ { PARACHUTE_HOME: home },
168
+ );
169
+ expect(exitCode).not.toBe(0);
170
+ expect(stdout).toBe("");
171
+ expect(stderr).toContain("reserved");
172
+ // The vault must not have been created.
173
+ expect(existsSync(join(home, "vault", "data", reserved))).toBe(false);
174
+ }
175
+ });
176
+
177
+ test("near-misses of reserved names (adminx, admin2) create fine", () => {
178
+ for (const name of ["adminx", "admin2"]) {
179
+ const { exitCode, stdout } = runCli(["create", name, "--json"], {
180
+ PARACHUTE_HOME: home,
181
+ });
182
+ expect(exitCode).toBe(0);
183
+ expect(JSON.parse(stdout.trim()).name).toBe(name);
184
+ }
111
185
  });
112
186
 
113
187
  test("duplicate name in --json mode errors on stderr and exits non-zero", () => {
@@ -122,6 +196,102 @@ describe("vault create --json", () => {
122
196
  });
123
197
  });
124
198
 
199
+ /**
200
+ * vault#442: default to per-user OAuth — `create` must NOT auto-mint or bake in
201
+ * a shared `vault:<name>:admin` token. Token-minting is explicit opt-in
202
+ * (`--mint`) and scope-narrow (read/write, NEVER admin); `--token <bearer>` is
203
+ * the paste path. These tests pin the behavioral contract.
204
+ */
205
+ describe("vault create — OAuth-first auth (vault#442)", () => {
206
+ test("default create does NOT mint a token — empty token + OAuth guidance (--json)", () => {
207
+ const { exitCode, stdout } = runCli(["create", "oauthy", "--json"], {
208
+ PARACHUTE_HOME: home,
209
+ });
210
+ expect(exitCode).toBe(0);
211
+ const payload = JSON.parse(stdout.trim());
212
+ // No token baked in — OAuth on first connect.
213
+ expect(payload.token).toBe("");
214
+ expect(payload.token_guidance).toContain("No token minted");
215
+ expect(payload.token_guidance).toContain("per-user OAuth");
216
+ // Never the admin-mint failure copy.
217
+ expect(payload.token_guidance).not.toContain("No token issued");
218
+ expect(payload.token_guidance).not.toContain("admin");
219
+ });
220
+
221
+ test("default create leads the human summary with the OAuth connect command", () => {
222
+ const { exitCode, stdout } = runCli(["create", "connectme"], {
223
+ PARACHUTE_HOME: home,
224
+ });
225
+ expect(exitCode).toBe(0);
226
+ expect(stdout).toContain(
227
+ "Connect your AI: claude mcp add --transport http parachute-connectme",
228
+ );
229
+ expect(stdout).toContain("no token needed");
230
+ // Scope-narrow opt-in pointer, never admin.
231
+ expect(stdout).toContain("parachute auth mint-token --scope vault:connectme:read");
232
+ expect(stdout).not.toContain("vault:connectme:admin");
233
+ });
234
+
235
+ test("--scope admin is rejected from the create flow (admin never mintable here)", () => {
236
+ const { exitCode, stdout, stderr } = runCli(
237
+ ["create", "noadmin", "--mint", "--scope", "admin", "--json"],
238
+ { PARACHUTE_HOME: home },
239
+ );
240
+ expect(exitCode).not.toBe(0);
241
+ expect(stdout).toBe("");
242
+ expect(stderr).toContain('--scope must be "read" or "write"');
243
+ // The vault must not have been created (rejected before createVault).
244
+ expect(existsSync(join(home, "vault", "data", "noadmin", "vault.db"))).toBe(false);
245
+ });
246
+
247
+ test("--mint and --token are mutually exclusive", () => {
248
+ const { exitCode, stderr } = runCli(
249
+ ["create", "conflict", "--mint", "--token", "abc.def.ghi", "--json"],
250
+ { PARACHUTE_HOME: home },
251
+ );
252
+ expect(exitCode).not.toBe(0);
253
+ expect(stderr).toContain("mutually exclusive");
254
+ });
255
+
256
+ test("--token <bearer> paste path surfaces the supplied bearer (no mint attempted)", () => {
257
+ const { exitCode, stdout } = runCli(
258
+ ["create", "pasted", "--token", "header.auth.bearer", "--json"],
259
+ { PARACHUTE_HOME: home },
260
+ );
261
+ expect(exitCode).toBe(0);
262
+ const payload = JSON.parse(stdout.trim());
263
+ // The pasted bearer is surfaced verbatim — vault never minted one.
264
+ expect(payload.token).toBe("header.auth.bearer");
265
+ expect(payload.token_guidance).toContain("--token");
266
+ // No admin scope, no mint-failure copy.
267
+ expect(payload.token_guidance).not.toContain("No token issued");
268
+ });
269
+
270
+ test("--mint (no hub reachable) opts in but mints scope-narrow read, never admin", () => {
271
+ // In this sandbox there's no hub/operator.token, so the mint can't complete
272
+ // — but the request is scope-narrow read by default and must NEVER ask for
273
+ // an admin grant. We assert the create still succeeds and the guidance is
274
+ // the standalone path (the scope requested is read, per
275
+ // mintBootstrapCredential).
276
+ //
277
+ // Point the hub-presence probe at a guaranteed-closed port so the test is
278
+ // deterministic regardless of whether a real hub happens to be running on
279
+ // the dev box's 1939 (#445 added a live `/health` probe to branch the
280
+ // no-operator-token copy).
281
+ const { exitCode, stdout } = runCli(
282
+ ["create", "wantmint", "--mint", "--json"],
283
+ { PARACHUTE_HOME: home, PARACHUTE_HUB_PORT: "59399" },
284
+ );
285
+ expect(exitCode).toBe(0);
286
+ const payload = JSON.parse(stdout.trim());
287
+ // No hub here → no token, and the standalone guidance asks for NO admin
288
+ // grant (the #445 hub-present "admin wizard" copy is gated out by the dead
289
+ // probe port above).
290
+ expect(payload.token).toBe("");
291
+ expect(payload.token_guidance).not.toContain("admin");
292
+ });
293
+ });
294
+
125
295
  /**
126
296
  * Regression tests for #208: `vault create` was not updating
127
297
  * `~/.parachute/services.json`, so vaults created after init were invisible
@@ -191,11 +361,169 @@ describe("vault create (human mode)", () => {
191
361
  );
192
362
  expect(exitCode).toBe(0);
193
363
  expect(stdout).toContain('Vault "human" created.');
194
- // vault#282 Stage 2: with no hub reachable in this sandbox, no token is
195
- // issued the human output prints the guidance instead of "API token:".
196
- expect(stdout).toContain("No token issued");
197
- expect(stdout).toContain("Install the hub");
364
+ // vault#442: default auth is per-user OAuth NO token is minted, even when
365
+ // a hub would have been reachable. The human output leads with the OAuth
366
+ // connect command and never prints an "API token:" line.
367
+ expect(stdout).toContain("No token minted");
368
+ expect(stdout).toContain("Connect your AI: claude mcp add --transport http parachute-human");
369
+ expect(stdout).not.toContain("API token:");
370
+ // The old admin auto-mint failure copy must NOT fire on a default create.
371
+ expect(stdout).not.toContain("No token issued");
198
372
  // Human output should NOT be valid JSON.
199
373
  expect(() => JSON.parse(stdout.trim())).toThrow();
200
374
  });
201
375
  });
376
+
377
+ /**
378
+ * Create-time default backup posture: new vaults default to an internal live
379
+ * mirror (local git backup of the markdown projection). The History preset
380
+ * `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
381
+ * auto_push:false}` is written to `data/<vault>/mirror-config.yaml` and (when
382
+ * git is present) the internal mirror dir is git-bootstrapped. The behavior is
383
+ * controlled by the server-wide `default_mirror` knob (default `internal`) and
384
+ * overridable per-create by `--no-mirror`.
385
+ *
386
+ * Critically: create-time ONLY — already-created vaults are NOT retroactively
387
+ * migrated, and the git-less box stays a successful create (best-effort
388
+ * bootstrap, config still written, actionable log).
389
+ */
390
+ describe("vault create — default internal live mirror", () => {
391
+ test("default (no knob) → History-preset mirror config written + git-bootstrapped", () => {
392
+ const { exitCode } = runCli(["create", "backed", "--json"], {
393
+ PARACHUTE_HOME: home,
394
+ });
395
+ expect(exitCode).toBe(0);
396
+
397
+ // Mirror config exists with the exact History preset.
398
+ const cfgPath = mirrorConfigPath(home, "backed");
399
+ expect(existsSync(cfgPath)).toBe(true);
400
+ const cfg = readFileSync(cfgPath, "utf-8");
401
+ expect(cfg).toContain("enabled: true");
402
+ expect(cfg).toContain("location: internal");
403
+ expect(cfg).toContain("sync_mode: events");
404
+ expect(cfg).toContain("auto_commit: true");
405
+ expect(cfg).toContain("auto_push: false");
406
+
407
+ // Git present on the test host → the internal mirror dir is a real repo
408
+ // with the seed commit (bootstrap ran). counts/usage reflect it: the
409
+ // mirror working tree exists under the vault data dir.
410
+ const repo = mirrorRepoPath(home, "backed");
411
+ expect(existsSync(join(repo, ".git"))).toBe(true);
412
+ const log = Bun.spawnSync({
413
+ cmd: ["git", "-C", repo, "log", "--oneline"],
414
+ stdout: "pipe",
415
+ });
416
+ expect(new TextDecoder().decode(log.stdout)).toContain("initial mirror bootstrap");
417
+ });
418
+
419
+ test("default_mirror: off knob → no mirror config written", () => {
420
+ writeDefaultMirrorKnob(home, "off");
421
+ const { exitCode } = runCli(["create", "bare", "--json"], {
422
+ PARACHUTE_HOME: home,
423
+ });
424
+ expect(exitCode).toBe(0);
425
+
426
+ // Vault is created…
427
+ expect(existsSync(join(home, "vault", "data", "bare", "vault.db"))).toBe(true);
428
+ // …but NO mirror config + NO mirror dir.
429
+ expect(existsSync(mirrorConfigPath(home, "bare"))).toBe(false);
430
+ expect(existsSync(mirrorRepoPath(home, "bare"))).toBe(false);
431
+ });
432
+
433
+ test("default_mirror: internal knob (explicit) → mirror config written", () => {
434
+ writeDefaultMirrorKnob(home, "internal");
435
+ const { exitCode } = runCli(["create", "explicit", "--json"], {
436
+ PARACHUTE_HOME: home,
437
+ });
438
+ expect(exitCode).toBe(0);
439
+ expect(existsSync(mirrorConfigPath(home, "explicit"))).toBe(true);
440
+ });
441
+
442
+ test("--no-mirror → no mirror config even when knob is internal", () => {
443
+ writeDefaultMirrorKnob(home, "internal");
444
+ const { exitCode } = runCli(["create", "optout", "--no-mirror", "--json"], {
445
+ PARACHUTE_HOME: home,
446
+ });
447
+ expect(exitCode).toBe(0);
448
+
449
+ expect(existsSync(join(home, "vault", "data", "optout", "vault.db"))).toBe(true);
450
+ expect(existsSync(mirrorConfigPath(home, "optout"))).toBe(false);
451
+ expect(existsSync(mirrorRepoPath(home, "optout"))).toBe(false);
452
+ });
453
+
454
+ test("--no-mirror in human mode keeps the rest of the create working", () => {
455
+ const { exitCode, stdout } = runCli(["create", "humanbare", "--no-mirror"], {
456
+ PARACHUTE_HOME: home,
457
+ });
458
+ expect(exitCode).toBe(0);
459
+ expect(stdout).toContain('Vault "humanbare" created.');
460
+ expect(existsSync(mirrorConfigPath(home, "humanbare"))).toBe(false);
461
+ });
462
+
463
+ test("git missing → vault still creates, config written, mirror NOT bootstrapped, actionable log, no crash", () => {
464
+ // Simulate a git-less server: spawn the CLI with a PATH that resolves
465
+ // `bun` (so the test can run) but NOT `git`. `bootstrapInternalMirror`'s
466
+ // `Bun.which("git")` preflight then returns null → friendly,
467
+ // best-effort failure. The vault create MUST still succeed.
468
+ const bunBin = Bun.which("bun");
469
+ expect(bunBin).toBeTruthy();
470
+ const fakeBin = mkdtempSync(join(tmpdir(), "vault-create-nogit-bin-"));
471
+ symlinkSync(bunBin!, join(fakeBin, "bun"));
472
+ try {
473
+ const proc = Bun.spawnSync({
474
+ cmd: [bunBin!, CLI, "create", "nogit", "--json"],
475
+ stdout: "pipe",
476
+ stderr: "pipe",
477
+ // Replace PATH entirely so `git` is unresolvable — only our fake bin
478
+ // (bun only) is on the path.
479
+ env: { ...process.env, PARACHUTE_HOME: home, PATH: fakeBin },
480
+ });
481
+ const exitCode = proc.exitCode ?? -1;
482
+ const stdout = new TextDecoder().decode(proc.stdout);
483
+ const stderr = new TextDecoder().decode(proc.stderr);
484
+
485
+ // No crash — the vault create succeeds on a git-less box.
486
+ expect(exitCode).toBe(0);
487
+ // The vault itself was created.
488
+ expect(existsSync(join(home, "vault", "data", "nogit", "vault.db"))).toBe(true);
489
+ // The mirror CONFIG was still written (intent persists for when git
490
+ // lands later).
491
+ expect(existsSync(mirrorConfigPath(home, "nogit"))).toBe(true);
492
+ // But the mirror was NOT git-bootstrapped (no .git — git was absent).
493
+ expect(existsSync(join(mirrorRepoPath(home, "nogit"), ".git"))).toBe(false);
494
+ // And the operator got an actionable, clear log on stderr.
495
+ expect(stderr).toContain("local git backup configured but not yet active");
496
+ expect(stderr).toContain("Install git");
497
+ // JSON stdout stays clean + parseable (the note went to stderr).
498
+ const payload = JSON.parse(stdout.trim());
499
+ expect(payload.name).toBe("nogit");
500
+ } finally {
501
+ rmSync(fakeBin, { recursive: true, force: true });
502
+ }
503
+ });
504
+
505
+ test("EXISTING vault (created before this change) is NOT auto-migrated on a later create", () => {
506
+ // Simulate a vault that predates the default-mirror behavior: create it
507
+ // with the knob OFF so it gets no mirror config (stand-in for "created by
508
+ // an older vault build").
509
+ writeDefaultMirrorKnob(home, "off");
510
+ const first = runCli(["create", "legacy", "--json"], { PARACHUTE_HOME: home });
511
+ expect(first.exitCode).toBe(0);
512
+ expect(existsSync(mirrorConfigPath(home, "legacy"))).toBe(false);
513
+
514
+ // Now flip the knob ON and create a SECOND, different vault. The act of
515
+ // creating "fresh" (which DOES get a mirror) must not retroactively write
516
+ // a mirror config onto the pre-existing "legacy" vault — create-time only,
517
+ // never a sweep over existing vaults.
518
+ writeDefaultMirrorKnob(home, "internal");
519
+ const second = runCli(["create", "fresh", "--json"], { PARACHUTE_HOME: home });
520
+ expect(second.exitCode).toBe(0);
521
+
522
+ // The new vault is backed…
523
+ expect(existsSync(mirrorConfigPath(home, "fresh"))).toBe(true);
524
+ // …but the pre-existing vault is left exactly as it was — no surprise
525
+ // mirror config, no surprise disk-doubling mirror repo.
526
+ expect(existsSync(mirrorConfigPath(home, "legacy"))).toBe(false);
527
+ expect(existsSync(mirrorRepoPath(home, "legacy"))).toBe(false);
528
+ });
529
+ });
@@ -7,7 +7,13 @@
7
7
  */
8
8
 
9
9
  import { describe, test, expect } from "bun:test";
10
- import { validateVaultName, decideInitVaultName, resolveFirstBootVaultName } from "./vault-name.ts";
10
+ import {
11
+ RESERVED_VAULT_NAMES,
12
+ decideInitVaultName,
13
+ reservedNameSquatWarnings,
14
+ resolveFirstBootVaultName,
15
+ validateVaultName,
16
+ } from "./vault-name.ts";
11
17
 
12
18
  describe("validateVaultName", () => {
13
19
  describe("accepts", () => {
@@ -69,12 +75,29 @@ describe("validateVaultName", () => {
69
75
  }
70
76
  });
71
77
 
72
- test("reserved name 'list'", () => {
73
- const result = validateVaultName("list");
78
+ // The consolidated reserved set (2026-06-09 hub-module-boundary B2):
79
+ // `list` (legacy), `new` + `assets` (hub SPA route collisions), `admin`
80
+ // (the daemon-level /vault/admin multi-vault mount). Kept in lockstep
81
+ // with hub's RESERVED_VAULT_NAMES.
82
+ test.each(["list", "new", "assets", "admin"])("reserved name '%s'", (name) => {
83
+ const result = validateVaultName(name);
74
84
  expect(result.ok).toBe(false);
75
85
  if (!result.ok) expect(result.error).toContain("reserved");
76
86
  });
77
87
 
88
+ test("the exported set carries exactly the four consolidated names", () => {
89
+ expect([...RESERVED_VAULT_NAMES].sort()).toEqual(["admin", "assets", "list", "new"]);
90
+ });
91
+
92
+ test.each(["adminx", "admin2", "newer", "asset", "listing"])(
93
+ "near-miss '%s' is NOT reserved",
94
+ (name) => {
95
+ const result = validateVaultName(name);
96
+ expect(result.ok).toBe(true);
97
+ if (result.ok) expect(result.name).toBe(name);
98
+ },
99
+ );
100
+
78
101
  test("single character (below 2-char min)", () => {
79
102
  const result = validateVaultName("a");
80
103
  expect(result.ok).toBe(false);
@@ -95,6 +118,41 @@ describe("validateVaultName", () => {
95
118
  });
96
119
  });
97
120
 
121
+ describe("reservedNameSquatWarnings", () => {
122
+ test("fires for a squatted 'admin' vault, naming the shadowing + recovery", () => {
123
+ const warnings = reservedNameSquatWarnings(["default", "admin"]);
124
+ expect(warnings).toHaveLength(1);
125
+ expect(warnings[0]).toContain('vault "admin"');
126
+ expect(warnings[0]).toContain("shadowed");
127
+ expect(warnings[0]).toContain("/vault/admin/*");
128
+ // Recovery procedure (no rename command exists): export → create →
129
+ // import → remove.
130
+ expect(warnings[0]).toContain("export");
131
+ expect(warnings[0]).toContain("create <newname>");
132
+ expect(warnings[0]).toContain("import");
133
+ expect(warnings[0]).toContain("remove admin --yes");
134
+ });
135
+
136
+ test("fires once per squatted name — admin + new + assets all warned", () => {
137
+ const warnings = reservedNameSquatWarnings(["admin", "new", "assets", "ok"]);
138
+ expect(warnings).toHaveLength(3);
139
+ expect(warnings.join("\n")).toContain('vault "admin"');
140
+ expect(warnings.join("\n")).toContain('vault "new"');
141
+ expect(warnings.join("\n")).toContain('vault "assets"');
142
+ });
143
+
144
+ test("silent for clean vault lists and near-misses", () => {
145
+ expect(reservedNameSquatWarnings([])).toEqual([]);
146
+ expect(reservedNameSquatWarnings(["default", "work", "adminx", "admin2"])).toEqual([]);
147
+ });
148
+
149
+ test("silent for 'list' (reserved for consistency, but not shadowed)", () => {
150
+ // `/vault/list/*` still routes per-vault — list is reserved at create
151
+ // time but a legacy squatter keeps working, so no scary boot warning.
152
+ expect(reservedNameSquatWarnings(["list"])).toEqual([]);
153
+ });
154
+ });
155
+
98
156
  describe("decideInitVaultName", () => {
99
157
  test("--vault-name=aaron resolves to name 'aaron'", () => {
100
158
  const d = decideInitVaultName(["--vault-name", "aaron"], { isTTY: true });
package/src/vault-name.ts CHANGED
@@ -7,34 +7,82 @@
7
7
  * rejected up front.
8
8
  *
9
9
  * Rule: lowercase alphanumeric + hyphens or underscores, 2–32 chars, with
10
- * `list` reserved. Used by the `init` prompt, the `--vault-name` flag, and
11
- * the `PARACHUTE_VAULT_NAME` env var at server first-boot. `cmdCreate`
12
- * keeps its own (slightly more permissive, legacy) regex for backward
13
- * compattightening it would reject names existing users may already
14
- * have minted.
10
+ * `list` / `new` / `assets` / `admin` reserved. Used by the `init` prompt,
11
+ * the `--vault-name` flag, the `PARACHUTE_VAULT_NAME` env var at server
12
+ * first-boot, and (since the 2026-06-09 hub-module-boundary migration B2)
13
+ * `cmdCreate`every name-minting edge shares this one validator.
15
14
  */
16
15
 
17
16
  const VAULT_NAME_RE = /^[a-z0-9_-]+$/;
18
17
  const VAULT_NAME_MIN_LEN = 2;
19
18
  const VAULT_NAME_MAX_LEN = 32;
20
19
 
21
- const RESERVED_NAMES = new Set([
22
- // Collides with the `/vaults/list` discovery endpoint historically; the
23
- // routes have since moved under `/vault/<name>/`, but `cmdCreate` still
24
- // rejects "list" and consistency is cheap.
20
+ /**
21
+ * THE reserved vault-name set kept in lockstep with hub's
22
+ * `RESERVED_VAULT_NAMES` (parachute-hub/src/vault-name.ts, B2h of the
23
+ * 2026-06-09 hub-module-boundary migration). A vault under any of these
24
+ * names would have its URL surface captured by a reserved route:
25
+ *
26
+ * - `list` — legacy `/vaults/list` discovery-endpoint collision; the
27
+ * routes have since moved under `/vault/<name>/`, but consistency with
28
+ * the historical reservation is cheap.
29
+ * - `new` — collides with `/vault/new`, the hub SPA's create route.
30
+ * - `assets` — collides with `/vault/assets/*`, the hub SPA's bundle.
31
+ * - `admin` — collides with `/vault/admin`, the daemon-level mount for
32
+ * vault's own multi-vault admin surface (B3). Both the hub's route and
33
+ * this daemon's own routing dispatch `/vault/admin/*` before the
34
+ * per-vault branch, so a vault named `admin` would be fully shadowed.
35
+ */
36
+ export const RESERVED_VAULT_NAMES: ReadonlySet<string> = new Set([
25
37
  "list",
38
+ "new",
39
+ "assets",
40
+ "admin",
26
41
  ]);
27
42
 
43
+ /**
44
+ * The subset of reserved names whose data plane is SHADOWED by reserved
45
+ * routes when a vault squats them (created before the reservation landed).
46
+ * `list` is reserved for historical consistency only — `/vault/list/*`
47
+ * still routes per-vault — so it's excluded here.
48
+ */
49
+ const SHADOWED_RESERVED_NAMES = ["admin", "new", "assets"] as const;
50
+
51
+ /**
52
+ * Build boot-time warnings for vaults squatting a shadowed reserved name.
53
+ * Pure (takes the vault list, returns warning strings) so server boot can
54
+ * `console.warn` each and tests can pin the copy without booting a server.
55
+ *
56
+ * Recovery procedure is spelled out because no rename command exists:
57
+ * export → create under a new name → import → remove the squatter.
58
+ */
59
+ export function reservedNameSquatWarnings(vaults: readonly string[]): string[] {
60
+ const warnings: string[] = [];
61
+ for (const reserved of SHADOWED_RESERVED_NAMES) {
62
+ if (!vaults.includes(reserved)) continue;
63
+ warnings.push(
64
+ `[reserved-name] vault "${reserved}" exists but "${reserved}" is a reserved name — ` +
65
+ `its data plane (/vault/${reserved}/*) is shadowed by reserved hub/daemon routes, so its ` +
66
+ `MCP, REST, and admin surfaces are unreachable. Recover by renaming it (no rename ` +
67
+ `command exists): parachute-vault export <dir> --vault ${reserved} → ` +
68
+ `parachute-vault create <newname> → parachute-vault import <dir> --vault <newname> → ` +
69
+ `parachute-vault remove ${reserved} --yes`,
70
+ );
71
+ }
72
+ return warnings;
73
+ }
74
+
28
75
  export type VaultNameValidation =
29
76
  | { ok: true; name: string }
30
77
  | { ok: false; error: string };
31
78
 
32
79
  /**
33
80
  * Validate a vault name. Accepts lowercase alphanumeric + hyphens or
34
- * underscores, 2–32 chars. Trims surrounding whitespace before checking.
35
- * `cmdCreate` keeps its own (legacy-permissive) regex; this validator is
36
- * the strict gate used by the env var, the `--vault-name` flag, and
37
- * hub's first-boot wizard.
81
+ * underscores, 2–32 chars, none of `RESERVED_VAULT_NAMES`. Trims
82
+ * surrounding whitespace before checking. The one gate used by the env
83
+ * var, the `--vault-name` flag, hub's first-boot wizard, AND `cmdCreate`
84
+ * (consolidated 2026-06-09 — cmdCreate previously carried its own inline
85
+ * charset + `"list"` check that had drifted from this set).
38
86
  */
39
87
  export function validateVaultName(raw: string): VaultNameValidation {
40
88
  const name = raw.trim();
@@ -54,7 +102,7 @@ export function validateVaultName(raw: string): VaultNameValidation {
54
102
  "vault names must be lowercase alphanumeric with hyphens or underscores. Try again.",
55
103
  };
56
104
  }
57
- if (RESERVED_NAMES.has(name)) {
105
+ if (RESERVED_VAULT_NAMES.has(name)) {
58
106
  return { ok: false, error: `"${name}" is a reserved vault name.` };
59
107
  }
60
108
  return { ok: true, name };