@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.
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { hasNoDisplay, init, looksLikeServer, resolveAdminUrl } from "../commands/init.ts";
5
+ import {
6
+ hasNoDisplay,
7
+ init,
8
+ looksLikeServer,
9
+ resolveAdminUrl,
10
+ resolveInitChannel,
11
+ } from "../commands/init.ts";
6
12
  import type { ExposeState } from "../expose-state.ts";
7
13
  import { writeHubPort } from "../hub-control.ts";
8
14
  import { writePid } from "../process-state.ts";
@@ -960,6 +966,154 @@ describe("hasNoDisplay heuristic (Fix 2 — headless browser-open guard)", () =>
960
966
  });
961
967
  });
962
968
 
969
+ describe("init --hub-origin (Caddy-direct zero-SSH boot, onboarding-streamline 2026-06-25)", () => {
970
+ test("persists the origin BEFORE ensureHub so modules spawn with it in one pass", async () => {
971
+ const h = makeHarness();
972
+ try {
973
+ const order: string[] = [];
974
+ const code = await init({
975
+ configDir: h.configDir,
976
+ ensureHubVersion: async () => ({
977
+ outcome: "match" as const,
978
+ installedVersion: "test",
979
+ messages: [],
980
+ }),
981
+ manifestPath: h.manifestPath,
982
+ log: () => {},
983
+ alive: () => false,
984
+ hubOrigin: "https://box.sslip.io",
985
+ // The load-bearing ordering assertion: the origin write MUST happen
986
+ // before the hub unit starts (which boots + spawns vault/scribe), so
987
+ // the boot-time issuer + child env pick it up without a restart.
988
+ setHubOriginImpl: (_dir, origin) => {
989
+ order.push(`set-origin:${origin}`);
990
+ },
991
+ ensureHub: async () => {
992
+ order.push("ensureHub");
993
+ writeHubPort(1939, h.configDir);
994
+ return { pid: 5555, port: 1939, started: true };
995
+ },
996
+ readExposeStateFn: () => undefined,
997
+ isTty: false,
998
+ platform: "linux",
999
+ installVaultModuleImpl: noopVaultInstall,
1000
+ });
1001
+ expect(code).toBe(0);
1002
+ expect(order).toEqual(["set-origin:https://box.sslip.io", "ensureHub"]);
1003
+ } finally {
1004
+ h.cleanup();
1005
+ }
1006
+ });
1007
+
1008
+ test("default impl writes hub_settings.hub_origin to the real DB", async () => {
1009
+ const h = makeHarness();
1010
+ try {
1011
+ const code = await init({
1012
+ configDir: h.configDir,
1013
+ ensureHubVersion: async () => ({
1014
+ outcome: "match" as const,
1015
+ installedVersion: "test",
1016
+ messages: [],
1017
+ }),
1018
+ manifestPath: h.manifestPath,
1019
+ log: () => {},
1020
+ alive: () => false,
1021
+ hubOrigin: "https://box.sslip.io",
1022
+ // No setHubOriginImpl override → exercise the production default
1023
+ // (openHubDb + setHubOrigin) against the harness's temp configDir.
1024
+ ensureHub: async () => {
1025
+ writeHubPort(1939, h.configDir);
1026
+ return { pid: 5555, port: 1939, started: true };
1027
+ },
1028
+ readExposeStateFn: () => undefined,
1029
+ isTty: false,
1030
+ platform: "linux",
1031
+ installVaultModuleImpl: noopVaultInstall,
1032
+ });
1033
+ expect(code).toBe(0);
1034
+ const { hubDbPath, openHubDb } = await import("../hub-db.ts");
1035
+ const { getHubOrigin } = await import("../hub-settings.ts");
1036
+ const db = openHubDb(hubDbPath(h.configDir));
1037
+ try {
1038
+ expect(getHubOrigin(db)).toBe("https://box.sslip.io");
1039
+ } finally {
1040
+ db.close();
1041
+ }
1042
+ } finally {
1043
+ h.cleanup();
1044
+ }
1045
+ });
1046
+
1047
+ test("no --hub-origin → no persistence call (laptop/loopback path unchanged)", async () => {
1048
+ const h = makeHarness();
1049
+ try {
1050
+ let setCalls = 0;
1051
+ const code = await init({
1052
+ configDir: h.configDir,
1053
+ ensureHubVersion: async () => ({
1054
+ outcome: "match" as const,
1055
+ installedVersion: "test",
1056
+ messages: [],
1057
+ }),
1058
+ manifestPath: h.manifestPath,
1059
+ log: () => {},
1060
+ alive: () => false,
1061
+ setHubOriginImpl: () => {
1062
+ setCalls++;
1063
+ },
1064
+ ensureHub: async () => {
1065
+ writeHubPort(1939, h.configDir);
1066
+ return { pid: 5555, port: 1939, started: true };
1067
+ },
1068
+ readExposeStateFn: () => undefined,
1069
+ isTty: false,
1070
+ platform: "linux",
1071
+ installVaultModuleImpl: noopVaultInstall,
1072
+ });
1073
+ expect(code).toBe(0);
1074
+ expect(setCalls).toBe(0);
1075
+ } finally {
1076
+ h.cleanup();
1077
+ }
1078
+ });
1079
+
1080
+ test("a persistence failure is non-fatal — init still reaches the wizard", async () => {
1081
+ const h = makeHarness();
1082
+ try {
1083
+ const logs: string[] = [];
1084
+ const code = await init({
1085
+ configDir: h.configDir,
1086
+ ensureHubVersion: async () => ({
1087
+ outcome: "match" as const,
1088
+ installedVersion: "test",
1089
+ messages: [],
1090
+ }),
1091
+ manifestPath: h.manifestPath,
1092
+ log: (l) => logs.push(l),
1093
+ alive: () => false,
1094
+ hubOrigin: "https://box.sslip.io",
1095
+ setHubOriginImpl: () => {
1096
+ throw new Error("disk full");
1097
+ },
1098
+ ensureHub: async () => {
1099
+ writeHubPort(1939, h.configDir);
1100
+ return { pid: 5555, port: 1939, started: true };
1101
+ },
1102
+ readExposeStateFn: () => undefined,
1103
+ isTty: false,
1104
+ platform: "linux",
1105
+ installVaultModuleImpl: noopVaultInstall,
1106
+ });
1107
+ expect(code).toBe(0);
1108
+ const joined = logs.join("\n");
1109
+ expect(joined).toContain("Couldn't persist the hub origin");
1110
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
1111
+ } finally {
1112
+ h.cleanup();
1113
+ }
1114
+ });
1115
+ });
1116
+
963
1117
  describe("init exposure chain", () => {
964
1118
  test("TTY + no exposure + no flags → prompt is shown", async () => {
965
1119
  const h = makeHarness();
@@ -1717,5 +1871,352 @@ describe("init exposure chain", () => {
1717
1871
  });
1718
1872
  });
1719
1873
 
1874
+ // ---------------------------------------------------------------------------
1875
+ // hub#694 — first-run robustness.
1876
+ //
1877
+ // Bug 1 (the spawn race): the supervisor scans services.json EXACTLY ONCE at
1878
+ // hub-unit boot. So the vault module MUST be seeded into services.json BEFORE
1879
+ // the hub unit starts (ensureHub) — otherwise the boot scan finds no vault row
1880
+ // and vault never spawns until a manual `parachute restart`. These tests pin the
1881
+ // ordering: vault-install precedes ensureHub on both the laptop (no --hub-origin)
1882
+ // and the DO (--hub-origin) path, and Step-0 origin-persist stays first.
1883
+ //
1884
+ // Bug 2 (channel): init must install the vault module from the chosen channel
1885
+ // (`--channel rc` / PARACHUTE_CHANNEL=rc), not always @latest — otherwise an rc
1886
+ // box downgrades vault below the rc-tracking hub.
1887
+ // ---------------------------------------------------------------------------
1888
+
1889
+ describe("init hub#694 — vault seeded before the supervisor boot scan (bug 1)", () => {
1890
+ test("laptop (no --hub-origin): installs+seeds vault BEFORE ensureHub", async () => {
1891
+ const h = makeHarness();
1892
+ try {
1893
+ const order: string[] = [];
1894
+ const code = await init({
1895
+ configDir: h.configDir,
1896
+ ensureHubVersion: async () => ({
1897
+ outcome: "match" as const,
1898
+ installedVersion: "test",
1899
+ messages: [],
1900
+ }),
1901
+ manifestPath: h.manifestPath,
1902
+ log: () => {},
1903
+ alive: () => false,
1904
+ // The load-bearing assertion: the vault module install (which seeds the
1905
+ // services.json row) MUST happen before the hub unit boots, so the
1906
+ // supervisor's one-time boot scan finds + spawns vault on the first pass.
1907
+ installVaultModuleImpl: async () => {
1908
+ order.push("install-vault");
1909
+ return 0;
1910
+ },
1911
+ ensureHub: async () => {
1912
+ order.push("ensureHub");
1913
+ writeHubPort(1939, h.configDir);
1914
+ return { pid: 0, port: 1939, started: true };
1915
+ },
1916
+ readExposeStateFn: () => undefined,
1917
+ isTty: false,
1918
+ platform: "linux",
1919
+ });
1920
+ expect(code).toBe(0);
1921
+ expect(order).toEqual(["install-vault", "ensureHub"]);
1922
+ } finally {
1923
+ h.cleanup();
1924
+ }
1925
+ });
1926
+
1927
+ test("--hub-origin (DO path): order is set-origin → install vault → ensureHub", async () => {
1928
+ const h = makeHarness();
1929
+ try {
1930
+ const order: string[] = [];
1931
+ const code = await init({
1932
+ configDir: h.configDir,
1933
+ ensureHubVersion: async () => ({
1934
+ outcome: "match" as const,
1935
+ installedVersion: "test",
1936
+ messages: [],
1937
+ }),
1938
+ manifestPath: h.manifestPath,
1939
+ log: () => {},
1940
+ alive: () => false,
1941
+ hubOrigin: "https://box.sslip.io",
1942
+ // hub#693 Step-0 origin persist must remain FIRST (boot issuer + child
1943
+ // env read it); the hub#694 vault seed slots in AFTER it but BEFORE the
1944
+ // hub unit boots, so the freshly-spawned vault comes up with the public
1945
+ // origin in its accepted-iss set AND is present in the boot scan — in one
1946
+ // pass, no restart.
1947
+ setHubOriginImpl: (_dir, origin) => {
1948
+ order.push(`set-origin:${origin}`);
1949
+ },
1950
+ installVaultModuleImpl: async () => {
1951
+ order.push("install-vault");
1952
+ return 0;
1953
+ },
1954
+ ensureHub: async () => {
1955
+ order.push("ensureHub");
1956
+ writeHubPort(1939, h.configDir);
1957
+ return { pid: 0, port: 1939, started: true };
1958
+ },
1959
+ readExposeStateFn: () => undefined,
1960
+ isTty: false,
1961
+ platform: "linux",
1962
+ });
1963
+ expect(code).toBe(0);
1964
+ expect(order).toEqual(["set-origin:https://box.sslip.io", "install-vault", "ensureHub"]);
1965
+ } finally {
1966
+ h.cleanup();
1967
+ }
1968
+ });
1969
+
1970
+ test("real seed: vault row is in services.json by the time ensureHub runs", async () => {
1971
+ // End-to-end-ish: drive the REAL defaultInstallVaultModule (bun-linked vault
1972
+ // short-circuits the bun-add) and assert the services.json row exists when
1973
+ // ensureHub is invoked — i.e. the boot scan would find it.
1974
+ const h = makeHarness();
1975
+ try {
1976
+ let vaultRowAtEnsure: boolean | undefined;
1977
+ const code = await init({
1978
+ configDir: h.configDir,
1979
+ ensureHubVersion: async () => ({
1980
+ outcome: "match" as const,
1981
+ installedVersion: "test",
1982
+ messages: [],
1983
+ }),
1984
+ manifestPath: h.manifestPath,
1985
+ log: () => {},
1986
+ alive: () => false,
1987
+ ensureHub: async () => {
1988
+ // At hub-unit-boot time the services.json must already carry vault.
1989
+ const { findService } = await import("../services-manifest.ts");
1990
+ vaultRowAtEnsure = findService("parachute-vault", h.manifestPath) !== undefined;
1991
+ writeHubPort(1939, h.configDir);
1992
+ return { pid: 0, port: 1939, started: true };
1993
+ },
1994
+ readExposeStateFn: () => undefined,
1995
+ isTty: false,
1996
+ platform: "linux",
1997
+ // NOTE: no installVaultModuleImpl override — exercise the real install
1998
+ // so we prove the seed write lands before ensureHub, not just the stub.
1999
+ });
2000
+ expect(code).toBe(0);
2001
+ expect(vaultRowAtEnsure).toBe(true);
2002
+ } finally {
2003
+ h.cleanup();
2004
+ }
2005
+ });
2006
+
2007
+ test("already-seeded vault: install is skipped, ensureHub still runs", async () => {
2008
+ const h = makeHarness();
2009
+ try {
2010
+ seedVault(h.manifestPath);
2011
+ const order: string[] = [];
2012
+ const code = await init({
2013
+ configDir: h.configDir,
2014
+ ensureHubVersion: async () => ({
2015
+ outcome: "match" as const,
2016
+ installedVersion: "test",
2017
+ messages: [],
2018
+ }),
2019
+ manifestPath: h.manifestPath,
2020
+ log: () => {},
2021
+ alive: () => false,
2022
+ installVaultModuleImpl: async () => {
2023
+ order.push("install-vault");
2024
+ return 0;
2025
+ },
2026
+ ensureHub: async () => {
2027
+ order.push("ensureHub");
2028
+ writeHubPort(1939, h.configDir);
2029
+ return { pid: 0, port: 1939, started: true };
2030
+ },
2031
+ readExposeStateFn: () => undefined,
2032
+ isTty: false,
2033
+ platform: "linux",
2034
+ });
2035
+ expect(code).toBe(0);
2036
+ // Vault already present → install short-circuits; only ensureHub fires.
2037
+ expect(order).toEqual(["ensureHub"]);
2038
+ } finally {
2039
+ h.cleanup();
2040
+ }
2041
+ });
2042
+ });
2043
+
2044
+ describe("init hub#694 — install channel (bug 2)", () => {
2045
+ test("--channel rc propagates to the vault module install", async () => {
2046
+ const h = makeHarness();
2047
+ try {
2048
+ const channels: (string | undefined)[] = [];
2049
+ const code = await init({
2050
+ configDir: h.configDir,
2051
+ ensureHubVersion: async () => ({
2052
+ outcome: "match" as const,
2053
+ installedVersion: "test",
2054
+ messages: [],
2055
+ }),
2056
+ manifestPath: h.manifestPath,
2057
+ log: () => {},
2058
+ alive: () => false,
2059
+ channel: "rc",
2060
+ installVaultModuleImpl: async (_d, _m, channel) => {
2061
+ channels.push(channel);
2062
+ return 0;
2063
+ },
2064
+ ensureHub: async () => {
2065
+ writeHubPort(1939, h.configDir);
2066
+ return { pid: 0, port: 1939, started: true };
2067
+ },
2068
+ readExposeStateFn: () => undefined,
2069
+ isTty: false,
2070
+ platform: "linux",
2071
+ });
2072
+ expect(code).toBe(0);
2073
+ expect(channels).toEqual(["rc"]);
2074
+ } finally {
2075
+ h.cleanup();
2076
+ }
2077
+ });
2078
+
2079
+ test("PARACHUTE_CHANNEL=rc env propagates when no --channel flag", async () => {
2080
+ const h = makeHarness();
2081
+ try {
2082
+ const channels: (string | undefined)[] = [];
2083
+ const code = await init({
2084
+ configDir: h.configDir,
2085
+ ensureHubVersion: async () => ({
2086
+ outcome: "match" as const,
2087
+ installedVersion: "test",
2088
+ messages: [],
2089
+ }),
2090
+ manifestPath: h.manifestPath,
2091
+ log: () => {},
2092
+ alive: () => false,
2093
+ // No `channel` opt — exercise the env fallback the DO cloud-init script
2094
+ // relies on (it exports PARACHUTE_CHANNEL but passes no --channel flag).
2095
+ env: { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv,
2096
+ installVaultModuleImpl: async (_d, _m, channel) => {
2097
+ channels.push(channel);
2098
+ return 0;
2099
+ },
2100
+ ensureHub: async () => {
2101
+ writeHubPort(1939, h.configDir);
2102
+ return { pid: 0, port: 1939, started: true };
2103
+ },
2104
+ readExposeStateFn: () => undefined,
2105
+ isTty: false,
2106
+ platform: "linux",
2107
+ });
2108
+ expect(code).toBe(0);
2109
+ expect(channels).toEqual(["rc"]);
2110
+ } finally {
2111
+ h.cleanup();
2112
+ }
2113
+ });
2114
+
2115
+ test("default (no flag, no env): vault install gets undefined → install resolves latest", async () => {
2116
+ const h = makeHarness();
2117
+ try {
2118
+ const channels: (string | undefined)[] = [];
2119
+ const code = await init({
2120
+ configDir: h.configDir,
2121
+ ensureHubVersion: async () => ({
2122
+ outcome: "match" as const,
2123
+ installedVersion: "test",
2124
+ messages: [],
2125
+ }),
2126
+ manifestPath: h.manifestPath,
2127
+ log: () => {},
2128
+ alive: () => false,
2129
+ env: {} as NodeJS.ProcessEnv,
2130
+ installVaultModuleImpl: async (_d, _m, channel) => {
2131
+ channels.push(channel);
2132
+ return 0;
2133
+ },
2134
+ ensureHub: async () => {
2135
+ writeHubPort(1939, h.configDir);
2136
+ return { pid: 0, port: 1939, started: true };
2137
+ },
2138
+ readExposeStateFn: () => undefined,
2139
+ isTty: false,
2140
+ platform: "linux",
2141
+ });
2142
+ expect(code).toBe(0);
2143
+ expect(channels).toEqual([undefined]);
2144
+ } finally {
2145
+ h.cleanup();
2146
+ }
2147
+ });
2148
+
2149
+ test("--channel flag overrides PARACHUTE_CHANNEL env", async () => {
2150
+ const h = makeHarness();
2151
+ try {
2152
+ const channels: (string | undefined)[] = [];
2153
+ const code = await init({
2154
+ configDir: h.configDir,
2155
+ ensureHubVersion: async () => ({
2156
+ outcome: "match" as const,
2157
+ installedVersion: "test",
2158
+ messages: [],
2159
+ }),
2160
+ manifestPath: h.manifestPath,
2161
+ log: () => {},
2162
+ alive: () => false,
2163
+ channel: "latest",
2164
+ env: { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv,
2165
+ installVaultModuleImpl: async (_d, _m, channel) => {
2166
+ channels.push(channel);
2167
+ return 0;
2168
+ },
2169
+ ensureHub: async () => {
2170
+ writeHubPort(1939, h.configDir);
2171
+ return { pid: 0, port: 1939, started: true };
2172
+ },
2173
+ readExposeStateFn: () => undefined,
2174
+ isTty: false,
2175
+ platform: "linux",
2176
+ });
2177
+ expect(code).toBe(0);
2178
+ expect(channels).toEqual(["latest"]);
2179
+ } finally {
2180
+ h.cleanup();
2181
+ }
2182
+ });
2183
+ });
2184
+
2185
+ describe("resolveInitChannel (hub#694 bug 2)", () => {
2186
+ const empty = {} as NodeJS.ProcessEnv;
2187
+ test("explicit rc/latest wins", () => {
2188
+ expect(resolveInitChannel("rc", empty)).toBe("rc");
2189
+ expect(resolveInitChannel("latest", { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv)).toBe(
2190
+ "latest",
2191
+ );
2192
+ });
2193
+ test("PARACHUTE_CHANNEL env when no explicit", () => {
2194
+ expect(resolveInitChannel(undefined, { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv)).toBe(
2195
+ "rc",
2196
+ );
2197
+ });
2198
+ test("PARACHUTE_INSTALL_CHANNEL env when no explicit + no PARACHUTE_CHANNEL", () => {
2199
+ expect(
2200
+ resolveInitChannel(undefined, { PARACHUTE_INSTALL_CHANNEL: "rc" } as NodeJS.ProcessEnv),
2201
+ ).toBe("rc");
2202
+ });
2203
+ test("PARACHUTE_CHANNEL wins over PARACHUTE_INSTALL_CHANNEL", () => {
2204
+ expect(
2205
+ resolveInitChannel(undefined, {
2206
+ PARACHUTE_CHANNEL: "latest",
2207
+ PARACHUTE_INSTALL_CHANNEL: "rc",
2208
+ } as NodeJS.ProcessEnv),
2209
+ ).toBe("latest");
2210
+ });
2211
+ test("garbage env → undefined (install falls back to latest)", () => {
2212
+ expect(
2213
+ resolveInitChannel(undefined, { PARACHUTE_CHANNEL: "banana" } as NodeJS.ProcessEnv),
2214
+ ).toBe(undefined);
2215
+ });
2216
+ test("nothing set → undefined", () => {
2217
+ expect(resolveInitChannel(undefined, empty)).toBe(undefined);
2218
+ });
2219
+ });
2220
+
1720
2221
  // Type alias used only inside this test file for the heuristic test.
1721
2222
  type ExposeChoice = "none" | "tailnet" | "cloudflare";
@@ -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)", () => {