@openparachute/hub 0.7.3-rc.8 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.8",
3
+ "version": "0.7.3",
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,26 @@ 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
+ });
86
106
  });
87
107
 
88
108
  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
+ });