@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 +1 -1
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +433 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/hub-server.test.ts +240 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/setup-wizard.test.ts +121 -1
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/api-modules-ops.ts +12 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +409 -0
- package/src/commands/init.ts +63 -0
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/help.ts +8 -0
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +46 -0
- package/src/jwt-sign.ts +14 -3
- package/src/setup-wizard.ts +20 -0
- package/src/vault-hub-origin-env.ts +109 -16
|
@@ -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<
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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);
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -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
|
|
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;
|