@openparachute/hub 0.7.3-rc.9 → 0.7.4-rc.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.9",
3
+ "version": "0.7.4-rc.1",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -83,6 +83,48 @@ describe("cli", () => {
83
83
  expect(code).toBe(1);
84
84
  expect(stderr).toMatch(/unknown command/);
85
85
  });
86
+
87
+ // hub#693: --hub-origin is persisted to hub_settings.hub_origin and stamps the
88
+ // OAuth `iss` claim, so the CLI must validate it to the same shape as
89
+ // `hub set-origin` BEFORE it reaches init() / the DB. These reject at the arg
90
+ // layer (exit 1) before any daemon work runs.
91
+ test("init --hub-origin with a path component exits 1", async () => {
92
+ const { code, stderr } = await runCli([
93
+ "init",
94
+ "--hub-origin",
95
+ "https://host.example/some/path",
96
+ ]);
97
+ expect(code).toBe(1);
98
+ expect(stderr).toMatch(/invalid --hub-origin/);
99
+ });
100
+
101
+ test("init --hub-origin with a non-http(s) scheme exits 1", async () => {
102
+ const { code, stderr } = await runCli(["init", "--hub-origin", "ftp://bad.example"]);
103
+ expect(code).toBe(1);
104
+ expect(stderr).toMatch(/invalid --hub-origin/);
105
+ });
106
+
107
+ // hub#694 bug 2: `init --channel` mirrors `install --channel` — rejected at
108
+ // the arg layer (exit 1) before any daemon work, so these are hermetic.
109
+ test("init --channel without a value exits 1 (hub#694)", async () => {
110
+ const { code, stderr } = await runCli(["init", "--channel"]);
111
+ expect(code).toBe(1);
112
+ expect(stderr).toMatch(/--channel requires a value/);
113
+ });
114
+
115
+ test("init --channel with an invalid value exits 1 (hub#694)", async () => {
116
+ const { code, stderr } = await runCli(["init", "--channel", "banana"]);
117
+ expect(code).toBe(1);
118
+ expect(stderr).toMatch(/--channel must be "rc" or "latest"/);
119
+ expect(stderr).toMatch(/banana/);
120
+ });
121
+
122
+ test("init --help documents --channel (hub#694)", async () => {
123
+ const { code, stdout } = await runCli(["init", "--help"]);
124
+ expect(code).toBe(0);
125
+ expect(stdout).toMatch(/--channel/);
126
+ expect(stdout).toMatch(/rc\|latest/);
127
+ });
86
128
  });
87
129
 
88
130
  describe("cli per-subcommand help", () => {
@@ -0,0 +1,433 @@
1
+ /**
2
+ * `parachute hub set-origin <url>` (onboarding-streamline 2026-06-25,
3
+ * Caddy-direct zero-SSH path).
4
+ *
5
+ * The verb persists the operator's canonical public origin to
6
+ * `hub_settings.hub_origin` from the CLI — closing the "set the issuer without
7
+ * an admin browser session" gap on a headless reverse-proxy box. These tests
8
+ * pin: it writes the row, it validates + canonicalizes the URL (reusing the
9
+ * SPA's `validateHubOrigin`), and it warns-but-allows loopback.
10
+ */
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { hub, hubSetOrigin, rewriteCaddyfileHost } from "../commands/hub.ts";
16
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
17
+ import { getHubOrigin } from "../hub-settings.ts";
18
+ import type { CommandResult } from "../tailscale/run.ts";
19
+
20
+ describe("parachute hub set-origin", () => {
21
+ let dir: string;
22
+ let log: string[];
23
+ const collect = (line: string) => log.push(line);
24
+
25
+ beforeEach(() => {
26
+ dir = mkdtempSync(join(tmpdir(), "hub-set-origin-"));
27
+ log = [];
28
+ });
29
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
30
+
31
+ /**
32
+ * Hermetic seams for the non-DB side effects: point the Caddyfile at a
33
+ * non-existent path (→ "no managed Caddyfile" branch), and mock the reload
34
+ * Runner + module restart so NOTHING real reloads Caddy or drives the live
35
+ * supervisor. The DB write is the only real side effect (scoped to the temp
36
+ * configDir). Tests that exercise Caddy specifically override these.
37
+ */
38
+ function safeSeams(): {
39
+ configDir: string;
40
+ log: (line: string) => void;
41
+ caddyfilePath: string;
42
+ run: (cmd: readonly string[]) => Promise<{ code: number; stdout: string; stderr: string }>;
43
+ restartModules: () => Promise<number>;
44
+ } {
45
+ return {
46
+ configDir: dir,
47
+ log: collect,
48
+ caddyfilePath: join(dir, "no-caddyfile-here"),
49
+ run: async () => ({ code: 0, stdout: "", stderr: "" }),
50
+ restartModules: async () => 0,
51
+ };
52
+ }
53
+
54
+ /** Open the configDir's hub.db and read the persisted origin. */
55
+ function persisted(): string | null {
56
+ const db = openHubDb(hubDbPath(dir));
57
+ try {
58
+ return getHubOrigin(db);
59
+ } finally {
60
+ db.close();
61
+ }
62
+ }
63
+
64
+ test("persists a valid public origin to hub_settings.hub_origin", async () => {
65
+ const code = await hubSetOrigin(["https://box.sslip.io"], safeSeams());
66
+ expect(code).toBe(0);
67
+ expect(persisted()).toBe("https://box.sslip.io");
68
+ });
69
+
70
+ test("strips a trailing slash (canonical bare-origin form)", async () => {
71
+ const code = await hubSetOrigin(["https://box.example.com/"], safeSeams());
72
+ expect(code).toBe(0);
73
+ expect(persisted()).toBe("https://box.example.com");
74
+ });
75
+
76
+ test("rejects a non-http(s) scheme without writing", async () => {
77
+ const code = await hubSetOrigin(["ftp://box.example.com"], safeSeams());
78
+ expect(code).toBe(1);
79
+ expect(persisted()).toBeNull();
80
+ });
81
+
82
+ test("rejects a URL with a path without writing", async () => {
83
+ const code = await hubSetOrigin(["https://box.example.com/admin"], safeSeams());
84
+ expect(code).toBe(1);
85
+ expect(persisted()).toBeNull();
86
+ });
87
+
88
+ test("rejects a bare hostname (no scheme) without writing", async () => {
89
+ const code = await hubSetOrigin(["box.example.com"], safeSeams());
90
+ expect(code).toBe(1);
91
+ expect(persisted()).toBeNull();
92
+ });
93
+
94
+ test("missing the URL argument is a usage error, no write", async () => {
95
+ const code = await hubSetOrigin([], safeSeams());
96
+ expect(code).toBe(1);
97
+ expect(persisted()).toBeNull();
98
+ });
99
+
100
+ test("warns but ALLOWS a loopback origin (dev/test escape hatch)", async () => {
101
+ const code = await hubSetOrigin(["http://127.0.0.1:1939"], safeSeams());
102
+ expect(code).toBe(0);
103
+ expect(persisted()).toBe("http://127.0.0.1:1939");
104
+ expect(log.some((l) => l.toLowerCase().includes("loopback"))).toBe(true);
105
+ });
106
+
107
+ test("prints a restart instruction when no managed Caddyfile is present", async () => {
108
+ let restartCalls = 0;
109
+ await hubSetOrigin(["https://box.sslip.io"], {
110
+ ...safeSeams(),
111
+ restartModules: async () => {
112
+ restartCalls++;
113
+ return 1; // simulate restart not landing → prints the manual hint
114
+ },
115
+ });
116
+ const joined = log.join("\n");
117
+ expect(restartCalls).toBe(1);
118
+ expect(joined).toContain("parachute restart");
119
+ });
120
+
121
+ test("openDb seam is honored (no touch of the live ~/.parachute)", async () => {
122
+ // Point the seam at a SEPARATE on-disk DB so we can assert (a) the seam was
123
+ // consulted, and (b) the write landed in the seam's DB — never the
124
+ // configDir's default path. (A real Database can't be Proxy-wrapped: Bun's
125
+ // prepared-statement private fields break under a Proxy.)
126
+ const altDir = mkdtempSync(join(tmpdir(), "hub-set-origin-alt-"));
127
+ try {
128
+ let opened = 0;
129
+ const code = await hubSetOrigin(["https://box.sslip.io"], {
130
+ ...safeSeams(),
131
+ openDb: () => {
132
+ opened++;
133
+ return openHubDb(hubDbPath(altDir));
134
+ },
135
+ });
136
+ expect(code).toBe(0);
137
+ expect(opened).toBe(1);
138
+ // The write landed in the seam's DB, NOT the configDir's default DB.
139
+ const alt = openHubDb(hubDbPath(altDir));
140
+ try {
141
+ expect(getHubOrigin(alt)).toBe("https://box.sslip.io");
142
+ } finally {
143
+ alt.close();
144
+ }
145
+ } finally {
146
+ rmSync(altDir, { recursive: true, force: true });
147
+ }
148
+ });
149
+ });
150
+
151
+ describe("parachute hub dispatcher", () => {
152
+ test("no subcommand prints help, exits 0", async () => {
153
+ const code = await hub([]);
154
+ expect(code).toBe(0);
155
+ });
156
+
157
+ test("--help exits 0", async () => {
158
+ expect(await hub(["--help"])).toBe(0);
159
+ });
160
+
161
+ test("unknown subcommand exits 1", async () => {
162
+ expect(await hub(["frobnicate"])).toBe(1);
163
+ });
164
+ });
165
+
166
+ // --- rewriteCaddyfileHost (pure) -----------------------------------------
167
+
168
+ const MANAGED_CADDYFILE = `# Managed by Parachute install (install/digitalocean.sh). Re-run to refresh.
169
+ old.example.com {
170
+ reverse_proxy 127.0.0.1:1939 {
171
+ # Strip client-supplied layer-spoofing trust signals — only a real
172
+ # Cloudflare / Tailscale edge may set these; this Caddy is the edge.
173
+ header_up -Cf-Ray
174
+ header_up -Cf-Connecting-Ip
175
+ header_up -Tailscale-Funnel-Request
176
+ header_up -Tailscale-User-Login
177
+ }
178
+ }
179
+ `;
180
+
181
+ describe("rewriteCaddyfileHost", () => {
182
+ test("rewrites ONLY the site-address line, body + header strips intact", () => {
183
+ const r = rewriteCaddyfileHost(MANAGED_CADDYFILE, "new.example.com");
184
+ expect(r.kind).toBe("rewritten");
185
+ if (r.kind !== "rewritten") throw new Error("unreachable");
186
+ expect(r.content).toContain("new.example.com {");
187
+ expect(r.content).not.toContain("old.example.com");
188
+ // The reverse_proxy body + the security-load-bearing header strips survive.
189
+ expect(r.content).toContain("reverse_proxy 127.0.0.1:1939 {");
190
+ expect(r.content).toContain("header_up -Cf-Ray");
191
+ expect(r.content).toContain("header_up -Cf-Connecting-Ip");
192
+ expect(r.content).toContain("header_up -Tailscale-Funnel-Request");
193
+ expect(r.content).toContain("header_up -Tailscale-User-Login");
194
+ // Only one line changed (host line); everything else byte-identical.
195
+ const before = MANAGED_CADDYFILE.split("\n");
196
+ const after = r.content.split("\n");
197
+ const diff = before.filter((l, i) => l !== after[i]);
198
+ expect(diff).toEqual(["old.example.com {"]);
199
+ });
200
+
201
+ test("idempotent — host already matches → unchanged (no reload needed)", () => {
202
+ expect(rewriteCaddyfileHost(MANAGED_CADDYFILE, "old.example.com").kind).toBe("unchanged");
203
+ });
204
+
205
+ test("an unmanaged Caddyfile (no marker) → not-managed", () => {
206
+ const unmanaged = "example.com {\n reverse_proxy 127.0.0.1:1939\n}\n";
207
+ expect(rewriteCaddyfileHost(unmanaged, "new.example.com").kind).toBe("not-managed");
208
+ });
209
+
210
+ test("marker present but no site-opener line (hand-edited) → customized", () => {
211
+ const odd = "# Managed by Parachute install (foo)\nreverse_proxy 127.0.0.1:1939\n";
212
+ expect(rewriteCaddyfileHost(odd, "new.example.com").kind).toBe("customized");
213
+ });
214
+
215
+ test("injection-shaped host can't break the rewrite (host comes from validated URL)", () => {
216
+ // The caller only ever passes `new URL(origin).host` from an already-validated
217
+ // origin, so a host with spaces / braces never reaches here — but even a weird
218
+ // token is written verbatim as the bare site address (no shell, plain file edit).
219
+ const r = rewriteCaddyfileHost(MANAGED_CADDYFILE, "weird.host:8443");
220
+ expect(r.kind).toBe("rewritten");
221
+ if (r.kind !== "rewritten") throw new Error("unreachable");
222
+ expect(r.content).toContain("weird.host:8443 {");
223
+ // The body is still intact — the regex only touched the site opener.
224
+ expect(r.content).toContain("header_up -Cf-Ray");
225
+ });
226
+ });
227
+
228
+ // --- set-origin Caddy rewrite + reload + restart -------------------------
229
+
230
+ const okResult = (): CommandResult => ({ code: 0, stdout: "", stderr: "" });
231
+
232
+ describe("parachute hub set-origin — Caddy automation", () => {
233
+ let dir: string;
234
+ let log: string[];
235
+ const collect = (line: string) => log.push(line);
236
+
237
+ beforeEach(() => {
238
+ dir = mkdtempSync(join(tmpdir(), "hub-set-origin-caddy-"));
239
+ log = [];
240
+ });
241
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
242
+
243
+ function caddyFixture(): string {
244
+ const p = join(dir, "Caddyfile");
245
+ writeFileSync(p, MANAGED_CADDYFILE);
246
+ return p;
247
+ }
248
+
249
+ test("managed Caddyfile → host line rewritten, reload + restart invoked (mocked)", async () => {
250
+ const caddyfilePath = caddyFixture();
251
+ const runCalls: ReadonlyArray<string>[] = [];
252
+ let restartCalls = 0;
253
+ const code = await hubSetOrigin(["https://new.example.com"], {
254
+ configDir: dir,
255
+ log: collect,
256
+ caddyfilePath,
257
+ getuid: () => 0, // simulate root so the rewrite path runs
258
+ run: async (cmd) => {
259
+ runCalls.push(cmd);
260
+ return okResult();
261
+ },
262
+ restartModules: async () => {
263
+ restartCalls++;
264
+ return 0;
265
+ },
266
+ });
267
+ expect(code).toBe(0);
268
+ // DB origin persisted.
269
+ const db = openHubDb(hubDbPath(dir));
270
+ try {
271
+ expect(getHubOrigin(db)).toBe("https://new.example.com");
272
+ } finally {
273
+ db.close();
274
+ }
275
+ // Host line rewritten on disk; body + header strips preserved.
276
+ const written = readFileSync(caddyfilePath, "utf8");
277
+ expect(written).toContain("new.example.com {");
278
+ expect(written).not.toContain("old.example.com");
279
+ expect(written).toContain("header_up -Cf-Connecting-Ip");
280
+ // Reload + restart both invoked.
281
+ expect(runCalls).toContainEqual(["systemctl", "reload", "caddy"]);
282
+ expect(restartCalls).toBe(1);
283
+ });
284
+
285
+ test("NO managed Caddyfile → origin persisted, manual steps printed, reload/restart NOT invoked", async () => {
286
+ const runCalls: ReadonlyArray<string>[] = [];
287
+ let restartCalls = 0;
288
+ const code = await hubSetOrigin(["https://new.example.com"], {
289
+ configDir: dir,
290
+ log: collect,
291
+ caddyfilePath: join(dir, "does-not-exist"),
292
+ getuid: () => 0,
293
+ run: async (cmd) => {
294
+ runCalls.push(cmd);
295
+ return okResult();
296
+ },
297
+ restartModules: async () => {
298
+ restartCalls++;
299
+ return 0;
300
+ },
301
+ });
302
+ expect(code).toBe(0);
303
+ const db = openHubDb(hubDbPath(dir));
304
+ try {
305
+ expect(getHubOrigin(db)).toBe("https://new.example.com");
306
+ } finally {
307
+ db.close();
308
+ }
309
+ // No managed Caddyfile → reload never runs. (restart still runs by design;
310
+ // it's the module-propagation step, independent of Caddy.)
311
+ expect(runCalls).toEqual([]);
312
+ expect(restartCalls).toBe(1);
313
+ expect(log.join("\n")).toContain("No Parachute-managed Caddyfile");
314
+ });
315
+
316
+ test("--no-caddy → Caddy untouched (no read/write/reload), restart still runs", async () => {
317
+ const caddyfilePath = caddyFixture();
318
+ const runCalls: ReadonlyArray<string>[] = [];
319
+ let restartCalls = 0;
320
+ const code = await hubSetOrigin(["https://new.example.com", "--no-caddy"], {
321
+ configDir: dir,
322
+ log: collect,
323
+ caddyfilePath,
324
+ getuid: () => 0,
325
+ run: async (cmd) => {
326
+ runCalls.push(cmd);
327
+ return okResult();
328
+ },
329
+ restartModules: async () => {
330
+ restartCalls++;
331
+ return 0;
332
+ },
333
+ });
334
+ expect(code).toBe(0);
335
+ // Caddyfile left byte-identical (not rewritten).
336
+ expect(readFileSync(caddyfilePath, "utf8")).toBe(MANAGED_CADDYFILE);
337
+ expect(runCalls).toEqual([]);
338
+ expect(restartCalls).toBe(1);
339
+ expect(log.join("\n")).toContain("--no-caddy");
340
+ });
341
+
342
+ test("--no-restart → modules not restarted", async () => {
343
+ const caddyfilePath = caddyFixture();
344
+ let restartCalls = 0;
345
+ const code = await hubSetOrigin(["https://new.example.com", "--no-restart"], {
346
+ configDir: dir,
347
+ log: collect,
348
+ caddyfilePath,
349
+ getuid: () => 0,
350
+ run: async () => okResult(),
351
+ restartModules: async () => {
352
+ restartCalls++;
353
+ return 0;
354
+ },
355
+ });
356
+ expect(code).toBe(0);
357
+ expect(restartCalls).toBe(0);
358
+ expect(log.join("\n")).toContain("--no-restart");
359
+ });
360
+
361
+ test("non-root with a managed Caddyfile → graceful, non-fatal, NOT rewritten/reloaded", async () => {
362
+ const caddyfilePath = caddyFixture();
363
+ const runCalls: ReadonlyArray<string>[] = [];
364
+ const code = await hubSetOrigin(["https://new.example.com"], {
365
+ configDir: dir,
366
+ log: collect,
367
+ caddyfilePath,
368
+ getuid: () => 1000, // non-root
369
+ run: async (cmd) => {
370
+ runCalls.push(cmd);
371
+ return okResult();
372
+ },
373
+ restartModules: async () => 0,
374
+ });
375
+ expect(code).toBe(0); // non-fatal: origin still persisted
376
+ // File untouched, reload never attempted.
377
+ expect(readFileSync(caddyfilePath, "utf8")).toBe(MANAGED_CADDYFILE);
378
+ expect(runCalls).toEqual([]);
379
+ expect(log.join("\n").toLowerCase()).toContain("not running as root");
380
+ });
381
+
382
+ test("reload fails → graceful, non-fatal, file still rewritten, manual reload hinted", async () => {
383
+ const caddyfilePath = caddyFixture();
384
+ const code = await hubSetOrigin(["https://new.example.com"], {
385
+ configDir: dir,
386
+ log: collect,
387
+ caddyfilePath,
388
+ getuid: () => 0,
389
+ run: async () => ({ code: 1, stdout: "", stderr: "caddy: config error" }),
390
+ restartModules: async () => 0,
391
+ });
392
+ expect(code).toBe(0); // non-fatal
393
+ expect(readFileSync(caddyfilePath, "utf8")).toContain("new.example.com {");
394
+ const joined = log.join("\n");
395
+ expect(joined).toContain("reload");
396
+ expect(joined).toContain("caddy: config error");
397
+ });
398
+
399
+ test("restart fails → graceful, non-fatal, prints the manual restart hint", async () => {
400
+ const caddyfilePath = caddyFixture();
401
+ const code = await hubSetOrigin(["https://new.example.com"], {
402
+ configDir: dir,
403
+ log: collect,
404
+ caddyfilePath,
405
+ getuid: () => 0,
406
+ run: async () => okResult(),
407
+ restartModules: async () => 1, // restart reports failure
408
+ });
409
+ expect(code).toBe(0); // non-fatal — origin persisted, Caddy reloaded
410
+ expect(log.join("\n")).toContain("parachute restart");
411
+ });
412
+
413
+ test("a managed Caddyfile already on the new host → unchanged, reload skipped", async () => {
414
+ const caddyfilePath = caddyFixture();
415
+ const runCalls: ReadonlyArray<string>[] = [];
416
+ // Set origin to the host already in the fixture (old.example.com).
417
+ const code = await hubSetOrigin(["https://old.example.com"], {
418
+ configDir: dir,
419
+ log: collect,
420
+ caddyfilePath,
421
+ getuid: () => 0,
422
+ run: async (cmd) => {
423
+ runCalls.push(cmd);
424
+ return okResult();
425
+ },
426
+ restartModules: async () => 0,
427
+ });
428
+ expect(code).toBe(0);
429
+ // Host already matched → no reload.
430
+ expect(runCalls).toEqual([]);
431
+ expect(log.join("\n")).toContain("already points at old.example.com");
432
+ });
433
+ });
@@ -21,7 +21,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
21
21
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
+ import { resolveStartupIssuer } from "../commands/serve.ts";
25
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
24
26
  import { parseHubOrigins, serializeHubOrigins } from "../hub-origin.ts";
27
+ import { getHubOrigin, setHubOrigin } from "../hub-settings.ts";
25
28
  import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
26
29
 
27
30
  describe("serializeHubOrigins / parseHubOrigins round-trip", () => {
@@ -197,3 +200,74 @@ describe("buildHubOriginsEnvValue — assembles the hub's legitimate-origin set"
197
200
  });
198
201
  });
199
202
  });
203
+
204
+ /**
205
+ * THE CRUX (onboarding-streamline 2026-06-25, Caddy-direct zero-SSH path):
206
+ * a box whose ONLY canonical-origin source is the DB row `hub_settings.hub_origin`
207
+ * (no PARACHUTE_HUB_ORIGIN env, no expose-state, no RENDER/FLY platform var —
208
+ * the bare-droplet-behind-Caddy shape) MUST inject that public origin into the
209
+ * supervised modules' PARACHUTE_HUB_ORIGINS. Otherwise vault/scribe accept only
210
+ * loopback `iss` and reject every token the hub mints under the public origin
211
+ * (which the per-request resolveIssuer DOES stamp from the DB).
212
+ *
213
+ * These tests prove the full boot chain: DB row → `resolveStartupIssuer`
214
+ * (boot-time issuer seed) → `buildHubOriginsEnvValue` (the env injected at
215
+ * child spawn) CONTAINS the public origin.
216
+ */
217
+ describe("DB hub_origin flows into the injected PARACHUTE_HUB_ORIGINS (Caddy-direct boot chain)", () => {
218
+ let dir: string;
219
+ const noExpose = (): string | undefined => undefined;
220
+
221
+ beforeEach(() => {
222
+ dir = mkdtempSync(join(tmpdir(), "hub-origins-db-"));
223
+ });
224
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
225
+
226
+ test("DB-persisted public origin lands in the assembled origin set", () => {
227
+ // Persist the Caddy public origin to the DB exactly as `hub set-origin` /
228
+ // `init --hub-origin` would.
229
+ const db = openHubDb(hubDbPath(dir));
230
+ setHubOrigin(db, "https://box.sslip.io");
231
+ db.close();
232
+
233
+ // Boot-time issuer resolution reads the DB row (passed as dbHubOrigin) —
234
+ // no env, no expose-state, no platform var (the bare-Caddy shape).
235
+ const dbOrigin = getHubOrigin(openHubDb(hubDbPath(dir))) ?? undefined;
236
+ const issuer = resolveStartupIssuer(
237
+ { ...(dbOrigin !== undefined ? { dbHubOrigin: dbOrigin } : {}) },
238
+ {},
239
+ noExpose,
240
+ );
241
+ expect(issuer).toBe("https://box.sslip.io");
242
+
243
+ // That issuer seeds the env injected into vault/scribe — the public origin
244
+ // MUST be in their accepted-`iss` set.
245
+ const set = parseHubOrigins(
246
+ buildHubOriginsEnvValue(dir, issuer, {}, join(dir, "expose-state.json")),
247
+ );
248
+ expect(set).toContain("https://box.sslip.io");
249
+ // Loopback aliases stay present (co-located CLI / loopback proxy path).
250
+ expect(set).toContain("http://127.0.0.1:1939");
251
+ expect(set).toContain("http://localhost:1939");
252
+ });
253
+
254
+ test("no DB origin AND nothing else → loopback-only (the regression this guards: no public origin would leak in)", () => {
255
+ // Fresh DB, no row, no env, no expose-state: the boot issuer is undefined
256
+ // and the set is loopback-only. Proves the fix doesn't fabricate an origin.
257
+ const dbOrigin = getHubOrigin(openHubDb(hubDbPath(dir))) ?? undefined;
258
+ const issuer = resolveStartupIssuer(
259
+ { ...(dbOrigin !== undefined ? { dbHubOrigin: dbOrigin } : {}) },
260
+ {},
261
+ noExpose,
262
+ );
263
+ expect(issuer).toBeUndefined();
264
+ const set = parseHubOrigins(
265
+ buildHubOriginsEnvValue(dir, issuer, {}, join(dir, "expose-state.json")),
266
+ );
267
+ // Loopback-only — no public origin fabricated. (Order is Set-insertion
268
+ // dependent; assert membership + size rather than a brittle exact array.)
269
+ expect(set).toContain("http://127.0.0.1:1939");
270
+ expect(set).toContain("http://localhost:1939");
271
+ expect(set).toHaveLength(2);
272
+ });
273
+ });