@openparachute/vault 0.4.5 → 0.4.6

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.
@@ -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
+ }