@openparachute/vault 0.4.5 → 0.4.6-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -810,8 +810,37 @@ cp .env.example .env # edit with your config
810
810
  docker compose up -d
811
811
  ```
812
812
 
813
+ Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first vault (defaults to `default`). Lowercase alphanumeric + hyphens or underscores, 2–32 chars.
814
+
813
815
  ### Cloud platforms
814
816
 
817
+ #### Render — recommended (hub-managed, v0.6)
818
+
819
+ Most users deploy vault via parachute-hub's `/admin/modules` after the
820
+ hub itself is on Render. See <https://parachute.computer/deploy/render/>
821
+ for the primary v0.6 self-host story: one Render Blueprint provisions
822
+ hub on a persistent disk, then you install vault (and the other
823
+ Parachute modules) from the hub's admin UI. The hub container
824
+ supervises vault's process, the shared persistent disk holds vault
825
+ state, and module upgrades flow through the admin UI rather than
826
+ separate Render redeploys.
827
+
828
+ #### Render — standalone vault (advanced)
829
+
830
+ The `render.yaml` Blueprint at the repo root deploys vault as its own
831
+ Render web service, separate from hub. This is the **advanced path** —
832
+ useful when you want vault on its own container (separate scaling,
833
+ isolated logs, vault-only deploy without a hub) but not what the
834
+ typical v0.6 self-host wants. If you're not sure, use the hub-managed
835
+ path above. The standalone Blueprint stays in tree because some
836
+ operators specifically want this shape (vault#341).
837
+
838
+ Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first
839
+ vault (defaults to `default`). Lowercase alphanumeric + hyphens or
840
+ underscores, 2–32 chars.
841
+
842
+ #### Other platforms
843
+
815
844
  **Railway** ($5/mo) — Deploy from GitHub, persistent volume, public URL.
816
845
  **Fly.io** ($3-5/mo) — `fly launch --copy-config && fly volumes create vault_data --size 1 && fly deploy`
817
846
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.5",
3
+ "version": "0.4.6-rc.3",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/auth.test.ts CHANGED
@@ -398,3 +398,238 @@ describe("auth — legacy global YAML keys honor declared scope", () => {
398
398
  }
399
399
  });
400
400
  });
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // VAULT_AUTH_TOKEN — server-wide operator bearer (vault#339)
404
+ //
405
+ // The container-shape auth gate. When the env var is set, a request whose
406
+ // `Authorization: Bearer <value>` matches authenticates as full/admin
407
+ // against any vault on the server — the operator-channel path for sibling
408
+ // services on Render where vault and hub run as separate containers and
409
+ // hub needs a stable shared bearer to call vault.
410
+ //
411
+ // Semantic confirmed for the loopback/non-loopback split (auth gate is
412
+ // orthogonal to socket-level loopback): when VAULT_AUTH_TOKEN is unset,
413
+ // vault's existing token surface (per-vault DB tokens + hub JWTs + legacy
414
+ // YAML keys) is the ONLY auth surface. The bind socket defaults to
415
+ // 127.0.0.1 (`VAULT_BIND` in bind.ts), but no implicit loopback trust
416
+ // exists at the auth layer — a request from 127.0.0.1 still has to
417
+ // present a valid bearer. This matches docs/auth-model.md §1.
418
+ // ---------------------------------------------------------------------------
419
+
420
+ describe("auth — VAULT_AUTH_TOKEN server-wide operator bearer", () => {
421
+ const TOKEN = "test-operator-token-deadbeef0123456789abcdef";
422
+ let prevToken: string | undefined;
423
+
424
+ beforeEach(() => {
425
+ prevToken = process.env.VAULT_AUTH_TOKEN;
426
+ });
427
+
428
+ afterEach(() => {
429
+ if (prevToken === undefined) delete process.env.VAULT_AUTH_TOKEN;
430
+ else process.env.VAULT_AUTH_TOKEN = prevToken;
431
+ });
432
+
433
+ test("env set + matching bearer → 200 on vault auth, full permission, admin scopes", async () => {
434
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
435
+ seedVault("journal");
436
+ const journalConfig = readVaultConfig("journal")!;
437
+ const journalStore = getVaultStore("journal");
438
+
439
+ const result = await authenticateVaultRequest(
440
+ bearer(TOKEN),
441
+ journalConfig,
442
+ journalStore.db,
443
+ );
444
+
445
+ expect("error" in result).toBe(false);
446
+ if (!("error" in result)) {
447
+ expect(result.permission).toBe("full");
448
+ expect(result.scopes).toContain("vault:admin");
449
+ expect(result.legacyDerived).toBe(false);
450
+ expect(result.scoped_tags).toBeNull();
451
+ }
452
+ });
453
+
454
+ test("env set + matching bearer authenticates against ANY vault on the server", async () => {
455
+ // Server-wide → not tied to any one vault's DB. Same bearer works
456
+ // for journal and work without minting a per-vault token in either.
457
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
458
+ seedVault("journal");
459
+ seedVault("work");
460
+ const journalConfig = readVaultConfig("journal")!;
461
+ const journalStore = getVaultStore("journal");
462
+ const workConfig = readVaultConfig("work")!;
463
+ const workStore = getVaultStore("work");
464
+
465
+ const j = await authenticateVaultRequest(bearer(TOKEN), journalConfig, journalStore.db);
466
+ const w = await authenticateVaultRequest(bearer(TOKEN), workConfig, workStore.db);
467
+
468
+ expect("error" in j).toBe(false);
469
+ expect("error" in w).toBe(false);
470
+ });
471
+
472
+ test("env set + missing bearer → 401 (no implicit auth)", async () => {
473
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
474
+ seedVault("journal");
475
+ const journalConfig = readVaultConfig("journal")!;
476
+ const journalStore = getVaultStore("journal");
477
+
478
+ // No Authorization header at all.
479
+ const noBearer = new Request("https://vault.test/x");
480
+ const result = await authenticateVaultRequest(noBearer, journalConfig, journalStore.db);
481
+
482
+ expect("error" in result).toBe(true);
483
+ if ("error" in result) {
484
+ expect(result.error.status).toBe(401);
485
+ }
486
+ });
487
+
488
+ test("env set + wrong bearer → 401", async () => {
489
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
490
+ seedVault("journal");
491
+ const journalConfig = readVaultConfig("journal")!;
492
+ const journalStore = getVaultStore("journal");
493
+
494
+ const result = await authenticateVaultRequest(
495
+ bearer("wrong-token-doesnotmatch"),
496
+ journalConfig,
497
+ journalStore.db,
498
+ );
499
+
500
+ expect("error" in result).toBe(true);
501
+ if ("error" in result) {
502
+ expect(result.error.status).toBe(401);
503
+ }
504
+ });
505
+
506
+ test("env set + bearer that matches a vault token still resolves (server-wide first, but per-vault unchanged)", async () => {
507
+ // Per-vault tokens keep working even when the server-wide bearer is
508
+ // set. The server-wide check is a fast-path lookup before token DB
509
+ // resolution — a per-vault token doesn't match the env var so it
510
+ // falls through to the existing path.
511
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
512
+ seedVault("journal");
513
+ const perVaultToken = mintTokenInVault("journal");
514
+ const journalConfig = readVaultConfig("journal")!;
515
+ const journalStore = getVaultStore("journal");
516
+
517
+ const result = await authenticateVaultRequest(
518
+ bearer(perVaultToken),
519
+ journalConfig,
520
+ journalStore.db,
521
+ );
522
+
523
+ expect("error" in result).toBe(false);
524
+ });
525
+
526
+ test("env unset + valid per-vault bearer → 200 (existing behavior preserved)", async () => {
527
+ delete process.env.VAULT_AUTH_TOKEN;
528
+ seedVault("journal");
529
+ const token = mintTokenInVault("journal");
530
+ const journalConfig = readVaultConfig("journal")!;
531
+ const journalStore = getVaultStore("journal");
532
+
533
+ const result = await authenticateVaultRequest(bearer(token), journalConfig, journalStore.db);
534
+ expect("error" in result).toBe(false);
535
+ });
536
+
537
+ test("env unset + missing bearer → 401 (existing behavior preserved)", async () => {
538
+ delete process.env.VAULT_AUTH_TOKEN;
539
+ seedVault("journal");
540
+ const journalConfig = readVaultConfig("journal")!;
541
+ const journalStore = getVaultStore("journal");
542
+
543
+ const noBearer = new Request("https://vault.test/x");
544
+ const result = await authenticateVaultRequest(noBearer, journalConfig, journalStore.db);
545
+ expect("error" in result).toBe(true);
546
+ if ("error" in result) {
547
+ expect(result.error.status).toBe(401);
548
+ }
549
+ });
550
+
551
+ test("env unset + non-loopback simulated via X-Forwarded-For → still 401 without bearer", async () => {
552
+ // Doc note: vault has NO implicit loopback trust at the auth layer.
553
+ // The X-Forwarded-For shape (set by hub / Cloudflare Tunnel / etc.)
554
+ // doesn't affect the auth gate; tokens are required regardless of
555
+ // socket origin. The `bind.ts` 127.0.0.1 default is a socket-level
556
+ // listen-restriction, not a trust-asymmetric auth bypass.
557
+ delete process.env.VAULT_AUTH_TOKEN;
558
+ seedVault("journal");
559
+ const journalConfig = readVaultConfig("journal")!;
560
+ const journalStore = getVaultStore("journal");
561
+
562
+ const remote = new Request("https://vault.test/x", {
563
+ headers: { "X-Forwarded-For": "203.0.113.7" },
564
+ });
565
+ const result = await authenticateVaultRequest(remote, journalConfig, journalStore.db);
566
+ expect("error" in result).toBe(true);
567
+ if ("error" in result) {
568
+ expect(result.error.status).toBe(401);
569
+ }
570
+ });
571
+
572
+ test("env set with whitespace-only value → treated as unset", async () => {
573
+ process.env.VAULT_AUTH_TOKEN = " ";
574
+ seedVault("journal");
575
+ const journalConfig = readVaultConfig("journal")!;
576
+ const journalStore = getVaultStore("journal");
577
+
578
+ // An empty/whitespace VAULT_AUTH_TOKEN must NOT allow any bearer to
579
+ // pass — the operator either commits to bearer auth or doesn't.
580
+ const result = await authenticateVaultRequest(
581
+ bearer(""),
582
+ journalConfig,
583
+ journalStore.db,
584
+ );
585
+ expect("error" in result).toBe(true);
586
+ });
587
+
588
+ test("env set + matching bearer also works on the global auth surface", async () => {
589
+ // /vaults metadata listing + /health vault names go through
590
+ // authenticateGlobalRequest. The server-wide bearer must work there
591
+ // too — otherwise hub couldn't enumerate vaults using the operator
592
+ // channel.
593
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
594
+ seedVault("journal");
595
+
596
+ const result = await authenticateGlobalRequest(bearer(TOKEN));
597
+ expect("error" in result).toBe(false);
598
+ if (!("error" in result)) {
599
+ expect(result.permission).toBe("full");
600
+ }
601
+ });
602
+
603
+ test("env set + wrong bearer on global auth surface → 401", async () => {
604
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
605
+ seedVault("journal");
606
+
607
+ const result = await authenticateGlobalRequest(bearer("wrong-token"));
608
+ expect("error" in result).toBe(true);
609
+ if ("error" in result) {
610
+ expect(result.error.status).toBe(401);
611
+ }
612
+ });
613
+
614
+ test("near-miss bearer (one-char difference, same length) → 401 (constant-time compare)", async () => {
615
+ // Defensive: the server-wide compare uses crypto.timingSafeEqual so
616
+ // a one-char-off bearer that matches length-wise still rejects.
617
+ // We can't measure timing in a unit test, but we can pin the
618
+ // correctness side: a same-length near-miss must still reject.
619
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
620
+ seedVault("journal");
621
+ const journalConfig = readVaultConfig("journal")!;
622
+ const journalStore = getVaultStore("journal");
623
+
624
+ const nearMiss = TOKEN.slice(0, -1) + "x";
625
+ expect(nearMiss).not.toBe(TOKEN);
626
+ expect(nearMiss.length).toBe(TOKEN.length);
627
+
628
+ const result = await authenticateVaultRequest(
629
+ bearer(nearMiss),
630
+ journalConfig,
631
+ journalStore.db,
632
+ );
633
+ expect("error" in result).toBe(true);
634
+ });
635
+ });
package/src/auth.ts CHANGED
@@ -20,6 +20,7 @@ import type { VaultConfig, StoredKey } from "./config.ts";
20
20
  import { resolveToken } from "./token-store.ts";
21
21
  import type { TokenPermission } from "./token-store.ts";
22
22
  import type { Database } from "bun:sqlite";
23
+ import crypto from "node:crypto";
23
24
  import { getVaultStore } from "./vault-store.ts";
24
25
  import {
25
26
  findBroadVaultScopes,
@@ -32,6 +33,66 @@ import {
32
33
  } from "./scopes.ts";
33
34
  import { HubJwtError, looksLikeJwt, validateHubJwt } from "./hub-jwt.ts";
34
35
 
36
+ /**
37
+ * Server-wide operator bearer token, sourced from the `VAULT_AUTH_TOKEN`
38
+ * environment variable.
39
+ *
40
+ * Read dynamically per request (not cached at import time) so test seams
41
+ * that mutate `process.env.VAULT_AUTH_TOKEN` work without re-importing.
42
+ * In production the env var is set at container start and doesn't change.
43
+ *
44
+ * Empty / whitespace-only values are treated as unset — the operator
45
+ * either commits to bearer auth or doesn't, no degraded "empty token
46
+ * always matches" failure mode.
47
+ */
48
+ function getServerWideAuthToken(env: NodeJS.ProcessEnv = process.env): string | null {
49
+ const raw = env.VAULT_AUTH_TOKEN;
50
+ if (typeof raw !== "string") return null;
51
+ const trimmed = raw.trim();
52
+ return trimmed.length === 0 ? null : trimmed;
53
+ }
54
+
55
+ /**
56
+ * Constant-time string equality. Returns false when lengths differ
57
+ * (timingSafeEqual throws on length mismatch; we want a quiet false).
58
+ */
59
+ function constantTimeEquals(a: string, b: string): boolean {
60
+ const aBuf = Buffer.from(a, "utf8");
61
+ const bBuf = Buffer.from(b, "utf8");
62
+ if (aBuf.length !== bBuf.length) return false;
63
+ return crypto.timingSafeEqual(aBuf, bBuf);
64
+ }
65
+
66
+ /**
67
+ * If `VAULT_AUTH_TOKEN` is set and the provided bearer matches it
68
+ * (constant-time), return a full-admin AuthResult that's accepted by
69
+ * every vault on the server.
70
+ *
71
+ * The operator-channel auth shape for non-loopback deploys (Render,
72
+ * sibling-container setups, vault#339). Hub uses this to call vault
73
+ * across a container boundary; end-user OAuth tokens still take the
74
+ * per-vault hub-JWT / pvt_* paths below. See `docs/auth-model.md` §2.
75
+ *
76
+ * Scope set is broad (`vault:admin`) — the env-var bearer is an
77
+ * operator credential, not a user credential. Tag-scoping doesn't
78
+ * apply; we represent it as unscoped (`scoped_tags: null`).
79
+ */
80
+ function tryServerWideAuth(
81
+ providedKey: string,
82
+ env: NodeJS.ProcessEnv = process.env,
83
+ ): AuthResult | null {
84
+ const configured = getServerWideAuthToken(env);
85
+ if (configured === null) return null;
86
+ if (!constantTimeEquals(providedKey, configured)) return null;
87
+ return {
88
+ permission: "full",
89
+ scopes: [SCOPE_ADMIN, SCOPE_WRITE, SCOPE_READ],
90
+ legacyDerived: false,
91
+ scoped_tags: null,
92
+ vault_name: null,
93
+ };
94
+ }
95
+
35
96
  /** Result of a successful auth check. */
36
97
  export interface AuthResult {
37
98
  permission: TokenPermission;
@@ -168,6 +229,15 @@ export async function authenticateVaultRequest(
168
229
  return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
169
230
  }
170
231
 
232
+ // Server-wide operator token (vault#339). When VAULT_AUTH_TOKEN is set,
233
+ // a matching bearer authenticates as full/admin against any vault. This
234
+ // is the cross-container path for Render / sibling-service deployments
235
+ // where hub talks to vault over HTTP. Checked first so it short-circuits
236
+ // both JWT validation and per-vault DB lookups — the operator token is
237
+ // a credential the operator opts into, not one we'd ever fall through.
238
+ const serverWide = tryServerWideAuth(key);
239
+ if (serverWide !== null) return serverWide;
240
+
171
241
  // JWT path: hub-issued tokens. Trust pinned to the hub origin via `iss`
172
242
  // verification inside validateHubJwt; signature checked against hub's JWKS.
173
243
  // Audience strict-checked against `vault.<name>` so a token stamped for
@@ -326,6 +396,14 @@ export async function authenticateGlobalRequest(
326
396
  return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
327
397
  }
328
398
 
399
+ // Server-wide operator token (vault#339). When VAULT_AUTH_TOKEN is set,
400
+ // a matching bearer authenticates as full/admin on cross-vault routes
401
+ // (/vaults metadata listing, /health detail). Checked first so a
402
+ // container-host operator-channel call doesn't depend on any per-vault
403
+ // DB lookup.
404
+ const serverWide = tryServerWideAuth(key);
405
+ if (serverWide !== null) return serverWide;
406
+
329
407
  // Hub-issued JWTs are always vault-bound (aud=vault.<name>). The unified
330
408
  // /vaults / /health surface spans every vault and has no single audience to
331
409
  // strict-check against, so JWTs aren't accepted here. Cross-vault listing
@@ -17,7 +17,7 @@
17
17
  * never touch ~/.parachute.
18
18
  */
19
19
 
20
- import { describe, test, expect, beforeEach, afterAll } from "bun:test";
20
+ import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
21
21
  import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
22
22
  import { join } from "path";
23
23
  import { tmpdir } from "os";
@@ -1641,3 +1641,87 @@ describe("scope enforcement on /api/*", () => {
1641
1641
  expect(writeRes.status).toBe(403);
1642
1642
  });
1643
1643
  });
1644
+
1645
+ // ---------------------------------------------------------------------------
1646
+ // /health — smoke tests for the unauthenticated liveness probe (vault#339).
1647
+ //
1648
+ // /health must ALWAYS return 200 regardless of VAULT_AUTH_TOKEN config so
1649
+ // Render's health probe + Docker HEALTHCHECK can poll the container even
1650
+ // before the operator has configured a bearer. The response shape changes
1651
+ // (vault names are leaked only to authed callers) but the status code is
1652
+ // invariant.
1653
+ // ---------------------------------------------------------------------------
1654
+
1655
+ describe("/health — always 200 (Render/Docker healthcheck contract)", () => {
1656
+ let prevToken: string | undefined;
1657
+
1658
+ beforeEach(() => {
1659
+ prevToken = process.env.VAULT_AUTH_TOKEN;
1660
+ });
1661
+
1662
+ afterEach(() => {
1663
+ if (prevToken === undefined) delete process.env.VAULT_AUTH_TOKEN;
1664
+ else process.env.VAULT_AUTH_TOKEN = prevToken;
1665
+ });
1666
+
1667
+ test("env unset + no bearer → 200 (anonymous probe)", async () => {
1668
+ delete process.env.VAULT_AUTH_TOKEN;
1669
+ createVault("journal");
1670
+
1671
+ const res = await route(new Request("http://localhost:1940/health"), "/health");
1672
+ expect(res.status).toBe(200);
1673
+ const body = await res.json();
1674
+ expect(body.status).toBe("ok");
1675
+ // No bearer → no vault names leaked.
1676
+ expect(body.vaults).toBeUndefined();
1677
+ });
1678
+
1679
+ test("env set + no bearer → 200 (Render's health probe doesn't have the secret)", async () => {
1680
+ process.env.VAULT_AUTH_TOKEN = "operator-token-xyz";
1681
+ createVault("journal");
1682
+
1683
+ const res = await route(new Request("http://localhost:1940/health"), "/health");
1684
+ expect(res.status).toBe(200);
1685
+ const body = await res.json();
1686
+ expect(body.status).toBe("ok");
1687
+ expect(body.vaults).toBeUndefined();
1688
+ });
1689
+
1690
+ test("env set + matching bearer → 200 + vault names leaked", async () => {
1691
+ process.env.VAULT_AUTH_TOKEN = "operator-token-xyz";
1692
+ createVault("journal");
1693
+ createVault("work");
1694
+
1695
+ const res = await route(
1696
+ new Request("http://localhost:1940/health", {
1697
+ headers: { Authorization: "Bearer operator-token-xyz" },
1698
+ }),
1699
+ "/health",
1700
+ );
1701
+ expect(res.status).toBe(200);
1702
+ const body = await res.json();
1703
+ expect(body.status).toBe("ok");
1704
+ expect(Array.isArray(body.vaults)).toBe(true);
1705
+ expect(body.vaults).toContain("journal");
1706
+ expect(body.vaults).toContain("work");
1707
+ });
1708
+
1709
+ test("env set + wrong bearer → 200 (still healthy, just no vault detail)", async () => {
1710
+ // /health doesn't 401 on a wrong bearer — it just falls back to the
1711
+ // anonymous response. The operator's probe stays green even if the
1712
+ // bearer is mid-rotation.
1713
+ process.env.VAULT_AUTH_TOKEN = "operator-token-xyz";
1714
+ createVault("journal");
1715
+
1716
+ const res = await route(
1717
+ new Request("http://localhost:1940/health", {
1718
+ headers: { Authorization: "Bearer wrong-token" },
1719
+ }),
1720
+ "/health",
1721
+ );
1722
+ expect(res.status).toBe(200);
1723
+ const body = await res.json();
1724
+ expect(body.status).toBe("ok");
1725
+ expect(body.vaults).toBeUndefined();
1726
+ });
1727
+ });
package/src/server.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath } from "./config.ts";
19
19
  import { existsSync, rmSync } from "fs";
20
20
  import { migrateVaultKeys } from "./token-store.ts";
21
+ import { resolveFirstBootVaultName } from "./vault-name.ts";
21
22
  import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
22
23
  import { defaultHookRegistry } from "../core/src/hooks.ts";
23
24
  import { registerTriggers } from "./triggers.ts";
@@ -98,13 +99,31 @@ if (process.env.SCRIBE_URL) {
98
99
  console.log("[transcribe] worker disabled (set SCRIBE_URL to enable)");
99
100
  }
100
101
 
101
- // Auto-init: create a default vault if none exist (first run in Docker)
102
+ if (process.env.VAULT_AUTH_TOKEN?.trim()) {
103
+ console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
104
+ }
105
+
106
+ // Auto-init: create a default vault if none exist (first run in Docker).
107
+ // The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
108
+ // falls back to "default". Hub's first-boot wizard (hub#267) passes through
109
+ // an operator-chosen name via this env var.
102
110
  if (listVaults().length === 0) {
103
111
  const globalConfig = readGlobalConfig();
104
112
  if (!globalConfig.default_vault) {
113
+ const firstBoot = resolveFirstBootVaultName(process.env.PARACHUTE_VAULT_NAME);
114
+ if (firstBoot.source === "env") {
115
+ console.log(`[vault first-boot] using PARACHUTE_VAULT_NAME=${firstBoot.name}`);
116
+ } else if (firstBoot.source === "env-invalid") {
117
+ console.warn(
118
+ `[vault first-boot] PARACHUTE_VAULT_NAME=${JSON.stringify(firstBoot.rawValue)} is invalid (${firstBoot.reason}); falling back to "default"`,
119
+ );
120
+ } else {
121
+ console.log("[vault first-boot] using default name (no PARACHUTE_VAULT_NAME set)");
122
+ }
123
+ const vaultName = firstBoot.name;
105
124
  const { fullKey, keyId } = generateApiKey();
106
125
  writeVaultConfig({
107
- name: "default",
126
+ name: vaultName,
108
127
  api_keys: [{
109
128
  id: keyId,
110
129
  label: "default",
@@ -114,7 +133,7 @@ if (listVaults().length === 0) {
114
133
  }],
115
134
  created_at: new Date().toISOString(),
116
135
  });
117
- globalConfig.default_vault = "default";
136
+ globalConfig.default_vault = vaultName;
118
137
  if (!globalConfig.api_keys?.length) {
119
138
  globalConfig.api_keys = [{
120
139
  id: keyId,
@@ -125,7 +144,7 @@ if (listVaults().length === 0) {
125
144
  }];
126
145
  }
127
146
  writeGlobalConfig(globalConfig);
128
- console.log(`Auto-created default vault (API key: ${fullKey})`);
147
+ console.log(`Auto-created vault "${vaultName}" (API key: ${fullKey})`);
129
148
  }
130
149
  }
131
150
 
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Unit tests for `validateVaultName` — the rule enforced by the `init`
3
- * prompt and the `--vault-name` flag. Covers each rejection branch plus
4
- * the happy paths the prompt has to accept (default, hyphens, underscores).
3
+ * prompt, the `--vault-name` flag, and the `PARACHUTE_VAULT_NAME` env var
4
+ * at server first-boot. Covers each rejection branch (empty, length,
5
+ * regex, reserved) plus the happy paths the prompt has to accept (default,
6
+ * hyphens, underscores, length boundaries).
5
7
  */
6
8
 
7
9
  import { describe, test, expect } from "bun:test";
8
- import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
10
+ import { validateVaultName, decideInitVaultName, resolveFirstBootVaultName } from "./vault-name.ts";
9
11
 
10
12
  describe("validateVaultName", () => {
11
13
  describe("accepts", () => {
@@ -14,11 +16,12 @@ describe("validateVaultName", () => {
14
16
  "aaron",
15
17
  "personal",
16
18
  "work",
17
- "a",
19
+ "ab", // 2-char boundary (min length)
18
20
  "vault-1",
19
21
  "my_vault",
20
22
  "a-b_c-1",
21
23
  "abc123",
24
+ "a".repeat(32), // 32-char boundary (max length)
22
25
  ])("%s", (name) => {
23
26
  const result = validateVaultName(name);
24
27
  expect(result.ok).toBe(true);
@@ -71,6 +74,24 @@ describe("validateVaultName", () => {
71
74
  expect(result.ok).toBe(false);
72
75
  if (!result.ok) expect(result.error).toContain("reserved");
73
76
  });
77
+
78
+ test("single character (below 2-char min)", () => {
79
+ const result = validateVaultName("a");
80
+ expect(result.ok).toBe(false);
81
+ if (!result.ok) expect(result.error).toContain("2");
82
+ });
83
+
84
+ test("33 characters (above 32-char max)", () => {
85
+ const result = validateVaultName("a".repeat(33));
86
+ expect(result.ok).toBe(false);
87
+ if (!result.ok) expect(result.error).toContain("32");
88
+ });
89
+
90
+ test("200 characters (well above max)", () => {
91
+ const result = validateVaultName("a".repeat(200));
92
+ expect(result.ok).toBe(false);
93
+ if (!result.ok) expect(result.error).toContain("32");
94
+ });
74
95
  });
75
96
  });
76
97
 
@@ -121,3 +142,78 @@ describe("decideInitVaultName", () => {
121
142
  expect(d).toEqual({ kind: "name", name: "aaron" });
122
143
  });
123
144
  });
145
+
146
+ describe("resolveFirstBootVaultName", () => {
147
+ test("env var unset → fallback to default", () => {
148
+ const r = resolveFirstBootVaultName(undefined);
149
+ expect(r).toEqual({ source: "default", name: "default" });
150
+ });
151
+
152
+ test("env var empty string → fallback to default", () => {
153
+ const r = resolveFirstBootVaultName("");
154
+ expect(r).toEqual({ source: "default", name: "default" });
155
+ });
156
+
157
+ test("env var whitespace-only → fallback to default", () => {
158
+ const r = resolveFirstBootVaultName(" ");
159
+ expect(r).toEqual({ source: "default", name: "default" });
160
+ });
161
+
162
+ test("env var set to a valid name → that name (positive path)", () => {
163
+ const r = resolveFirstBootVaultName("smoke-1939");
164
+ expect(r).toEqual({ source: "env", name: "smoke-1939" });
165
+ });
166
+
167
+ test("env var set to a valid name with underscores → that name", () => {
168
+ const r = resolveFirstBootVaultName("my_vault");
169
+ expect(r).toEqual({ source: "env", name: "my_vault" });
170
+ });
171
+
172
+ test("env var set with surrounding whitespace → trimmed + accepted", () => {
173
+ const r = resolveFirstBootVaultName(" aaron ");
174
+ expect(r).toEqual({ source: "env", name: "aaron" });
175
+ });
176
+
177
+ test("env var set to invalid (uppercase + spaces + special) → fallback to default + record raw + reason", () => {
178
+ const r = resolveFirstBootVaultName("Bad Name!!");
179
+ expect(r.source).toBe("env-invalid");
180
+ expect(r.name).toBe("default");
181
+ if (r.source === "env-invalid") {
182
+ expect(r.rawValue).toBe("Bad Name!!");
183
+ expect(r.reason).toContain("lowercase alphanumeric");
184
+ }
185
+ });
186
+
187
+ test("env var set to reserved name 'list' → fallback to default", () => {
188
+ const r = resolveFirstBootVaultName("list");
189
+ expect(r.source).toBe("env-invalid");
190
+ expect(r.name).toBe("default");
191
+ if (r.source === "env-invalid") {
192
+ expect(r.reason).toContain("reserved");
193
+ }
194
+ });
195
+
196
+ test("env var set to slash-containing name → fallback to default", () => {
197
+ const r = resolveFirstBootVaultName("team/work");
198
+ expect(r.source).toBe("env-invalid");
199
+ expect(r.name).toBe("default");
200
+ });
201
+
202
+ test("env var set to a 200-char name → fallback to default (over max-length)", () => {
203
+ const r = resolveFirstBootVaultName("a".repeat(200));
204
+ expect(r.source).toBe("env-invalid");
205
+ expect(r.name).toBe("default");
206
+ if (r.source === "env-invalid") {
207
+ expect(r.reason).toContain("32");
208
+ }
209
+ });
210
+
211
+ test("env var set to a single character → fallback to default (under min-length)", () => {
212
+ const r = resolveFirstBootVaultName("a");
213
+ expect(r.source).toBe("env-invalid");
214
+ expect(r.name).toBe("default");
215
+ if (r.source === "env-invalid") {
216
+ expect(r.reason).toContain("2");
217
+ }
218
+ });
219
+ });
package/src/vault-name.ts CHANGED
@@ -5,12 +5,17 @@
5
5
  * the SQLite filename, and the OAuth consent page — anything that breaks
6
6
  * URL routing or filesystem assumptions has to be rejected up front.
7
7
  *
8
- * Used by the `init` prompt and the `--vault-name` flag. `cmdCreate` keeps
9
- * its own (slightly more permissive, legacy) regex for backward compat
10
- * tightening it would reject names existing users may already have minted.
8
+ * Rule: lowercase alphanumeric + hyphens or underscores, 2–32 chars, with
9
+ * `list` reserved. Used by the `init` prompt, the `--vault-name` flag, and
10
+ * the `PARACHUTE_VAULT_NAME` env var at server first-boot. `cmdCreate`
11
+ * keeps its own (slightly more permissive, legacy) regex for backward
12
+ * compat — tightening it would reject names existing users may already
13
+ * have minted.
11
14
  */
12
15
 
13
16
  const VAULT_NAME_RE = /^[a-z0-9_-]+$/;
17
+ const VAULT_NAME_MIN_LEN = 2;
18
+ const VAULT_NAME_MAX_LEN = 32;
14
19
 
15
20
  const RESERVED_NAMES = new Set([
16
21
  // Collides with the `/vaults/list` discovery endpoint historically; the
@@ -23,11 +28,24 @@ export type VaultNameValidation =
23
28
  | { ok: true; name: string }
24
29
  | { ok: false; error: string };
25
30
 
31
+ /**
32
+ * Validate a vault name. Accepts lowercase alphanumeric + hyphens or
33
+ * underscores, 2–32 chars. Trims surrounding whitespace before checking.
34
+ * `cmdCreate` keeps its own (legacy-permissive) regex; this validator is
35
+ * the strict gate used by the env var, the `--vault-name` flag, and
36
+ * hub's first-boot wizard.
37
+ */
26
38
  export function validateVaultName(raw: string): VaultNameValidation {
27
39
  const name = raw.trim();
28
40
  if (!name) {
29
41
  return { ok: false, error: "vault name cannot be empty." };
30
42
  }
43
+ if (name.length < VAULT_NAME_MIN_LEN || name.length > VAULT_NAME_MAX_LEN) {
44
+ return {
45
+ ok: false,
46
+ error: `vault names must be ${VAULT_NAME_MIN_LEN}–${VAULT_NAME_MAX_LEN} characters long.`,
47
+ };
48
+ }
31
49
  if (!VAULT_NAME_RE.test(name)) {
32
50
  return {
33
51
  ok: false,
@@ -78,3 +96,43 @@ export function decideInitVaultName(
78
96
  }
79
97
  return { kind: "prompt" };
80
98
  }
99
+
100
+ /**
101
+ * Pick the first-boot vault name based on `PARACHUTE_VAULT_NAME`. Used by
102
+ * `server.ts` when the server starts with zero vaults on disk (Docker
103
+ * first-boot, hub-driven self-host install).
104
+ *
105
+ * - env var unset / empty / whitespace-only → `{ source: "default", name: "default" }`
106
+ * - env var present + valid → `{ source: "env", name: <validated> }`
107
+ * - env var present + invalid → `{ source: "env-invalid", name: "default",
108
+ * rawValue: <original>, reason: <validator message> }` (caller logs a
109
+ * warning and proceeds with the default name; we never abort first-boot
110
+ * over a misconfigured env var)
111
+ *
112
+ * Validation uses the same `validateVaultName` rule as the `--vault-name`
113
+ * flag — lowercase alphanumeric + hyphens or underscores, 2–32 chars, with
114
+ * the `list` reserved-name carveout — so hub's wizard, the CLI flag, and
115
+ * the env var all share one truth.
116
+ */
117
+ export type FirstBootVaultName =
118
+ | { source: "default"; name: "default" }
119
+ | { source: "env"; name: string }
120
+ | { source: "env-invalid"; name: "default"; rawValue: string; reason: string };
121
+
122
+ export function resolveFirstBootVaultName(
123
+ rawEnvValue: string | undefined,
124
+ ): FirstBootVaultName {
125
+ if (rawEnvValue === undefined || rawEnvValue.trim() === "") {
126
+ return { source: "default", name: "default" };
127
+ }
128
+ const v = validateVaultName(rawEnvValue);
129
+ if (v.ok) {
130
+ return { source: "env", name: v.name };
131
+ }
132
+ return {
133
+ source: "env-invalid",
134
+ name: "default",
135
+ rawValue: rawEnvValue,
136
+ reason: v.error,
137
+ };
138
+ }