@openparachute/vault 0.4.4-rc.14 → 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.
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
2
3
  import { handleViewNote } from "./routes.ts";
4
+ import { SqliteStore } from "../core/src/store.ts";
3
5
 
4
6
  // Redirect URL builder — mirrors the logic in server.ts
5
7
  function buildRedirectUrl(reqUrl: string, noteId: string, prefix = ""): string {
@@ -211,4 +213,19 @@ describe("handleViewNote", async () => {
211
213
  const resp = await handleViewNote(store, "n1", { publishedTag: "public" });
212
214
  expect(resp.status).toBe(404);
213
215
  });
216
+
217
+ it("returns 409 ambiguous_path when bare path resolves to multiple notes (vault#331 N1)", async () => {
218
+ // Real SqliteStore so the v18 composite (path, extension) uniqueness
219
+ // index lets two notes coexist at the same path with different
220
+ // extensions — the scenario AmbiguousPathError is built for.
221
+ const store = new SqliteStore(new Database(":memory:"));
222
+ await store.createNote("# md", { id: "vn-md", path: "Foo", tags: ["publish"] });
223
+ await store.createNote("a,b\n1,2", { id: "vn-csv", path: "Foo", extension: "csv", tags: ["publish"] });
224
+ const resp = await handleViewNote(store, "Foo", { authenticated: true });
225
+ expect(resp.status).toBe(409);
226
+ const body = await resp.json() as any;
227
+ expect(body.error_type).toBe("ambiguous_path");
228
+ expect(body.path).toBe("Foo");
229
+ expect(body.candidates).toHaveLength(2);
230
+ });
214
231
  });
package/src/routes.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { Store, Note } from "../core/src/types.ts";
15
15
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
- import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
16
+ import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
17
17
  import { attachValidationStatus } from "../core/src/mcp.ts";
18
18
  import * as linkOps from "../core/src/links.ts";
19
19
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
@@ -75,6 +75,25 @@ function parseQueryList(url: URL, key: string): string[] | undefined {
75
75
  return val ? val.split(",") : undefined;
76
76
  }
77
77
 
78
+ /**
79
+ * Parse the extension query parameter (vault#328). Two accepted shapes:
80
+ * - `?extension=csv` (single value → string)
81
+ * - `?extension=csv&extension=yaml` OR `?extension=csv,yaml`
82
+ * (repeated or comma-list → array)
83
+ * Returns undefined when absent so the queryNotes filter is skipped.
84
+ * Validation lives at the engine layer — bad strings result in zero
85
+ * matches rather than 400, mirroring how `path` works.
86
+ */
87
+ function parseExtensionFilter(url: URL): string | string[] | undefined {
88
+ const all = url.searchParams.getAll("extension");
89
+ if (all.length === 0) return undefined;
90
+ // Flatten comma-lists inside each param.
91
+ const flat = all.flatMap((v) => v.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
92
+ if (flat.length === 0) return undefined;
93
+ if (flat.length === 1) return flat[0]!;
94
+ return flat;
95
+ }
96
+
78
97
  function parseInt10(val: string | null): number | undefined {
79
98
  if (!val) return undefined;
80
99
  const n = parseInt(val, 10);
@@ -373,11 +392,21 @@ function parseExpandParams(
373
392
 
374
393
 
375
394
  /**
376
- * Resolve a note by ID or path. Tries ID first, then case-insensitive path.
395
+ * Resolve a note by ID or path. Tries ID first, then case-insensitive
396
+ * path. A trailing `.<ext>` matching the extension pattern is parsed
397
+ * as `(path, extension)` to disambiguate notes sharing a path
398
+ * differing only by extension (vault#330 S1). When the path is
399
+ * ambiguous and no extension hint is supplied, `getNoteByPath` throws
400
+ * `AmbiguousPathError` — REST handlers catch it and return 409.
377
401
  */
378
402
  async function resolveNote(store: Store, idOrPath: string): Promise<Note | null> {
379
403
  const byId = await store.getNote(idOrPath);
380
404
  if (byId) return byId;
405
+ const extMatch = idOrPath.match(/^(.*)\.([a-z0-9]{1,16})$/i);
406
+ if (extMatch) {
407
+ const explicit = await store.getNoteByPath(extMatch[1]!, extMatch[2]!);
408
+ if (explicit) return explicit;
409
+ }
381
410
  return await store.getNoteByPath(idOrPath);
382
411
  }
383
412
 
@@ -398,12 +427,49 @@ class NotFoundError extends Error {
398
427
  // Notes — GET/POST/PATCH/DELETE /api/notes[/:idOrPath]
399
428
  // ---------------------------------------------------------------------------
400
429
 
430
+ /**
431
+ * Convert a thrown `AmbiguousPathError` (vault#330 S1) into a structured
432
+ * 409 JSON response. Shared by every handler that calls `resolveNote`
433
+ * with a user-supplied path — handleNotes, handleFindPath,
434
+ * handleViewNote. Returns null when the error isn't an
435
+ * AmbiguousPathError so the caller can re-throw / fall through.
436
+ */
437
+ function ambiguousPathResponse(e: any): Response | null {
438
+ if (!e || e.code !== "AMBIGUOUS_PATH") return null;
439
+ return json(
440
+ {
441
+ error_type: "ambiguous_path",
442
+ error: "ambiguous_path",
443
+ path: e.path,
444
+ candidates: e.candidates,
445
+ message: e.message,
446
+ },
447
+ 409,
448
+ );
449
+ }
450
+
401
451
  export async function handleNotes(
402
452
  req: Request,
403
453
  store: Store,
404
454
  subpath: string,
405
455
  vault?: string,
406
456
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
457
+ ): Promise<Response> {
458
+ try {
459
+ return await handleNotesInner(req, store, subpath, vault, tagScope);
460
+ } catch (e: any) {
461
+ const ambig = ambiguousPathResponse(e);
462
+ if (ambig) return ambig;
463
+ throw e;
464
+ }
465
+ }
466
+
467
+ async function handleNotesInner(
468
+ req: Request,
469
+ store: Store,
470
+ subpath: string,
471
+ vault?: string,
472
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
407
473
  ): Promise<Response> {
408
474
  const url = new URL(req.url);
409
475
  const method = req.method;
@@ -508,6 +574,12 @@ export async function handleNotes(
508
574
  hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
509
575
  path: parseQuery(url, "path") ?? undefined,
510
576
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
577
+ // Extension filter (vault#328). Accepts repeated `extension=`
578
+ // params for the array form: `?extension=csv&extension=yaml`.
579
+ // `parseQueryList` already returns undefined when no params
580
+ // are present, so the filter is silently skipped on a plain
581
+ // GET without the extension query.
582
+ extension: parseExtensionFilter(url),
511
583
  metadata: bracket.metadata,
512
584
  // Date-range precedence chain (highest to lowest):
513
585
  // 1. Bracket-style `meta[created_at][gte]=…` (canonical).
@@ -660,12 +732,19 @@ export async function handleNotes(
660
732
  if (batched) db.exec("BEGIN");
661
733
  try {
662
734
  for (const item of items) {
735
+ // Validate extension before reaching the Store (vault#328).
736
+ // Thrown inside the BEGIN block — outer catch rolls the batch
737
+ // back, same shape as the path-conflict path.
738
+ const extension = item.extension !== undefined
739
+ ? validateExtension(item.extension)
740
+ : undefined;
663
741
  const note = await store.createNote(item.content ?? "", {
664
742
  id: item.id,
665
743
  path: item.path,
666
744
  tags: item.tags,
667
745
  metadata: item.metadata,
668
746
  created_at: item.createdAt ?? item.created_at,
747
+ ...(extension !== undefined ? { extension } : {}),
669
748
  });
670
749
 
671
750
  // Create explicit links
@@ -688,6 +767,12 @@ export async function handleNotes(
688
767
  409,
689
768
  );
690
769
  }
770
+ if (e && e.code === "INVALID_EXTENSION") {
771
+ return json(
772
+ { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
773
+ 400,
774
+ );
775
+ }
691
776
  throw e;
692
777
  }
693
778
 
@@ -845,12 +930,17 @@ export async function handleNotes(
845
930
  }
846
931
  const idLooksLikePath = idOrPathStr.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPathStr);
847
932
  const explicitPath = typeof body.path === "string" ? body.path as string : undefined;
933
+ // Validate extension before reaching the Store (vault#328).
934
+ const createExt = body.extension !== undefined
935
+ ? validateExtension(body.extension)
936
+ : undefined;
848
937
  const createOpts: Parameters<Store["createNote"]>[1] = {
849
938
  ...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
850
939
  ...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
851
940
  ...(body.metadata !== undefined ? { metadata: body.metadata as Record<string, unknown> } : {}),
852
941
  ...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
853
942
  ...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
943
+ ...(createExt !== undefined ? { extension: createExt } : {}),
854
944
  };
855
945
  const content = (body.content as string | undefined) ?? "";
856
946
  const created = await store.createNote(content, createOpts);
@@ -1019,6 +1109,11 @@ export async function handleNotes(
1019
1109
  if (body.prepend !== undefined) updates.prepend = body.prepend;
1020
1110
  }
1021
1111
  if (body.path !== undefined) updates.path = body.path;
1112
+ if (body.extension !== undefined) {
1113
+ // Validate up front (vault#328). Throws ExtensionValidationError
1114
+ // which the outer catch converts to a 400.
1115
+ updates.extension = validateExtension(body.extension);
1116
+ }
1022
1117
  if (body.metadata !== undefined) {
1023
1118
  const existing = (note.metadata as Record<string, unknown>) ?? {};
1024
1119
  updates.metadata = { ...existing, ...body.metadata };
@@ -1108,6 +1203,12 @@ export async function handleNotes(
1108
1203
  409,
1109
1204
  );
1110
1205
  }
1206
+ if (e && e.code === "INVALID_EXTENSION") {
1207
+ return json(
1208
+ { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
1209
+ 400,
1210
+ );
1211
+ }
1111
1212
  throw e;
1112
1213
  }
1113
1214
  }
@@ -1448,6 +1549,11 @@ export async function handleFindPath(
1448
1549
  return json(result);
1449
1550
  } catch (e: any) {
1450
1551
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
1552
+ // vault#331 N1 — surface AmbiguousPathError from resolveNote as 409
1553
+ // mirroring the handleNotes path. Without this, an ambiguous source/
1554
+ // target path on /api/find-path bubbled to a server-level 500.
1555
+ const ambig = ambiguousPathResponse(e);
1556
+ if (ambig) return ambig;
1451
1557
  throw e;
1452
1558
  }
1453
1559
  }
@@ -1640,7 +1746,19 @@ export async function handleViewNote(
1640
1746
  options: { authenticated?: boolean; publishedTag?: string } = {},
1641
1747
  ): Promise<Response> {
1642
1748
  const { authenticated = false, publishedTag = "publish" } = options;
1643
- const note = await resolveNote(store, idOrPath);
1749
+ let note: Note | null;
1750
+ try {
1751
+ note = await resolveNote(store, idOrPath);
1752
+ } catch (e: any) {
1753
+ // vault#331 N1 — surface AmbiguousPathError as 409. The HTML view
1754
+ // route doesn't otherwise return JSON, but the structured body is
1755
+ // the right shape for the API contract; a human reader hitting
1756
+ // this URL gets the JSON inline (rare — the bare path form is
1757
+ // mostly an API consumer's mistake).
1758
+ const ambig = ambiguousPathResponse(e);
1759
+ if (ambig) return ambig;
1760
+ throw e;
1761
+ }
1644
1762
  if (!note) {
1645
1763
  return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" } });
1646
1764
  }
@@ -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
+ }