@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.
@@ -960,6 +960,154 @@ describe("hasNoDisplay heuristic (Fix 2 — headless browser-open guard)", () =>
960
960
  });
961
961
  });
962
962
 
963
+ describe("init --hub-origin (Caddy-direct zero-SSH boot, onboarding-streamline 2026-06-25)", () => {
964
+ test("persists the origin BEFORE ensureHub so modules spawn with it in one pass", async () => {
965
+ const h = makeHarness();
966
+ try {
967
+ const order: string[] = [];
968
+ const code = await init({
969
+ configDir: h.configDir,
970
+ ensureHubVersion: async () => ({
971
+ outcome: "match" as const,
972
+ installedVersion: "test",
973
+ messages: [],
974
+ }),
975
+ manifestPath: h.manifestPath,
976
+ log: () => {},
977
+ alive: () => false,
978
+ hubOrigin: "https://box.sslip.io",
979
+ // The load-bearing ordering assertion: the origin write MUST happen
980
+ // before the hub unit starts (which boots + spawns vault/scribe), so
981
+ // the boot-time issuer + child env pick it up without a restart.
982
+ setHubOriginImpl: (_dir, origin) => {
983
+ order.push(`set-origin:${origin}`);
984
+ },
985
+ ensureHub: async () => {
986
+ order.push("ensureHub");
987
+ writeHubPort(1939, h.configDir);
988
+ return { pid: 5555, port: 1939, started: true };
989
+ },
990
+ readExposeStateFn: () => undefined,
991
+ isTty: false,
992
+ platform: "linux",
993
+ installVaultModuleImpl: noopVaultInstall,
994
+ });
995
+ expect(code).toBe(0);
996
+ expect(order).toEqual(["set-origin:https://box.sslip.io", "ensureHub"]);
997
+ } finally {
998
+ h.cleanup();
999
+ }
1000
+ });
1001
+
1002
+ test("default impl writes hub_settings.hub_origin to the real DB", async () => {
1003
+ const h = makeHarness();
1004
+ try {
1005
+ const code = await init({
1006
+ configDir: h.configDir,
1007
+ ensureHubVersion: async () => ({
1008
+ outcome: "match" as const,
1009
+ installedVersion: "test",
1010
+ messages: [],
1011
+ }),
1012
+ manifestPath: h.manifestPath,
1013
+ log: () => {},
1014
+ alive: () => false,
1015
+ hubOrigin: "https://box.sslip.io",
1016
+ // No setHubOriginImpl override → exercise the production default
1017
+ // (openHubDb + setHubOrigin) against the harness's temp configDir.
1018
+ ensureHub: async () => {
1019
+ writeHubPort(1939, h.configDir);
1020
+ return { pid: 5555, port: 1939, started: true };
1021
+ },
1022
+ readExposeStateFn: () => undefined,
1023
+ isTty: false,
1024
+ platform: "linux",
1025
+ installVaultModuleImpl: noopVaultInstall,
1026
+ });
1027
+ expect(code).toBe(0);
1028
+ const { hubDbPath, openHubDb } = await import("../hub-db.ts");
1029
+ const { getHubOrigin } = await import("../hub-settings.ts");
1030
+ const db = openHubDb(hubDbPath(h.configDir));
1031
+ try {
1032
+ expect(getHubOrigin(db)).toBe("https://box.sslip.io");
1033
+ } finally {
1034
+ db.close();
1035
+ }
1036
+ } finally {
1037
+ h.cleanup();
1038
+ }
1039
+ });
1040
+
1041
+ test("no --hub-origin → no persistence call (laptop/loopback path unchanged)", async () => {
1042
+ const h = makeHarness();
1043
+ try {
1044
+ let setCalls = 0;
1045
+ const code = await init({
1046
+ configDir: h.configDir,
1047
+ ensureHubVersion: async () => ({
1048
+ outcome: "match" as const,
1049
+ installedVersion: "test",
1050
+ messages: [],
1051
+ }),
1052
+ manifestPath: h.manifestPath,
1053
+ log: () => {},
1054
+ alive: () => false,
1055
+ setHubOriginImpl: () => {
1056
+ setCalls++;
1057
+ },
1058
+ ensureHub: async () => {
1059
+ writeHubPort(1939, h.configDir);
1060
+ return { pid: 5555, port: 1939, started: true };
1061
+ },
1062
+ readExposeStateFn: () => undefined,
1063
+ isTty: false,
1064
+ platform: "linux",
1065
+ installVaultModuleImpl: noopVaultInstall,
1066
+ });
1067
+ expect(code).toBe(0);
1068
+ expect(setCalls).toBe(0);
1069
+ } finally {
1070
+ h.cleanup();
1071
+ }
1072
+ });
1073
+
1074
+ test("a persistence failure is non-fatal — init still reaches the wizard", async () => {
1075
+ const h = makeHarness();
1076
+ try {
1077
+ const logs: string[] = [];
1078
+ const code = await init({
1079
+ configDir: h.configDir,
1080
+ ensureHubVersion: async () => ({
1081
+ outcome: "match" as const,
1082
+ installedVersion: "test",
1083
+ messages: [],
1084
+ }),
1085
+ manifestPath: h.manifestPath,
1086
+ log: (l) => logs.push(l),
1087
+ alive: () => false,
1088
+ hubOrigin: "https://box.sslip.io",
1089
+ setHubOriginImpl: () => {
1090
+ throw new Error("disk full");
1091
+ },
1092
+ ensureHub: async () => {
1093
+ writeHubPort(1939, h.configDir);
1094
+ return { pid: 5555, port: 1939, started: true };
1095
+ },
1096
+ readExposeStateFn: () => undefined,
1097
+ isTty: false,
1098
+ platform: "linux",
1099
+ installVaultModuleImpl: noopVaultInstall,
1100
+ });
1101
+ expect(code).toBe(0);
1102
+ const joined = logs.join("\n");
1103
+ expect(joined).toContain("Couldn't persist the hub origin");
1104
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
1105
+ } finally {
1106
+ h.cleanup();
1107
+ }
1108
+ });
1109
+ });
1110
+
963
1111
  describe("init exposure chain", () => {
964
1112
  test("TTY + no exposure + no flags → prompt is shown", async () => {
965
1113
  const h = makeHarness();
@@ -125,6 +125,51 @@ describe("bootSupervisedModules", () => {
125
125
  expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
126
126
  });
127
127
 
128
+ test("forwards PARACHUTE_HUB_ORIGINS (the multi-origin iss-set) alongside the single origin", async () => {
129
+ // Multi-origin iss-set (onboarding-streamline 2026-06-25): a resource
130
+ // server on scope-guard ≥0.5.0 widens its accepted-`iss` check to this set
131
+ // so a token minted under one URL of a multi-URL box validates via another.
132
+ // The set always carries the issuer + loopback aliases (the deterministic
133
+ // members); expose-state / platform members vary by box.
134
+ writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
135
+ const recorder = makeRecorder();
136
+ const sup = new Supervisor({ spawnFn: recorder.spawn });
137
+
138
+ await bootSupervisedModules(sup, {
139
+ manifestPath: h.manifestPath,
140
+ configDir: h.dir,
141
+ hubOrigin: "https://hub.example",
142
+ });
143
+
144
+ const origins = recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS;
145
+ expect(origins).toBeDefined();
146
+ const set = (origins ?? "").split(",");
147
+ expect(set).toContain("https://hub.example");
148
+ // SECURITY: the set is hub-controlled (issuer + loopback + expose +
149
+ // platform) — never a request Host. Loopback aliases are always present.
150
+ expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
151
+ expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
152
+ // And the single canonical origin is still written for back-compat.
153
+ expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
154
+ });
155
+
156
+ test("no PARACHUTE_HUB_ORIGINS when hubOrigin is unset (no widening, single-origin only)", async () => {
157
+ // Back-compat: a boot with no hubOrigin neither sets PARACHUTE_HUB_ORIGIN
158
+ // nor the set — the child keeps its own loopback default + scope-guard's
159
+ // single-origin behavior.
160
+ writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
161
+ const recorder = makeRecorder();
162
+ const sup = new Supervisor({ spawnFn: recorder.spawn });
163
+
164
+ await bootSupervisedModules(sup, {
165
+ manifestPath: h.manifestPath,
166
+ configDir: h.dir,
167
+ });
168
+
169
+ expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBeUndefined();
170
+ expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS).toBeUndefined();
171
+ });
172
+
128
173
  test("sets PORT in child env from services.json entry (hub#357)", async () => {
129
174
  // Container deploys (Render etc.) set PORT in hub's process.env via
130
175
  // Dockerfile / platform injection. The supervisor's defaultSpawnFn
@@ -21,7 +21,9 @@ describe("hubServeOptions — the production listener wires the WS bridge", () =
21
21
  // upgrades (the channel in-page terminal) 500'd through the hub
22
22
  // ("set the websocket object in Bun.serve({})"). The listener MUST declare a
23
23
  // websocket handler or `server.upgrade()` throws.
24
- const fakeFetch = (() => new Response("ok")) as unknown as Parameters<typeof hubServeOptions>[0]["fetch"];
24
+ const fakeFetch = (() => new Response("ok")) as unknown as Parameters<
25
+ typeof hubServeOptions
26
+ >[0]["fetch"];
25
27
 
26
28
  test("declares a websocket handler set (open/message/close)", () => {
27
29
  const o = hubServeOptions({ port: 0, hostname: "127.0.0.1", fetch: fakeFetch });
@@ -404,6 +406,70 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
404
406
  test("FLY_APP_NAME empty string → no fallback", () => {
405
407
  expect(resolveStartupIssuer({}, { FLY_APP_NAME: "" }, noExpose)).toBeUndefined();
406
408
  });
409
+
410
+ // onboarding-streamline 2026-06-25 — the Caddy-direct zero-SSH boot-issuer
411
+ // fix. The operator-set `hub_settings.hub_origin` (tier-1 in resolveIssuer)
412
+ // MUST also drive the boot-time issuer, else a box whose ONLY canonical-
413
+ // origin source is the DB row boots without an issuer and injects only
414
+ // loopback into supervised children's PARACHUTE_HUB_ORIGINS.
415
+ describe("dbHubOrigin tier (DB hub_settings.hub_origin)", () => {
416
+ test("dbHubOrigin wins over env/platform/expose (mirrors resolveIssuer tier-1)", () => {
417
+ const got = resolveStartupIssuer(
418
+ { dbHubOrigin: "https://box.sslip.io" },
419
+ {
420
+ PARACHUTE_HUB_ORIGIN: "https://env.example",
421
+ RENDER_EXTERNAL_URL: "https://render.example.onrender.com",
422
+ },
423
+ () => "https://exposed.example",
424
+ );
425
+ expect(got).toBe("https://box.sslip.io");
426
+ });
427
+
428
+ test("dbHubOrigin even wins over explicit opts.issuer (DB is the operator's deliberate canonical)", () => {
429
+ const got = resolveStartupIssuer(
430
+ { issuer: "https://flag.example", dbHubOrigin: "https://box.sslip.io" },
431
+ {},
432
+ noExpose,
433
+ );
434
+ expect(got).toBe("https://box.sslip.io");
435
+ });
436
+
437
+ test("dbHubOrigin trailing slash is stripped", () => {
438
+ expect(resolveStartupIssuer({ dbHubOrigin: "https://box.sslip.io/" }, {}, noExpose)).toBe(
439
+ "https://box.sslip.io",
440
+ );
441
+ });
442
+
443
+ test("a loopback dbHubOrigin is rejected (sanitized) → falls through to next tier", () => {
444
+ // A stray loopback value in the DB must NOT pin the issuer to a non-
445
+ // public origin; fall through to env so the box keeps a usable issuer.
446
+ const got = resolveStartupIssuer(
447
+ { dbHubOrigin: "http://127.0.0.1:1939" },
448
+ { PARACHUTE_HUB_ORIGIN: "https://env.example" },
449
+ noExpose,
450
+ );
451
+ expect(got).toBe("https://env.example");
452
+ });
453
+
454
+ test("a non-http(s) dbHubOrigin is rejected → falls through", () => {
455
+ const got = resolveStartupIssuer(
456
+ { dbHubOrigin: "ftp://box.example" },
457
+ {},
458
+ () => "https://exposed.example",
459
+ );
460
+ expect(got).toBe("https://exposed.example");
461
+ });
462
+
463
+ test("undefined dbHubOrigin is a no-op (unchanged precedence)", () => {
464
+ expect(
465
+ resolveStartupIssuer(
466
+ { dbHubOrigin: undefined },
467
+ { PARACHUTE_HUB_ORIGIN: "https://e.x" },
468
+ noExpose,
469
+ ),
470
+ ).toBe("https://e.x");
471
+ });
472
+ });
407
473
  });
408
474
 
409
475
  describe("resolveStartupIssuer — expose-state fallback (#531)", () => {
@@ -26,7 +26,7 @@ import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
26
26
  import { type ExposeState, readExposeState, writeExposeState } from "../expose-state.ts";
27
27
  import { hubDbPath, openHubDb } from "../hub-db.ts";
28
28
  import { hubFetch } from "../hub-server.ts";
29
- import { getSetting, setSetting } from "../hub-settings.ts";
29
+ import { getSetting, setHubOrigin, setSetting } from "../hub-settings.ts";
30
30
  import { validateAccessToken } from "../jwt-sign.ts";
31
31
  import {
32
32
  OPERATOR_TOKEN_SCOPE_SET_CLAIM,
@@ -537,6 +537,126 @@ describe("deriveWizardState", () => {
537
537
  db.close();
538
538
  }
539
539
  });
540
+
541
+ // --- Caddy-direct: a configured public origin already answers "how reached?"
542
+ // Seed a real vault row so the only step left to decide is expose.
543
+ const seedAdminAndVault = async (db: ReturnType<typeof openHubDb>) => {
544
+ await createUser(db, "owner", "pw");
545
+ writeManifest(
546
+ {
547
+ services: [
548
+ {
549
+ name: "parachute-vault",
550
+ version: "0.1.0",
551
+ port: 1940,
552
+ paths: ["/vault/default"],
553
+ health: "/health",
554
+ },
555
+ ],
556
+ },
557
+ h.manifestPath,
558
+ );
559
+ };
560
+
561
+ test("auto-skips expose when a public hub_origin is configured (Caddy-direct / init --hub-origin)", async () => {
562
+ // Caddy-direct box: `parachute init --hub-origin https://host` persisted a
563
+ // real public origin to hub_settings.hub_origin BEFORE the wizard opened.
564
+ // No Render/Fly env var, no live tailscale exposure. That origin already
565
+ // answers "how will this hub be reached?" — the wizard must skip the expose
566
+ // step (account → vault → done) rather than re-asking.
567
+ const db = openHubDb(hubDbPath(h.dir));
568
+ try {
569
+ await seedAdminAndVault(db);
570
+ setHubOrigin(db, "https://box.example.com");
571
+ const s = deriveWizardState({
572
+ db,
573
+ manifestPath: h.manifestPath,
574
+ env: {},
575
+ readExposeStateFn: h.readExposeStateFn,
576
+ });
577
+ expect(s.step).toBe("done");
578
+ expect(s.hasExposeMode).toBe(true);
579
+ // Seeded "public" (not localhost/tailnet) — a Caddy-fronted box is public.
580
+ expect(getSetting(db, "setup_expose_mode")).toBe("public");
581
+ } finally {
582
+ db.close();
583
+ }
584
+ });
585
+
586
+ test("auto-skips expose when PARACHUTE_HUB_ORIGIN env is a public origin (env-var Caddy box)", async () => {
587
+ const db = openHubDb(hubDbPath(h.dir));
588
+ try {
589
+ await seedAdminAndVault(db);
590
+ const s = deriveWizardState({
591
+ db,
592
+ manifestPath: h.manifestPath,
593
+ env: { PARACHUTE_HUB_ORIGIN: "https://box.example.com" },
594
+ readExposeStateFn: h.readExposeStateFn,
595
+ });
596
+ expect(s.step).toBe("done");
597
+ expect(s.hasExposeMode).toBe(true);
598
+ expect(getSetting(db, "setup_expose_mode")).toBe("public");
599
+ } finally {
600
+ db.close();
601
+ }
602
+ });
603
+
604
+ test("does NOT skip expose when hub_origin is loopback (laptop / dev box still asks)", async () => {
605
+ // A loopback hub_origin must NOT count as "reached publicly" —
606
+ // sanitizePublicOrigin rejects 127.0.0.1/localhost/::1/0.0.0.0, so the
607
+ // expose step still renders and the operator chooses.
608
+ const db = openHubDb(hubDbPath(h.dir));
609
+ try {
610
+ await seedAdminAndVault(db);
611
+ setHubOrigin(db, "http://127.0.0.1:1939");
612
+ const s = deriveWizardState({
613
+ db,
614
+ manifestPath: h.manifestPath,
615
+ env: {},
616
+ readExposeStateFn: h.readExposeStateFn,
617
+ });
618
+ expect(s.step).toBe("expose");
619
+ expect(s.hasExposeMode).toBe(false);
620
+ expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
621
+ } finally {
622
+ db.close();
623
+ }
624
+ });
625
+
626
+ test("does NOT skip expose when no origin is configured (unset → still asks)", async () => {
627
+ const db = openHubDb(hubDbPath(h.dir));
628
+ try {
629
+ await seedAdminAndVault(db);
630
+ const s = deriveWizardState({
631
+ db,
632
+ manifestPath: h.manifestPath,
633
+ env: {},
634
+ readExposeStateFn: h.readExposeStateFn,
635
+ });
636
+ expect(s.step).toBe("expose");
637
+ expect(s.hasExposeMode).toBe(false);
638
+ expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
639
+ } finally {
640
+ db.close();
641
+ }
642
+ });
643
+
644
+ test("does NOT skip expose when PARACHUTE_HUB_ORIGIN env is loopback", async () => {
645
+ const db = openHubDb(hubDbPath(h.dir));
646
+ try {
647
+ await seedAdminAndVault(db);
648
+ const s = deriveWizardState({
649
+ db,
650
+ manifestPath: h.manifestPath,
651
+ env: { PARACHUTE_HUB_ORIGIN: "http://localhost:1939" },
652
+ readExposeStateFn: h.readExposeStateFn,
653
+ });
654
+ expect(s.step).toBe("expose");
655
+ expect(s.hasExposeMode).toBe(false);
656
+ } finally {
657
+ db.close();
658
+ }
659
+ });
540
660
  });
541
661
 
542
662
  // --- GET /admin/setup ----------------------------------------------------
@@ -76,13 +76,29 @@ describe("persistVaultHubOrigin", () => {
76
76
  expect(existsSync(vaultEnv())).toBe(false);
77
77
  });
78
78
 
79
- test("is idempotent — no rewrite when the value is already current", () => {
79
+ test("is idempotent — no rewrite when both the origin and the set are current", () => {
80
80
  const log: string[] = [];
81
81
  expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(true);
82
+ // First call wrote BOTH the single origin and the multi-origin set.
83
+ expect(
84
+ log.some((l) => /persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/.test(l)),
85
+ ).toBe(true);
86
+ expect(log.some((l) => /persisted PARACHUTE_HUB_ORIGINS=/.test(l))).toBe(true);
87
+ const writesAfterFirst = log.length;
88
+ // Second call is a no-op: both values already current → false, no new logs.
82
89
  expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(false);
83
- // Only the first call logged.
84
- expect(log).toHaveLength(1);
85
- expect(log[0]).toMatch(/persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/);
90
+ expect(log).toHaveLength(writesAfterFirst);
91
+ });
92
+
93
+ test("writes PARACHUTE_HUB_ORIGINS (multi-origin iss-set) alongside the single origin", () => {
94
+ expect(persistVaultHubOrigin(dir, "https://hub.example.com", () => {})).toBe(true);
95
+ const values = readEnvFileValues(vaultEnv());
96
+ expect(values.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example.com");
97
+ // The set carries the issuer + loopback aliases (always present).
98
+ const set = (values.PARACHUTE_HUB_ORIGINS ?? "").split(",");
99
+ expect(set).toContain("https://hub.example.com");
100
+ expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
101
+ expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
86
102
  });
87
103
 
88
104
  test("updates a stale origin in-place and preserves sibling keys", () => {
@@ -113,6 +129,24 @@ describe("clearVaultHubOrigin", () => {
113
129
  expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
114
130
  });
115
131
 
132
+ test("removes BOTH the single origin and the multi-origin set, leaving siblings", () => {
133
+ writeFileSync(
134
+ mkVaultDir(),
135
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://hub.example.com\nPARACHUTE_HUB_ORIGINS=https://hub.example.com,http://127.0.0.1:1939\n",
136
+ );
137
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
138
+ const values = readEnvFileValues(vaultEnv());
139
+ expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
140
+ expect(values.PARACHUTE_HUB_ORIGINS).toBeUndefined();
141
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
142
+ });
143
+
144
+ test("clears a lone PARACHUTE_HUB_ORIGINS even if the single origin is absent", () => {
145
+ writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGINS=https://hub.example.com\n");
146
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
147
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGINS).toBeUndefined();
148
+ });
149
+
116
150
  test("no-op when no origin is present", () => {
117
151
  writeFileSync(mkVaultDir(), "SCRIBE_AUTH_TOKEN=secret\n");
118
152
  expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
@@ -55,6 +55,7 @@ import {
55
55
  import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
56
56
  import { enrichedPath } from "./spawn-path.ts";
57
57
  import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
58
+ import { buildHubOriginsEnvValue } from "./vault-hub-origin-env.ts";
58
59
  import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
59
60
 
60
61
  /**
@@ -609,10 +610,21 @@ async function spawnSupervised(
609
610
  // `process.env.PATH` may ALREADY be enriched by serve startup (serve.ts);
610
611
  // re-enriching here is a harmless no-op — `enrichedPath` is idempotent
611
612
  // (dedupe + append-only), so double-enrichment can't duplicate or reorder.
613
+ // PARACHUTE_HUB_ORIGINS (multi-origin iss-set, onboarding-streamline
614
+ // 2026-06-25): alongside the single canonical PARACHUTE_HUB_ORIGIN, inject
615
+ // the SET of origins the hub legitimately answers on (issuer ∪ loopback ∪
616
+ // expose-state ∪ platform). A resource server on scope-guard ≥0.5.0 widens
617
+ // its accepted-`iss` check to this set so a token minted under one URL of a
618
+ // multi-URL box validates via another URL of the SAME box. Mirrors
619
+ // buildModuleSpawnRequest (serve-boot.ts) — keep the two in sync. SECURITY:
620
+ // the set is hub-controlled config/disk state ONLY, never a request Host
621
+ // (see buildHubOriginsEnvValue). `deps.spawnEnv` still wins.
622
+ const hubOrigins = deps.issuer ? buildHubOriginsEnvValue(deps.configDir, deps.issuer) : undefined;
612
623
  const childEnv: Record<string, string> = {
613
624
  PATH: enrichedPath(),
614
625
  PORT: String(entry.port),
615
626
  ...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
627
+ ...(hubOrigins ? { PARACHUTE_HUB_ORIGINS: hubOrigins } : {}),
616
628
  ...(deps.spawnEnv ?? {}),
617
629
  };
618
630
  const req: SpawnRequest = {
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { MissingDependencyError } from "@openparachute/depcheck";
10
10
  import pkg from "../package.json" with { type: "json" };
11
+ import { validateHubOrigin } from "./api-settings-hub-origin.ts";
11
12
  import { CloudflaredStateError } from "./cloudflare/state.ts";
12
13
  // Command-implementation modules are loaded LAZILY inside their switch arms (see
13
14
  // `loadCommand` + each `case`), so a module that throws at eval-time is isolated
@@ -360,7 +361,32 @@ async function main(argv: string[]): Promise<number> {
360
361
  console.log(initHelp());
361
362
  return 0;
362
363
  }
363
- const exposeExtract = extractNamedFlag(rest, "--expose");
364
+ const originExtract = extractHubOrigin(rest);
365
+ if (originExtract.error) {
366
+ console.error(`parachute init: ${originExtract.error}`);
367
+ return 1;
368
+ }
369
+ // Validate --hub-origin to the SAME shape `hub set-origin` enforces — the
370
+ // value is persisted to hub_settings.hub_origin and stamps the OAuth `iss`
371
+ // claim on every minted token, so a malformed path/scheme/credential must
372
+ // never reach the DB. Strip a trailing slash first (browser copy-paste
373
+ // ergonomics), then validate; pass the normalized form downstream.
374
+ let validatedHubOrigin: string | undefined;
375
+ if (originExtract.hubOrigin !== undefined) {
376
+ const result = validateHubOrigin(originExtract.hubOrigin.replace(/\/+$/, ""));
377
+ if (!result.ok) {
378
+ console.error(`parachute init: invalid --hub-origin: ${result.description}`);
379
+ return 1;
380
+ }
381
+ if (result.normalized === null) {
382
+ console.error(
383
+ "parachute init: invalid --hub-origin: an empty value is not a valid public origin.",
384
+ );
385
+ return 1;
386
+ }
387
+ validatedHubOrigin = result.normalized;
388
+ }
389
+ const exposeExtract = extractNamedFlag(originExtract.rest, "--expose");
364
390
  if (exposeExtract.error) {
365
391
  console.error(`parachute init: ${exposeExtract.error}`);
366
392
  return 1;
@@ -392,6 +418,7 @@ async function main(argv: string[]): Promise<number> {
392
418
  console.error(
393
419
  "usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
394
420
  " [--expose none|tailnet|cloudflare]\n" +
421
+ " [--hub-origin <url>]\n" +
395
422
  " [--cli-wizard | --browser-wizard]",
396
423
  );
397
424
  return 1;
@@ -403,6 +430,7 @@ async function main(argv: string[]): Promise<number> {
403
430
  const initOpts: Parameters<typeof init>[0] = {};
404
431
  if (noBrowser) initOpts.noBrowser = true;
405
432
  if (noExposePrompt) initOpts.noExposePrompt = true;
433
+ if (validatedHubOrigin) initOpts.hubOrigin = validatedHubOrigin;
406
434
  if (exposeExtract.value) {
407
435
  initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
408
436
  }
@@ -931,6 +959,12 @@ async function main(argv: string[]): Promise<number> {
931
959
  return await mod.auth(rest);
932
960
  }
933
961
 
962
+ case "hub": {
963
+ const mod = await loadCommand("hub", () => import("./commands/hub.ts"));
964
+ if (!mod) return 1;
965
+ return await mod.hub(rest);
966
+ }
967
+
934
968
  case "vault": {
935
969
  const mod = await loadCommand("vault", () => import("./commands/vault.ts"));
936
970
  if (!mod) return 1;