@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.
@@ -0,0 +1,409 @@
1
+ /**
2
+ * `parachute hub <subcommand>` — hub-local configuration verbs that write
3
+ * `~/.parachute/hub.db`.
4
+ *
5
+ * Subcommands:
6
+ * - `set-origin <url>` — persist the operator's canonical public hub origin
7
+ * into `hub_settings.hub_origin`. That row is tier-1 in `resolveIssuer`
8
+ * (hub-server.ts) — the OAuth `iss` claim hub stamps into every JWT — and,
9
+ * since the onboarding-streamline 2026-06-25 boot-issuer fix, also seeds the
10
+ * `PARACHUTE_HUB_ORIGINS` set hub injects into supervised modules
11
+ * (vault/scribe) so they accept tokens minted under the public origin.
12
+ *
13
+ * The headline use case is the zero-SSH Caddy-direct DigitalOcean path: a bare
14
+ * droplet runs Caddy (Let's Encrypt TLS terminator + reverse proxy to the
15
+ * loopback hub), and the hub never does TLS. The hub binds loopback and reads
16
+ * its public origin from the DB. Before this verb the only way to set
17
+ * `hub_origin` was the admin SPA's `PUT /api/settings/hub-origin` — which needs
18
+ * a browser session you don't have on a freshly-provisioned headless box. The
19
+ * CLI verb closes that gap so a cloud-init script (or an operator over the DO
20
+ * console) can record the public origin without SSHing into the admin UI.
21
+ *
22
+ * Validation reuses `validateHubOrigin` (api-settings-hub-origin.ts) so the CLI
23
+ * and the SPA enforce the EXACT same shape: http(s) scheme, a hostname, no
24
+ * trailing slash / path / query / fragment / credentials. A loopback value is
25
+ * allowed (a dev/test box may legitimately set one) but warned about, since a
26
+ * loopback `hub_origin` would advertise a non-public issuer.
27
+ */
28
+
29
+ import type { Database } from "bun:sqlite";
30
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
31
+ import { validateHubOrigin } from "../api-settings-hub-origin.ts";
32
+ import { restart } from "../commands/lifecycle.ts";
33
+ import { CONFIG_DIR } from "../config.ts";
34
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
35
+ import { setHubOrigin } from "../hub-settings.ts";
36
+ import { type CommandResult, type Runner, defaultRunner } from "../tailscale/run.ts";
37
+ import { isLoopbackOrigin } from "../vault-hub-origin-env.ts";
38
+
39
+ /** Default path to the Parachute-managed Caddyfile (install/digitalocean.sh). */
40
+ const DEFAULT_CADDYFILE_PATH = "/etc/caddy/Caddyfile";
41
+
42
+ /**
43
+ * Stable prefix of the marker comment the install script writes as line 1 of a
44
+ * Parachute-managed Caddyfile (`# Managed by Parachute install …`). We match a
45
+ * prefix, not the whole line, so a future heredoc tweak to the trailing text
46
+ * doesn't break detection.
47
+ */
48
+ const CADDY_MARKER_PREFIX = "# Managed by Parachute install";
49
+
50
+ export interface HubCommandDeps {
51
+ configDir?: string;
52
+ log?: (line: string) => void;
53
+ /**
54
+ * Test seam: open the hub DB. Production opens `hub.db` under the resolved
55
+ * `configDir` (running migrations). Tests pass an in-memory / temp DB so the
56
+ * write is exercised without touching the operator's live `~/.parachute`.
57
+ */
58
+ openDb?: (configDir: string) => Database;
59
+ /**
60
+ * Path to the Parachute-managed Caddyfile. Defaults to the install script's
61
+ * hardcoded `/etc/caddy/Caddyfile`. Tests point it at a temp fixture.
62
+ */
63
+ caddyfilePath?: string;
64
+ /** fs read seam (default `node:fs`). Returns undefined when the file is absent. */
65
+ readFile?: (path: string) => string | undefined;
66
+ /** fs write seam (default `node:fs`). */
67
+ writeFile?: (path: string, content: string) => void;
68
+ /** fs existence seam (default `node:fs`). */
69
+ exists?: (path: string) => boolean;
70
+ /**
71
+ * Runner for `systemctl reload caddy` + the module restart fan-out. Canonical
72
+ * {@link Runner} seam (the same one `expose.ts` injects). Tests mock it so
73
+ * nothing real reloads.
74
+ */
75
+ run?: Runner;
76
+ /** Effective uid (default `process.getuid`). Non-root → skip the write/reload. */
77
+ getuid?: () => number | undefined;
78
+ /**
79
+ * Restart supervised modules so `PARACHUTE_HUB_ORIGINS` re-injects with the
80
+ * fresh origin. Defaults to `restart(undefined, …)` (restarts the hub unit,
81
+ * re-booting all modules). Tests mock it so the live supervisor isn't driven.
82
+ */
83
+ restartModules?: (configDir: string) => Promise<number>;
84
+ }
85
+
86
+ /** Outcome of an attempted in-place Caddyfile hostname rewrite. */
87
+ type CaddyRewriteResult =
88
+ | { kind: "rewritten"; content: string; host: string }
89
+ | { kind: "unchanged" } // host already matches — idempotent, no reload needed
90
+ | { kind: "not-managed" } // no file, or no `# Managed by Parachute install` marker
91
+ | { kind: "customized" }; // marker present but the site-address line shape is unrecognized
92
+
93
+ /**
94
+ * Rewrite ONLY the site-address (hostname) line of a Parachute-managed Caddyfile
95
+ * to `host`, leaving the `reverse_proxy` body + the security-load-bearing
96
+ * `header_up -…` trust-signal strips byte-intact.
97
+ *
98
+ * The managed shape (install/digitalocean.sh write_caddyfile) is:
99
+ *
100
+ * # Managed by Parachute install (install/digitalocean.sh). Re-run to refresh.
101
+ * <hostname> {
102
+ * reverse_proxy 127.0.0.1:<port> {
103
+ * header_up -Cf-Ray
104
+ * …
105
+ * }
106
+ * }
107
+ *
108
+ * Strategy: confirm the first non-empty line carries the marker prefix; then
109
+ * find the first top-level site-opener line (`<token> {`) that is NOT the
110
+ * `reverse_proxy …` line and replace its host token with `host`, preserving
111
+ * indentation + the trailing ` {`. If the marker is present but no such line
112
+ * matches (operator hand-edited), return `customized` and let the caller fall
113
+ * back to manual steps rather than guess.
114
+ */
115
+ export function rewriteCaddyfileHost(content: string, host: string): CaddyRewriteResult {
116
+ const lines = content.split("\n");
117
+ const firstNonEmpty = lines.find((l) => l.trim().length > 0);
118
+ if (firstNonEmpty === undefined || !firstNonEmpty.trim().startsWith(CADDY_MARKER_PREFIX)) {
119
+ return { kind: "not-managed" };
120
+ }
121
+ // The site-address opener is the first `<token> {`-terminated line that is NOT
122
+ // the `reverse_proxy …` line. A bare host token: no whitespace, no scheme.
123
+ const siteOpener = /^(\s*)(\S+)(\s*\{\s*)$/;
124
+ for (let i = 0; i < lines.length; i++) {
125
+ const line = lines[i];
126
+ if (line === undefined) continue;
127
+ const trimmed = line.trim();
128
+ if (trimmed.startsWith("#") || trimmed.length === 0) continue;
129
+ if (trimmed.startsWith("reverse_proxy")) continue;
130
+ const m = line.match(siteOpener);
131
+ if (!m) continue;
132
+ const [, indent, token, brace] = m;
133
+ if (indent === undefined || token === undefined || brace === undefined) continue;
134
+ if (token === host) return { kind: "unchanged" };
135
+ lines[i] = `${indent}${host}${brace}`;
136
+ return { kind: "rewritten", content: lines.join("\n"), host };
137
+ }
138
+ return { kind: "customized" };
139
+ }
140
+
141
+ /**
142
+ * `parachute hub set-origin <url>`. Validates + canonicalizes the URL, persists
143
+ * it to `hub_settings.hub_origin`, then — on a Parachute-managed Caddy-direct
144
+ * box — rewrites the Caddyfile hostname, reloads Caddy, and restarts the
145
+ * supervised modules so they re-pick the origin. Every post-DB step is
146
+ * best-effort: on any miss (no managed Caddyfile, `--no-caddy`, non-root, a
147
+ * failed reload/restart) the origin still persisted and the manual steps are
148
+ * printed. Returns 0 on success (incl. soft Caddy/restart misses), 1 only on a
149
+ * validation / DB-write failure.
150
+ */
151
+ export async function hubSetOrigin(
152
+ args: readonly string[],
153
+ deps: HubCommandDeps = {},
154
+ ): Promise<number> {
155
+ const configDir = deps.configDir ?? CONFIG_DIR;
156
+ const log = deps.log ?? ((line) => console.log(line));
157
+ const err = (line: string) => console.error(line);
158
+ const openDb = deps.openDb ?? ((dir: string) => openHubDb(hubDbPath(dir)));
159
+ const caddyfilePath = deps.caddyfilePath ?? DEFAULT_CADDYFILE_PATH;
160
+ const exists = deps.exists ?? ((path: string) => existsSync(path));
161
+ const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf8"));
162
+ const writeFile =
163
+ deps.writeFile ?? ((path: string, content: string) => writeFileSync(path, content));
164
+ const run = deps.run ?? defaultRunner;
165
+ const getuid =
166
+ deps.getuid ?? (() => (typeof process.getuid === "function" ? process.getuid() : undefined));
167
+ const restartModules =
168
+ deps.restartModules ??
169
+ ((dir: string) => restart(undefined, { configDir: dir, supervisor: {} }));
170
+
171
+ const noCaddy = args.includes("--no-caddy");
172
+ const noRestart = args.includes("--no-restart");
173
+ const positional = args.filter((a) => !a.startsWith("-"));
174
+ const raw = positional[0];
175
+ if (raw === undefined) {
176
+ err("usage: parachute hub set-origin <url>");
177
+ err("example: parachute hub set-origin https://box.sslip.io");
178
+ return 1;
179
+ }
180
+ if (positional.length > 1) {
181
+ err(`parachute hub set-origin: unexpected argument "${positional[1]}"`);
182
+ err("usage: parachute hub set-origin <url>");
183
+ return 1;
184
+ }
185
+
186
+ // Strip a trailing slash up front so the convenient `https://host/` form is
187
+ // accepted (the operator copy-pastes a browser URL). `validateHubOrigin`
188
+ // itself REJECTS a trailing slash (it's the SPA's PUT body validator, where a
189
+ // strict shape is wanted) — we canonicalize here, then validate the rest of
190
+ // the shape (scheme / hostname / no-path / no-credentials) through the shared
191
+ // validator so the CLI and SPA agree on everything else.
192
+ const trimmed = raw.replace(/\/+$/, "");
193
+ const result = validateHubOrigin(trimmed);
194
+ if (!result.ok) {
195
+ err(`parachute hub set-origin: ${result.description}`);
196
+ return 1;
197
+ }
198
+ // `validateHubOrigin` maps empty / null to `normalized: null` (clear the row).
199
+ // `set-origin` with an explicit non-empty arg should never land there — a
200
+ // present positional that normalizes to null means the operator passed an
201
+ // empty string, which is not a valid public origin for this verb.
202
+ if (result.normalized === null) {
203
+ err("parachute hub set-origin: a non-empty origin URL is required");
204
+ err("usage: parachute hub set-origin <url>");
205
+ return 1;
206
+ }
207
+ const origin = result.normalized;
208
+
209
+ // Loopback is allowed (a dev/test box may set one deliberately) but warned —
210
+ // a loopback hub_origin advertises a non-public issuer, so remote devices
211
+ // and supervised resource servers reached over a public URL would reject it.
212
+ if (isLoopbackOrigin(origin)) {
213
+ log(`⚠ ${origin} is a loopback origin — it won't be reachable from other devices.`);
214
+ log(" Set the box's PUBLIC URL (e.g. https://<droplet-ip>.sslip.io) for a real deploy.");
215
+ }
216
+
217
+ const db = openDb(configDir);
218
+ try {
219
+ setHubOrigin(db, origin);
220
+ } finally {
221
+ db.close();
222
+ }
223
+
224
+ log(`✓ Canonical hub origin set to ${origin}.`);
225
+ log(" Stored in hub_settings — the OAuth issuer (`iss`) hub stamps on every token,");
226
+ log(" and the origin set injected into supervised modules so they accept it.");
227
+
228
+ // The DB write is the load-bearing contract; everything below is best-effort
229
+ // automation of the manual restart/rewrite steps. Any miss falls back to
230
+ // printing the manual instructions and still returns 0.
231
+ //
232
+ // The Caddy site address is a BARE host (Caddy auto-TLS on HTTPS-default),
233
+ // taken from the ALREADY-VALIDATED URL — `new URL(origin).host` (no scheme),
234
+ // never a raw arg, so there's nothing to inject (the rewrite is a file edit,
235
+ // the reload/restart are argv-form Bun.spawn, no shell).
236
+ const host = new URL(origin).host;
237
+
238
+ if (noCaddy) {
239
+ log("");
240
+ log("(--no-caddy) Skipping the Caddyfile rewrite + reload.");
241
+ } else if (!exists(caddyfilePath)) {
242
+ // Tailscale / Cloudflare / manual-proxy box — no managed Caddyfile to touch.
243
+ log("");
244
+ log(
245
+ `No Parachute-managed Caddyfile at ${caddyfilePath} — origin set; restart modules to apply.`,
246
+ );
247
+ } else {
248
+ let content: string | undefined;
249
+ try {
250
+ content = readFile(caddyfilePath);
251
+ } catch {
252
+ content = undefined;
253
+ }
254
+ const rewrite =
255
+ content === undefined
256
+ ? ({ kind: "not-managed" } as const)
257
+ : rewriteCaddyfileHost(content, host);
258
+ if (rewrite.kind === "not-managed") {
259
+ log("");
260
+ log(
261
+ `No Parachute-managed Caddyfile at ${caddyfilePath} — origin set; restart modules to apply.`,
262
+ );
263
+ } else if (rewrite.kind === "customized") {
264
+ log("");
265
+ log(`The Caddyfile at ${caddyfilePath} looks customized — not rewriting its host line.`);
266
+ log(` Update the site address to "${host}" by hand, then: sudo systemctl reload caddy`);
267
+ } else if (rewrite.kind === "unchanged") {
268
+ // Host already matches — nothing to write or reload.
269
+ log("");
270
+ log(`Caddyfile already points at ${host} — no change needed.`);
271
+ } else if (getuid() !== undefined && getuid() !== 0) {
272
+ // Writing /etc/caddy/Caddyfile + `systemctl reload` need root; the hub
273
+ // never shells `sudo` itself. Persist + print the manual steps.
274
+ log("");
275
+ log(`Not running as root — can't rewrite ${caddyfilePath} or reload Caddy.`);
276
+ log(
277
+ ` As root: set the Caddyfile site address to "${host}", then: sudo systemctl reload caddy`,
278
+ );
279
+ } else {
280
+ // Root + managed file + host changed → rewrite, then (only if the write
281
+ // landed) reload.
282
+ let wrote = false;
283
+ try {
284
+ writeFile(caddyfilePath, rewrite.content);
285
+ wrote = true;
286
+ } catch (e) {
287
+ log("");
288
+ log(`Could not write ${caddyfilePath}: ${e instanceof Error ? e.message : String(e)}`);
289
+ log(` Update its site address to "${host}" by hand, then: sudo systemctl reload caddy`);
290
+ }
291
+ if (wrote) {
292
+ // `runCaddyReload` → defaultRunner pre-flights `systemctl` and THROWS
293
+ // (friendly missing-dep error) on a non-systemd box, before its own
294
+ // try/catch. Wrap it so a throw degrades to the manual-reload hint
295
+ // instead of escaping past the module restart below — the origin is
296
+ // already persisted, so the restart must still run.
297
+ let reload: CommandResult;
298
+ try {
299
+ reload = await runCaddyReload(run);
300
+ } catch (e) {
301
+ reload = { code: 1, stdout: "", stderr: e instanceof Error ? e.message : String(e) };
302
+ }
303
+ if (reload.code === 0) {
304
+ log("");
305
+ log(`✓ Rewrote ${caddyfilePath} → ${host}, reloaded Caddy.`);
306
+ } else {
307
+ log("");
308
+ log(`✓ Rewrote ${caddyfilePath} → ${host}, but Caddy reload reported an error:`);
309
+ if (reload.stderr.trim().length > 0) log(` ${reload.stderr.trim()}`);
310
+ log(" Reload it manually: sudo systemctl reload caddy");
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ // Restart supervised modules so they re-pick the origin via PARACHUTE_HUB_ORIGINS
317
+ // (injected at child-spawn time). One hub-unit restart re-boots all modules —
318
+ // strictly better than the old "restart vault / restart scribe" two-step.
319
+ if (noRestart) {
320
+ log("");
321
+ log("(--no-restart) Skipping the module restart. Apply with: parachute restart");
322
+ } else {
323
+ let restarted = false;
324
+ try {
325
+ restarted = (await restartModules(configDir)) === 0;
326
+ } catch {
327
+ restarted = false;
328
+ }
329
+ if (restarted) {
330
+ log("✓ Restarted modules (vault, scribe) so they accept the new origin.");
331
+ } else {
332
+ log("");
333
+ log("Restart already-running modules so they pick up the new origin:");
334
+ log(" parachute restart");
335
+ }
336
+ }
337
+
338
+ return 0;
339
+ }
340
+
341
+ /**
342
+ * `systemctl reload caddy` through the injected Runner. Kept tiny so the call
343
+ * site reads as one step; the Runner default (`defaultRunner`) env-inherits +
344
+ * pre-flights the binary, and tests mock it.
345
+ */
346
+ async function runCaddyReload(run: Runner): Promise<CommandResult> {
347
+ return run(["systemctl", "reload", "caddy"]);
348
+ }
349
+
350
+ /**
351
+ * `parachute hub <subcommand>` dispatcher. Mirrors `auth`'s shape (a thin
352
+ * router over subcommand handlers, each catching its own errors).
353
+ */
354
+ export async function hub(args: readonly string[], deps: HubCommandDeps = {}): Promise<number> {
355
+ const sub = args[0];
356
+ if (sub === undefined || sub === "--help" || sub === "-h" || sub === "help") {
357
+ console.log(hubHelp());
358
+ return 0;
359
+ }
360
+ if (sub === "set-origin") {
361
+ try {
362
+ return await hubSetOrigin(args.slice(1), deps);
363
+ } catch (err) {
364
+ console.error(
365
+ `parachute hub set-origin: ${err instanceof Error ? err.message : String(err)}`,
366
+ );
367
+ return 1;
368
+ }
369
+ }
370
+ console.error(`parachute hub: unknown subcommand "${sub}"`);
371
+ console.error("");
372
+ console.error(hubHelp());
373
+ return 1;
374
+ }
375
+
376
+ export function hubHelp(): string {
377
+ return `parachute hub — hub-local configuration
378
+
379
+ Usage:
380
+ parachute hub set-origin <url> [--no-caddy] [--no-restart]
381
+
382
+ Subcommands:
383
+ set-origin <url> Persist the canonical public origin (OAuth issuer) to the
384
+ hub DB. This is the URL the hub stamps as the \`iss\` claim
385
+ on every token AND the origin it tells supervised modules
386
+ (vault, scribe) to accept. Use it on a reverse-proxy /
387
+ Caddy-direct box where the hub binds loopback but is reached
388
+ over a public HTTPS URL — no admin browser session needed.
389
+
390
+ The URL must be http(s), with a hostname and no trailing
391
+ slash / path / query.
392
+
393
+ On a Parachute-managed Caddy-direct box (a
394
+ /etc/caddy/Caddyfile written by the install script), this
395
+ ALSO rewrites the Caddyfile's hostname line to the new
396
+ origin (the reverse_proxy body + trust-signal header strips
397
+ are left intact), reloads Caddy, and restarts the modules so
398
+ the new origin propagates — a one-command domain change. If
399
+ no managed Caddyfile is present, the rewrite is skipped and
400
+ the manual steps are printed. Pass --no-caddy to skip the
401
+ Caddyfile rewrite + reload, or --no-restart to skip the
402
+ module restart.
403
+
404
+ Examples:
405
+ parachute hub set-origin https://box.sslip.io
406
+ parachute hub set-origin https://parachute.example.com
407
+ parachute hub set-origin https://parachute.example.com --no-caddy
408
+ `;
409
+ }
@@ -41,6 +41,7 @@ import { type ExposeState, readExposeState } from "../expose-state.ts";
41
41
  import { type EnsureHubOpts, HUB_DEFAULT_PORT, HUB_SVC, readHubPort } from "../hub-control.ts";
42
42
  import { hubDbPath, openHubDb } from "../hub-db.ts";
43
43
  import { deriveHubOrigin } from "../hub-origin.ts";
44
+ import { setHubOrigin } from "../hub-settings.ts";
44
45
  import {
45
46
  type EnsureHubVersionMatchesResult,
46
47
  ensureHubUnit,
@@ -184,6 +185,27 @@ export interface InitOpts {
184
185
  * already known so there's no question to ask).
185
186
  */
186
187
  noWizardPrompt?: boolean;
188
+ /**
189
+ * Canonical public hub origin (the OAuth issuer / `iss` claim). Persisted to
190
+ * `hub_settings.hub_origin` BEFORE the hub unit starts + modules spawn, so
191
+ * supervised vault/scribe come up with the public origin already in their
192
+ * `PARACHUTE_HUB_ORIGINS` set in ONE pass — no restart needed. The headline
193
+ * use case is the zero-SSH Caddy-direct path: a cloud-init script passes
194
+ * `--hub-origin https://<droplet-ip>.sslip.io` so a reverse-proxied box
195
+ * records its public origin without an admin browser session. Validated +
196
+ * canonicalized by the CLI before it reaches here; persisted via the
197
+ * `setHubOriginImpl` seam. Loopback / non-http(s) values never reach this far
198
+ * (the CLI rejects them via `validateHubOrigin`). Absent → no-op (the laptop /
199
+ * loopback path is unchanged).
200
+ */
201
+ hubOrigin?: string;
202
+ /**
203
+ * Test seam: persist the canonical hub origin to `hub_settings.hub_origin`.
204
+ * Production opens `hub.db` (running migrations) and calls `setHubOrigin`.
205
+ * Tests pass a stub to assert the write happens BEFORE `ensureHub` without
206
+ * touching the operator's live DB. Receives the resolved configDir + origin.
207
+ */
208
+ setHubOriginImpl?: (configDir: string, origin: string) => void;
187
209
  /**
188
210
  * Test seam: probe the running hub for its first-claim bootstrap token
189
211
  * (hub#576). Production hits `GET http://127.0.0.1:<port>/admin/setup` with
@@ -391,6 +413,25 @@ async function defaultEnsureHubViaUnit(opts: EnsureHubOpts): Promise<{
391
413
  throw new Error(result.messages.join("\n") || `hub unit bringup failed (${result.outcome}).`);
392
414
  }
393
415
 
416
+ /**
417
+ * Default impl for the `--hub-origin` persistence step. Opens `hub.db` (running
418
+ * migrations on a fresh box) and writes `hub_settings.hub_origin`. Runs BEFORE
419
+ * the hub unit starts so the boot-time issuer resolution + child-spawn env
420
+ * (`PARACHUTE_HUB_ORIGINS`) pick up the public origin in one pass. The DB is an
421
+ * on-disk SQLite file shared with the (not-yet-started) serve process, so a
422
+ * write here is visible to the unit when it boots. The CLI has already
423
+ * validated + canonicalized the URL via `validateHubOrigin`, so this trusts the
424
+ * input (mirrors `setHubOrigin`'s typed-callsite contract).
425
+ */
426
+ function defaultSetHubOrigin(configDir: string, origin: string): void {
427
+ const db = openHubDb(hubDbPath(configDir));
428
+ try {
429
+ setHubOrigin(db, origin);
430
+ } finally {
431
+ db.close();
432
+ }
433
+ }
434
+
394
435
  /**
395
436
  * Resolve the issuer to mint the operator token under. At init time the hub is
396
437
  * reachable on loopback (just installed); prefer the live expose-state origin
@@ -629,10 +670,32 @@ export async function init(opts: InitOpts = {}): Promise<number> {
629
670
  const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
630
671
  const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
631
672
  const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
673
+ const setHubOriginImpl = opts.setHubOriginImpl ?? defaultSetHubOrigin;
632
674
 
633
675
  log("Parachute init — getting your hub set up.");
634
676
  log("");
635
677
 
678
+ // Step 0: persist `--hub-origin` BEFORE the hub unit starts (below). The
679
+ // boot-time issuer resolution + child-spawn env (`PARACHUTE_HUB_ORIGINS`)
680
+ // both read `hub_settings.hub_origin`, so writing it now means vault/scribe
681
+ // come up with the public origin already in their accepted-`iss` set in ONE
682
+ // pass — no restart needed (the Caddy-direct zero-SSH path). A failure here
683
+ // is non-fatal: warn + continue (the operator can re-run `parachute hub
684
+ // set-origin` later), since init's contract is hub up → wizard regardless.
685
+ if (opts.hubOrigin) {
686
+ try {
687
+ setHubOriginImpl(configDir, opts.hubOrigin);
688
+ log(`✓ Canonical hub origin set to ${opts.hubOrigin} (OAuth issuer).`);
689
+ log("");
690
+ } catch (err) {
691
+ const detail = err instanceof Error ? err.message : String(err);
692
+ log(
693
+ `⚠ Couldn't persist the hub origin (${detail}); set it later with \`parachute hub set-origin <url>\`.`,
694
+ );
695
+ log("");
696
+ }
697
+ }
698
+
636
699
  // Step 1: hub running?
637
700
  // NB: under the Phase 3a unit-managed hub there is no pidfile, so
638
701
  // `processState(HUB_SVC)` reports not-running on EVERY init re-run even when
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { join } from "node:path";
20
20
  import { readEnvFileValues } from "../env-file.ts";
21
- import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
21
+ import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV } from "../hub-origin.ts";
22
22
  import { ModuleManifestError } from "../module-manifest.ts";
23
23
  import {
24
24
  type ServiceSpec,
@@ -30,6 +30,7 @@ import {
30
30
  import { type ServiceEntry, readManifestLenient, upsertService } from "../services-manifest.ts";
31
31
  import { enrichedPath } from "../spawn-path.ts";
32
32
  import type { Supervisor } from "../supervisor.ts";
33
+ import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
33
34
 
34
35
  export interface BootOpts {
35
36
  /** Path to services.json. */
@@ -197,7 +198,20 @@ export function buildModuleSpawnRequest(
197
198
  PORT: String(entry.port),
198
199
  ...fileEnvSansPort,
199
200
  };
200
- if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
201
+ if (opts.hubOrigin) {
202
+ env[HUB_ORIGIN_ENV] = opts.hubOrigin;
203
+ // Multi-origin iss-set (onboarding-streamline 2026-06-25): alongside the
204
+ // single canonical origin, inject the SET of origins this hub legitimately
205
+ // answers on (issuer ∪ loopback aliases ∪ expose-state ∪ platform). A
206
+ // resource server on scope-guard ≥0.5.0 widens its accepted-`iss` check to
207
+ // this set, so a token minted under one URL of a multi-URL box validates
208
+ // when the resource is reached via another URL of the SAME box. SECURITY:
209
+ // the set is hub-controlled config/disk state only, NEVER a request Host —
210
+ // see `buildHubOriginsEnvValue`. The single `PARACHUTE_HUB_ORIGIN` above
211
+ // stays for back-compat with older scope-guard.
212
+ const originsValue = buildHubOriginsEnvValue(opts.configDir, opts.hubOrigin);
213
+ if (originsValue) env[HUB_ORIGINS_ENV] = originsValue;
214
+ }
201
215
  if (opts.extraEnv) Object.assign(env, opts.extraEnv);
202
216
 
203
217
  const req: SpawnReqShape = { short, cmd };
@@ -37,13 +37,14 @@ import { readExposeState } from "../expose-state.ts";
37
37
  import { createDbHolder, defaultStatInode, startDbPathLivenessTimer } from "../hub-db-liveness.ts";
38
38
  import { hubDbPath, openHubDb } from "../hub-db.ts";
39
39
  import { hubFetch } from "../hub-server.ts";
40
+ import { getHubOrigin } from "../hub-settings.ts";
40
41
  import { writeHubFile } from "../hub.ts";
41
- import { createWsBridgeHandlers } from "../ws-bridge.ts";
42
42
  import { enrichedPath } from "../spawn-path.ts";
43
43
  import { Supervisor } from "../supervisor.ts";
44
44
  import { createUser, userCount } from "../users.ts";
45
45
  import { sanitizePublicOrigin } from "../vault-hub-origin-env.ts";
46
46
  import { WELL_KNOWN_DIR } from "../well-known.ts";
47
+ import { createWsBridgeHandlers } from "../ws-bridge.ts";
47
48
  import { bootSupervisedModules } from "./serve-boot.ts";
48
49
 
49
50
  /**
@@ -217,12 +218,30 @@ function parsePort(raw: string | undefined): number | undefined {
217
218
  * remaining gap (rebuild the live spawn-env on `supervisor.restart` so the
218
219
  * first exposure propagates to already-running children without a manual
219
220
  * restart) is the deferred #532 follow-up; not implemented in this PR.
221
+ *
222
+ * DB ORIGIN, highest precedence (onboarding-streamline 2026-06-25, Caddy-direct
223
+ * zero-SSH path): the operator-set `hub_settings.hub_origin` is the tier-1 layer
224
+ * in the per-request `resolveIssuer` (hub-server.ts). The boot path MUST honor
225
+ * it too — otherwise a Caddy-fronted box whose ONLY canonical-origin source is
226
+ * the DB row (no `PARACHUTE_HUB_ORIGIN` env, no expose-state, no platform var)
227
+ * boots `serve` with no issuer, injects only loopback aliases into supervised
228
+ * children's `PARACHUTE_HUB_ORIGINS`, and vault/scribe then reject every token
229
+ * the hub mints under the public origin (which per-request `resolveIssuer` DOES
230
+ * stamp from the DB). Threading the DB origin in as the top input keeps the
231
+ * boot-time seed in lockstep with `resolveIssuer`'s tier-1. `serve()` reads the
232
+ * row off the opened hub DB and passes it as `dbHubOrigin`; tests pass it
233
+ * directly. It is sanitized like any other public-origin input (a stray
234
+ * loopback / non-http(s) DB value never pins the issuer).
220
235
  */
221
236
  export function resolveStartupIssuer(
222
- opts: { issuer?: string },
237
+ opts: { issuer?: string; dbHubOrigin?: string },
223
238
  env: NodeJS.ProcessEnv,
224
239
  readExpose: () => string | undefined = defaultReadExposeHubOrigin,
225
240
  ): string | undefined {
241
+ // DB row first — mirrors `resolveIssuer`'s tier-1 (hub_settings.hub_origin).
242
+ // Sanitized so a stray loopback / non-http(s) value never pins the issuer.
243
+ const dbOrigin = sanitizePublicOrigin(opts.dbHubOrigin);
244
+ if (dbOrigin) return dbOrigin;
226
245
  const flyOrigin = flyDefaultOriginFromEnv(env);
227
246
  const explicit = (
228
247
  opts.issuer ??
@@ -435,7 +454,6 @@ export async function serve(opts: ServeOpts = {}): Promise<{
435
454
 
436
455
  const envPort = parsePort(env.PORT);
437
456
  const port = opts.port ?? envPort ?? DEFAULT_PORT;
438
- const issuer = resolveStartupIssuer(opts, env);
439
457
  // Containers default to 0.0.0.0 so the platform's HTTP forwarder can
440
458
  // reach us; the `--hostname` flag / PARACHUTE_BIND_HOST is the escape
441
459
  // hatch for setups that want loopback-only inside a sidecar.
@@ -461,6 +479,24 @@ export async function serve(opts: ServeOpts = {}): Promise<{
461
479
  const dbPath = hubDbPath();
462
480
  // Self-heal-or-die DB holder (#594) + proactive ghost-fd watchdog (#610/#619).
463
481
  const { dbHolder, livenessTimer } = armServeDbWatchdog(dbPath, { log });
482
+ // Resolve the boot-time issuer AFTER the DB is open so the operator-set
483
+ // `hub_settings.hub_origin` (tier-1 in `resolveIssuer`) seeds the value the
484
+ // hub mints with AND the `PARACHUTE_HUB_ORIGINS` set injected into supervised
485
+ // children. This is what makes a Caddy-fronted, zero-SSH box (whose only
486
+ // canonical-origin source is the DB row) inject the public origin into
487
+ // vault/scribe so they accept hub-minted tokens. Pass it as `dbHubOrigin`;
488
+ // a malformed read can't crash startup — `getHubOrigin` is a plain SELECT
489
+ // and `resolveStartupIssuer` sanitizes the value.
490
+ let dbHubOrigin: string | undefined;
491
+ try {
492
+ dbHubOrigin = getHubOrigin(dbHolder.get()) ?? undefined;
493
+ } catch {
494
+ dbHubOrigin = undefined;
495
+ }
496
+ const issuer = resolveStartupIssuer(
497
+ { ...opts, ...(dbHubOrigin !== undefined ? { dbHubOrigin } : {}) },
498
+ env,
499
+ );
464
500
  const adminBootstrap = await seedInitialAdminIfNeeded(dbHolder.get(), env, log);
465
501
 
466
502
  if (adminBootstrap === "needs-setup") {
package/src/help.ts CHANGED
@@ -28,6 +28,8 @@ Usage:
28
28
  parachute migrate --to-supervised move a legacy detached install to the managed hub
29
29
  parachute migrate [--dry-run] archive legacy files at ecosystem root
30
30
  parachute auth <cmd> identity (set password, manage 2FA)
31
+ parachute hub set-origin <url> set the canonical public hub origin (OAuth issuer)
32
+ — for reverse-proxy / Caddy-direct boxes
31
33
  parachute vault <args...> vault-specific ops (tokens, 2fa, config, init,
32
34
  etc.) — forwards to parachute-vault.
33
35
  For lifecycle, use \`parachute start|stop|restart|logs vault\`.
@@ -130,6 +132,7 @@ export function initHelp(): string {
130
132
  Usage:
131
133
  parachute init [--no-browser] [--no-expose-prompt]
132
134
  [--expose none|tailnet|cloudflare]
135
+ [--hub-origin <url>]
133
136
  [--cli-wizard | --browser-wizard]
134
137
 
135
138
  What it does:
@@ -165,6 +168,11 @@ Flags:
165
168
  none — stay loopback-only
166
169
  tailnet — set up Tailscale serve (private to your tailnet)
167
170
  cloudflare — set up Cloudflare Tunnel (your own domain)
171
+ --hub-origin <url> set the canonical public origin (OAuth issuer) BEFORE
172
+ the hub + modules start, so vault/scribe come up
173
+ accepting it in one pass. For reverse-proxy /
174
+ Caddy-direct boxes that bind loopback but are reached
175
+ over a public HTTPS URL (e.g. https://<ip>.sslip.io).
168
176
  --cli-wizard skip the "browser or CLI?" prompt and walk the wizard
169
177
  in this terminal (hub#168 Cut 4)
170
178
  --browser-wizard skip the prompt and open the browser wizard directly