@openparachute/hub 0.5.13-rc.29 → 0.5.13-rc.35
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__/api-modules-ops.test.ts +82 -0
- package/src/__tests__/hub-server.test.ts +230 -4
- package/src/__tests__/lifecycle.test.ts +12 -1
- package/src/__tests__/serve-boot.test.ts +21 -0
- package/src/__tests__/serve.test.ts +64 -0
- package/src/__tests__/setup-wizard.test.ts +10 -3
- package/src/api-modules-ops.ts +25 -1
- package/src/commands/lifecycle.ts +7 -1
- package/src/commands/serve-boot.ts +8 -4
- package/src/commands/serve.ts +36 -1
- package/src/hub-server.ts +39 -8
package/package.json
CHANGED
|
@@ -1005,6 +1005,88 @@ describe("well-known regen after module ops", () => {
|
|
|
1005
1005
|
expect(doc.vaults.some((v) => v.name === "default")).toBe(true);
|
|
1006
1006
|
});
|
|
1007
1007
|
|
|
1008
|
+
test("runInstall sets PORT in child env from services.json entry (hub#356)", async () => {
|
|
1009
|
+
// Container deploys (Render / etc.) set PORT in hub's env via the
|
|
1010
|
+
// Dockerfile / platform. Without explicit override at spawn time, every
|
|
1011
|
+
// supervised child inherits hub's PORT via `env: process.env` and tries
|
|
1012
|
+
// to bind hub's own port — EADDRINUSE → crashloop → supervisor gives up.
|
|
1013
|
+
// Regression guard: the spawn captures the child's services.json port.
|
|
1014
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
1015
|
+
const { run } = alwaysOkRun();
|
|
1016
|
+
const wkPath = join(h.dir, "well-known.json");
|
|
1017
|
+
const install = fakeInstall("@openparachute/vault", {
|
|
1018
|
+
name: "vault",
|
|
1019
|
+
manifestName: "parachute-vault",
|
|
1020
|
+
port: 1940,
|
|
1021
|
+
paths: ["/vault/default"],
|
|
1022
|
+
health: "/vault/default/health",
|
|
1023
|
+
});
|
|
1024
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1025
|
+
const deps = {
|
|
1026
|
+
db: h.db,
|
|
1027
|
+
issuer: ISSUER,
|
|
1028
|
+
manifestPath: h.manifestPath,
|
|
1029
|
+
configDir: h.dir,
|
|
1030
|
+
supervisor,
|
|
1031
|
+
run,
|
|
1032
|
+
findGlobalInstall: install.findGlobalInstall,
|
|
1033
|
+
readModuleManifest: install.readModuleManifest,
|
|
1034
|
+
wellKnownPath: wkPath,
|
|
1035
|
+
};
|
|
1036
|
+
await handleInstall(
|
|
1037
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
1038
|
+
"vault",
|
|
1039
|
+
deps,
|
|
1040
|
+
);
|
|
1041
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1042
|
+
expect(spawns.length).toBe(1);
|
|
1043
|
+
expect(spawns[0]?.env?.PORT).toBe("1940");
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test("runInstall sets PARACHUTE_HUB_ORIGIN in child env from deps.issuer (hub#365)", async () => {
|
|
1047
|
+
// Supervised modules (vault, scribe, app) validate the `iss` claim
|
|
1048
|
+
// on hub-minted JWTs against PARACHUTE_HUB_ORIGIN. Without it, they
|
|
1049
|
+
// fall back to a loopback default and reject any token whose iss is
|
|
1050
|
+
// the public Render URL — surfaces as "hub JWT verification failed:
|
|
1051
|
+
// unexpected 'iss' claim value" on the first authed vault call.
|
|
1052
|
+
// Regression guard: install-path spawn carries the hub's resolved
|
|
1053
|
+
// issuer as PARACHUTE_HUB_ORIGIN.
|
|
1054
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
1055
|
+
const { run } = alwaysOkRun();
|
|
1056
|
+
const wkPath = join(h.dir, "well-known.json");
|
|
1057
|
+
const install = fakeInstall("@openparachute/vault", {
|
|
1058
|
+
name: "vault",
|
|
1059
|
+
manifestName: "parachute-vault",
|
|
1060
|
+
port: 1940,
|
|
1061
|
+
paths: ["/vault/default"],
|
|
1062
|
+
health: "/vault/default/health",
|
|
1063
|
+
});
|
|
1064
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1065
|
+
const deps = {
|
|
1066
|
+
db: h.db,
|
|
1067
|
+
// Use the test's canonical ISSUER (matches the bearer's iss claim
|
|
1068
|
+
// so handleInstall doesn't 401 — mintBearer always uses ISSUER).
|
|
1069
|
+
// The assertion below verifies whatever issuer is on `deps` lands
|
|
1070
|
+
// in the child's PARACHUTE_HUB_ORIGIN env.
|
|
1071
|
+
issuer: ISSUER,
|
|
1072
|
+
manifestPath: h.manifestPath,
|
|
1073
|
+
configDir: h.dir,
|
|
1074
|
+
supervisor,
|
|
1075
|
+
run,
|
|
1076
|
+
findGlobalInstall: install.findGlobalInstall,
|
|
1077
|
+
readModuleManifest: install.readModuleManifest,
|
|
1078
|
+
wellKnownPath: wkPath,
|
|
1079
|
+
};
|
|
1080
|
+
await handleInstall(
|
|
1081
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
1082
|
+
"vault",
|
|
1083
|
+
deps,
|
|
1084
|
+
);
|
|
1085
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1086
|
+
expect(spawns.length).toBe(1);
|
|
1087
|
+
expect(spawns[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe(ISSUER);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1008
1090
|
test("runInstall failure: bun add fails -> no well-known regen (no partial state)", async () => {
|
|
1009
1091
|
const { supervisor } = makeIdleSupervisor();
|
|
1010
1092
|
const wkPath = join(h.dir, "well-known.json");
|
|
@@ -5,7 +5,13 @@ import { join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
7
7
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
findServiceUpstream,
|
|
10
|
+
findVaultUpstream,
|
|
11
|
+
hubFetch,
|
|
12
|
+
layerOf,
|
|
13
|
+
parseArgs,
|
|
14
|
+
} from "../hub-server.ts";
|
|
9
15
|
import { setNotesRedirectDisabled } from "../hub-settings.ts";
|
|
10
16
|
import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
11
17
|
import { pidPath } from "../process-state.ts";
|
|
@@ -80,13 +86,24 @@ describe("hubFetch routing", () => {
|
|
|
80
86
|
// hub#259 rc.6: requires an admin row to bypass the fresh-hub
|
|
81
87
|
// funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
|
|
82
88
|
// test continues to exercise the signed-out-but-setup-done branch.
|
|
89
|
+
//
|
|
90
|
+
// Hub also funnels to /admin/setup when no vault is installed
|
|
91
|
+
// (env-seed case). Pass an explicit manifestPath + seed a vault row
|
|
92
|
+
// so the funnel sees an installed vault and lets the discovery page
|
|
93
|
+
// render. Without this, the manifestPath default falls back to
|
|
94
|
+
// `~/.parachute/services.json` — which works on a dev box with an
|
|
95
|
+
// installed vault but fails in CI with a 302 wizard redirect.
|
|
83
96
|
const h = makeHarness();
|
|
84
97
|
try {
|
|
98
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
85
99
|
const db = openHubDb(hubDbPath(h.dir));
|
|
86
100
|
try {
|
|
87
101
|
const { createUser } = await import("../users.ts");
|
|
88
102
|
await createUser(db, "owner", "pw");
|
|
89
|
-
const res = await hubFetch(h.dir, {
|
|
103
|
+
const res = await hubFetch(h.dir, {
|
|
104
|
+
getDb: () => db,
|
|
105
|
+
manifestPath: h.manifestPath,
|
|
106
|
+
})(req("/"));
|
|
90
107
|
expect(res.status).toBe(200);
|
|
91
108
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
92
109
|
const body = await res.text();
|
|
@@ -102,8 +119,12 @@ describe("hubFetch routing", () => {
|
|
|
102
119
|
});
|
|
103
120
|
|
|
104
121
|
test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
|
|
122
|
+
// Same wizard-funnel bypass as the signed-out test above — seed a
|
|
123
|
+
// vault row and pass an explicit manifestPath so CI doesn't fall back
|
|
124
|
+
// to ~/.parachute/services.json.
|
|
105
125
|
const h = makeHarness();
|
|
106
126
|
try {
|
|
127
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
107
128
|
const db = openHubDb(hubDbPath(h.dir));
|
|
108
129
|
try {
|
|
109
130
|
const { createUser } = await import("../users.ts");
|
|
@@ -113,7 +134,10 @@ describe("hubFetch routing", () => {
|
|
|
113
134
|
const user = await createUser(db, "aaron", "pw");
|
|
114
135
|
const session = createSession(db, { userId: user.id });
|
|
115
136
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
116
|
-
const res = await hubFetch(h.dir, {
|
|
137
|
+
const res = await hubFetch(h.dir, {
|
|
138
|
+
getDb: () => db,
|
|
139
|
+
manifestPath: h.manifestPath,
|
|
140
|
+
})(req("/", { headers: { cookie } }));
|
|
117
141
|
expect(res.status).toBe(200);
|
|
118
142
|
const body = await res.text();
|
|
119
143
|
expect(body).toContain("Signed in as");
|
|
@@ -1390,7 +1414,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1390
1414
|
fetch: async (req) => {
|
|
1391
1415
|
const u = new URL(req.url);
|
|
1392
1416
|
// Echo enough metadata for tests to verify path + method + body
|
|
1393
|
-
// arrive intact end-to-end.
|
|
1417
|
+
// arrive intact end-to-end. Also echo X-Forwarded-* headers so
|
|
1418
|
+
// tests can assert hub forwards proxy hints (hub#358).
|
|
1394
1419
|
const body = req.body ? await req.text() : "";
|
|
1395
1420
|
return new Response(
|
|
1396
1421
|
JSON.stringify({
|
|
@@ -1399,6 +1424,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1399
1424
|
pathname: u.pathname,
|
|
1400
1425
|
search: u.search,
|
|
1401
1426
|
body,
|
|
1427
|
+
forwardedHost: req.headers.get("x-forwarded-host"),
|
|
1428
|
+
forwardedProto: req.headers.get("x-forwarded-proto"),
|
|
1402
1429
|
}),
|
|
1403
1430
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1404
1431
|
);
|
|
@@ -1409,6 +1436,164 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1409
1436
|
return { port: server.port as number, stop: () => server.stop(true) };
|
|
1410
1437
|
}
|
|
1411
1438
|
|
|
1439
|
+
test("forwards X-Forwarded-Host + X-Forwarded-Proto to upstream (hub#358)", async () => {
|
|
1440
|
+
// Container deploys: supervised modules (vault, scribe, app) reverse-
|
|
1441
|
+
// proxy through hub. They need the public origin to construct OAuth
|
|
1442
|
+
// discovery metadata and redirect URIs. Without forwarded headers,
|
|
1443
|
+
// they fall back to their internal loopback URL — breaking OAuth
|
|
1444
|
+
// flows for clients that landed on a hub-proxied URL.
|
|
1445
|
+
const h = makeHarness();
|
|
1446
|
+
const upstream = startUpstream("hdr-test");
|
|
1447
|
+
try {
|
|
1448
|
+
writeManifest(
|
|
1449
|
+
{
|
|
1450
|
+
services: [
|
|
1451
|
+
{
|
|
1452
|
+
name: "parachute-vault",
|
|
1453
|
+
port: upstream.port,
|
|
1454
|
+
paths: ["/vault/default"],
|
|
1455
|
+
health: "/vault/default/health",
|
|
1456
|
+
version: "0.4.0",
|
|
1457
|
+
},
|
|
1458
|
+
],
|
|
1459
|
+
},
|
|
1460
|
+
h.manifestPath,
|
|
1461
|
+
);
|
|
1462
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1463
|
+
// Simulate Render-shape: page came in over HTTPS at parachute-hub.onrender.com
|
|
1464
|
+
// Render terminates TLS and sets X-Forwarded-Proto: https; the Host
|
|
1465
|
+
// header is preserved as the public hostname.
|
|
1466
|
+
const incoming = new Request("http://parachute-hub.onrender.com/vault/default/.well-known/oauth-authorization-server", {
|
|
1467
|
+
headers: {
|
|
1468
|
+
host: "parachute-hub.onrender.com",
|
|
1469
|
+
"x-forwarded-proto": "https",
|
|
1470
|
+
},
|
|
1471
|
+
});
|
|
1472
|
+
const res = await fetcher(incoming);
|
|
1473
|
+
expect(res.status).toBe(200);
|
|
1474
|
+
const body = (await res.json()) as { forwardedHost: string; forwardedProto: string };
|
|
1475
|
+
// Hub captured the public Host and forwarded it as X-Forwarded-Host.
|
|
1476
|
+
expect(body.forwardedHost).toBe("parachute-hub.onrender.com");
|
|
1477
|
+
// X-Forwarded-Proto preserved (edge already set it).
|
|
1478
|
+
expect(body.forwardedProto).toBe("https");
|
|
1479
|
+
} finally {
|
|
1480
|
+
upstream.stop();
|
|
1481
|
+
h.cleanup();
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
test("synthesizes X-Forwarded-Proto when edge didn't set it (direct HTTPS to hub)", async () => {
|
|
1486
|
+
// Non-Render shape: hub bound directly to https (e.g. local TLS or a
|
|
1487
|
+
// proxy that doesn't set X-Forwarded-Proto). isHttpsRequest sees the
|
|
1488
|
+
// URL's https scheme; proxyRequest synthesizes X-Forwarded-Proto.
|
|
1489
|
+
const h = makeHarness();
|
|
1490
|
+
const upstream = startUpstream("hdr-test");
|
|
1491
|
+
try {
|
|
1492
|
+
writeManifest(
|
|
1493
|
+
{
|
|
1494
|
+
services: [
|
|
1495
|
+
{
|
|
1496
|
+
name: "parachute-vault",
|
|
1497
|
+
port: upstream.port,
|
|
1498
|
+
paths: ["/vault/default"],
|
|
1499
|
+
health: "/vault/default/health",
|
|
1500
|
+
version: "0.4.0",
|
|
1501
|
+
},
|
|
1502
|
+
],
|
|
1503
|
+
},
|
|
1504
|
+
h.manifestPath,
|
|
1505
|
+
);
|
|
1506
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1507
|
+
const incoming = new Request("https://hub.example.com/vault/default/health", {
|
|
1508
|
+
headers: { host: "hub.example.com" },
|
|
1509
|
+
});
|
|
1510
|
+
const res = await fetcher(incoming);
|
|
1511
|
+
const body = (await res.json()) as { forwardedHost: string; forwardedProto: string };
|
|
1512
|
+
expect(body.forwardedHost).toBe("hub.example.com");
|
|
1513
|
+
expect(body.forwardedProto).toBe("https");
|
|
1514
|
+
} finally {
|
|
1515
|
+
upstream.stop();
|
|
1516
|
+
h.cleanup();
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
test("synthesizes X-Forwarded-Proto=http when neither edge nor URL signal HTTPS", async () => {
|
|
1521
|
+
// Plain-HTTP path: no edge, no https URL scheme. isHttpsRequest returns
|
|
1522
|
+
// false; proxyRequest synthesizes "http". This is local-dev shape.
|
|
1523
|
+
const h = makeHarness();
|
|
1524
|
+
const upstream = startUpstream("hdr-test");
|
|
1525
|
+
try {
|
|
1526
|
+
writeManifest(
|
|
1527
|
+
{
|
|
1528
|
+
services: [
|
|
1529
|
+
{
|
|
1530
|
+
name: "parachute-vault",
|
|
1531
|
+
port: upstream.port,
|
|
1532
|
+
paths: ["/vault/default"],
|
|
1533
|
+
health: "/vault/default/health",
|
|
1534
|
+
version: "0.4.0",
|
|
1535
|
+
},
|
|
1536
|
+
],
|
|
1537
|
+
},
|
|
1538
|
+
h.manifestPath,
|
|
1539
|
+
);
|
|
1540
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1541
|
+
const incoming = new Request("http://127.0.0.1:1939/vault/default/health", {
|
|
1542
|
+
headers: { host: "127.0.0.1:1939" },
|
|
1543
|
+
});
|
|
1544
|
+
const res = await fetcher(incoming);
|
|
1545
|
+
const body = (await res.json()) as { forwardedProto: string };
|
|
1546
|
+
expect(body.forwardedProto).toBe("http");
|
|
1547
|
+
} finally {
|
|
1548
|
+
upstream.stop();
|
|
1549
|
+
h.cleanup();
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
test("preserves X-Forwarded-Host when edge already set it (nested-proxy chain)", async () => {
|
|
1554
|
+
// Multi-hop chain: client → edge proxy → hub → upstream. Edge captures
|
|
1555
|
+
// the original host as X-Forwarded-Host before its own host-rewrite;
|
|
1556
|
+
// hub MUST preserve that, not overwrite with its own incoming Host
|
|
1557
|
+
// (which may be the edge's hostname). "Don't clobber" semantics.
|
|
1558
|
+
const h = makeHarness();
|
|
1559
|
+
const upstream = startUpstream("hdr-test");
|
|
1560
|
+
try {
|
|
1561
|
+
writeManifest(
|
|
1562
|
+
{
|
|
1563
|
+
services: [
|
|
1564
|
+
{
|
|
1565
|
+
name: "parachute-vault",
|
|
1566
|
+
port: upstream.port,
|
|
1567
|
+
paths: ["/vault/default"],
|
|
1568
|
+
health: "/vault/default/health",
|
|
1569
|
+
version: "0.4.0",
|
|
1570
|
+
},
|
|
1571
|
+
],
|
|
1572
|
+
},
|
|
1573
|
+
h.manifestPath,
|
|
1574
|
+
);
|
|
1575
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1576
|
+
// Edge already labeled the canonical hop: original-client.example
|
|
1577
|
+
// (this is what an HTTPS termination proxy in front of hub would set).
|
|
1578
|
+
const incoming = new Request("http://edge.local/vault/default/health", {
|
|
1579
|
+
headers: {
|
|
1580
|
+
host: "edge.local",
|
|
1581
|
+
"x-forwarded-host": "original-client.example",
|
|
1582
|
+
"x-forwarded-proto": "https",
|
|
1583
|
+
},
|
|
1584
|
+
});
|
|
1585
|
+
const res = await fetcher(incoming);
|
|
1586
|
+
const body = (await res.json()) as { forwardedHost: string; forwardedProto: string };
|
|
1587
|
+
// Edge's X-Forwarded-Host preserved verbatim, NOT clobbered by the
|
|
1588
|
+
// edge.local Host that hub itself saw.
|
|
1589
|
+
expect(body.forwardedHost).toBe("original-client.example");
|
|
1590
|
+
expect(body.forwardedProto).toBe("https");
|
|
1591
|
+
} finally {
|
|
1592
|
+
upstream.stop();
|
|
1593
|
+
h.cleanup();
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1412
1597
|
test("proxies a /vault/<name>/* request to the matching upstream", async () => {
|
|
1413
1598
|
const h = makeHarness();
|
|
1414
1599
|
const upstream = startUpstream("default-vault");
|
|
@@ -3033,3 +3218,44 @@ describe("hub-server.ts startup PID/port registration (#148)", () => {
|
|
|
3033
3218
|
}
|
|
3034
3219
|
}, 10_000);
|
|
3035
3220
|
});
|
|
3221
|
+
|
|
3222
|
+
describe("parseArgs — issuer env precedence (hub#365)", () => {
|
|
3223
|
+
test("--issuer flag wins over both env vars", () => {
|
|
3224
|
+
const got = parseArgs(["--issuer", "https://flag.example"], {
|
|
3225
|
+
PARACHUTE_HUB_ORIGIN: "https://env.example",
|
|
3226
|
+
RENDER_EXTERNAL_URL: "https://render.example",
|
|
3227
|
+
});
|
|
3228
|
+
expect(got.issuer).toBe("https://flag.example");
|
|
3229
|
+
});
|
|
3230
|
+
|
|
3231
|
+
test("PARACHUTE_HUB_ORIGIN wins over RENDER_EXTERNAL_URL", () => {
|
|
3232
|
+
const got = parseArgs([], {
|
|
3233
|
+
PARACHUTE_HUB_ORIGIN: "https://hub.example",
|
|
3234
|
+
RENDER_EXTERNAL_URL: "https://render.example",
|
|
3235
|
+
});
|
|
3236
|
+
expect(got.issuer).toBe("https://hub.example");
|
|
3237
|
+
});
|
|
3238
|
+
|
|
3239
|
+
test("RENDER_EXTERNAL_URL is used when no override (standalone Render boot)", () => {
|
|
3240
|
+
const got = parseArgs([], { RENDER_EXTERNAL_URL: "https://app.onrender.com" });
|
|
3241
|
+
expect(got.issuer).toBe("https://app.onrender.com");
|
|
3242
|
+
});
|
|
3243
|
+
|
|
3244
|
+
test("trailing slashes are stripped from all three sources", () => {
|
|
3245
|
+
expect(parseArgs(["--issuer", "https://x.example/"], {}).issuer).toBe("https://x.example");
|
|
3246
|
+
expect(parseArgs([], { PARACHUTE_HUB_ORIGIN: "https://x.example///" }).issuer).toBe(
|
|
3247
|
+
"https://x.example",
|
|
3248
|
+
);
|
|
3249
|
+
expect(parseArgs([], { RENDER_EXTERNAL_URL: "https://x.example/" }).issuer).toBe(
|
|
3250
|
+
"https://x.example",
|
|
3251
|
+
);
|
|
3252
|
+
});
|
|
3253
|
+
|
|
3254
|
+
test("issuer is undefined when no source is set", () => {
|
|
3255
|
+
expect(parseArgs([], {}).issuer).toBeUndefined();
|
|
3256
|
+
expect(parseArgs([], { PARACHUTE_HUB_ORIGIN: "" }).issuer).toBeUndefined();
|
|
3257
|
+
expect(parseArgs([], { RENDER_EXTERNAL_URL: "" }).issuer).toBeUndefined();
|
|
3258
|
+
// Bare slash collapses to empty after strip — must not become "" issuer.
|
|
3259
|
+
expect(parseArgs([], { RENDER_EXTERNAL_URL: "/" }).issuer).toBeUndefined();
|
|
3260
|
+
});
|
|
3261
|
+
});
|
|
@@ -342,7 +342,10 @@ describe("parachute start", () => {
|
|
|
342
342
|
log: () => {},
|
|
343
343
|
});
|
|
344
344
|
expect(code).toBe(0);
|
|
345
|
+
// PORT is always set by `parachute start` (hub#356) from the
|
|
346
|
+
// services.json entry. PARACHUTE_HUB_ORIGIN comes from expose-state.
|
|
345
347
|
expect(spawner.calls[0]?.env).toEqual({
|
|
348
|
+
PORT: "1940",
|
|
346
349
|
PARACHUTE_HUB_ORIGIN: "https://parachute.taildf9ce2.ts.net",
|
|
347
350
|
});
|
|
348
351
|
} finally {
|
|
@@ -364,6 +367,7 @@ describe("parachute start", () => {
|
|
|
364
367
|
});
|
|
365
368
|
expect(code).toBe(0);
|
|
366
369
|
expect(spawner.calls[0]?.env).toEqual({
|
|
370
|
+
PORT: "1940",
|
|
367
371
|
PARACHUTE_HUB_ORIGIN: "http://127.0.0.1:1939",
|
|
368
372
|
});
|
|
369
373
|
} finally {
|
|
@@ -398,6 +402,7 @@ describe("parachute start", () => {
|
|
|
398
402
|
});
|
|
399
403
|
expect(code).toBe(0);
|
|
400
404
|
expect(spawner.calls[0]?.env).toEqual({
|
|
405
|
+
PORT: "1940",
|
|
401
406
|
PARACHUTE_HUB_ORIGIN: "https://override.example.com",
|
|
402
407
|
});
|
|
403
408
|
} finally {
|
|
@@ -417,7 +422,11 @@ describe("parachute start", () => {
|
|
|
417
422
|
log: () => {},
|
|
418
423
|
});
|
|
419
424
|
expect(code).toBe(0);
|
|
420
|
-
|
|
425
|
+
// PORT is always set (hub#356) — even with no override, no exposure,
|
|
426
|
+
// and no hub.port file, the spawn env carries the canonical PORT
|
|
427
|
+
// from services.json. Test renamed from "omits env" to reflect
|
|
428
|
+
// the new minimum-env shape.
|
|
429
|
+
expect(spawner.calls[0]?.env).toEqual({ PORT: "1940" });
|
|
421
430
|
} finally {
|
|
422
431
|
h.cleanup();
|
|
423
432
|
}
|
|
@@ -453,6 +462,7 @@ describe("parachute start", () => {
|
|
|
453
462
|
});
|
|
454
463
|
expect(code).toBe(0);
|
|
455
464
|
expect(spawner.calls[0]?.env).toEqual({
|
|
465
|
+
PORT: "1943",
|
|
456
466
|
GROQ_API_KEY: "gsk_real_value",
|
|
457
467
|
QUOTED: "quoted_val",
|
|
458
468
|
});
|
|
@@ -483,6 +493,7 @@ describe("parachute start", () => {
|
|
|
483
493
|
});
|
|
484
494
|
expect(code).toBe(0);
|
|
485
495
|
expect(spawner.calls[0]?.env).toEqual({
|
|
496
|
+
PORT: "1940",
|
|
486
497
|
SCRIBE_AUTH_TOKEN: "secret",
|
|
487
498
|
PARACHUTE_HUB_ORIGIN: "https://live.example.com",
|
|
488
499
|
});
|
|
@@ -121,6 +121,27 @@ describe("bootSupervisedModules", () => {
|
|
|
121
121
|
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test("sets PORT in child env from services.json entry (hub#357)", async () => {
|
|
125
|
+
// Container deploys (Render etc.) set PORT in hub's process.env via
|
|
126
|
+
// Dockerfile / platform injection. The supervisor's defaultSpawnFn
|
|
127
|
+
// passes `env: process.env` so children inherit hub's PORT and try
|
|
128
|
+
// to bind hub's own port → EADDRINUSE crashloop. This boot path
|
|
129
|
+
// (called on hub startup to re-spawn supervised modules from
|
|
130
|
+
// services.json) was missed by hub#356 which only fixed the
|
|
131
|
+
// install-time + lifecycle paths. Third spawn site, same fix shape.
|
|
132
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
133
|
+
const recorder = makeRecorder();
|
|
134
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
135
|
+
|
|
136
|
+
await bootSupervisedModules(sup, {
|
|
137
|
+
manifestPath: h.manifestPath,
|
|
138
|
+
configDir: h.dir,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// VAULT_ENTRY's port = 1940 (vault's canonical).
|
|
142
|
+
expect(recorder.calls[0]?.env?.PORT).toBe("1940");
|
|
143
|
+
});
|
|
144
|
+
|
|
124
145
|
test("merges per-module .env file into child env", async () => {
|
|
125
146
|
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
126
147
|
// Write a per-module .env at <configDir>/<short>/.env — what the
|
|
@@ -6,6 +6,7 @@ import { _resetBootstrapTokenForTests, getBootstrapToken } from "../bootstrap-to
|
|
|
6
6
|
import {
|
|
7
7
|
formatBootstrapTokenBanner,
|
|
8
8
|
formatListeningBanner,
|
|
9
|
+
resolveStartupIssuer,
|
|
9
10
|
seedInitialAdminIfNeeded,
|
|
10
11
|
} from "../commands/serve.ts";
|
|
11
12
|
import { openHubDb } from "../hub-db.ts";
|
|
@@ -245,3 +246,66 @@ describe("bootstrap-token wiring under needs-setup", () => {
|
|
|
245
246
|
expect(getBootstrapToken()).toBeUndefined();
|
|
246
247
|
});
|
|
247
248
|
});
|
|
249
|
+
|
|
250
|
+
describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
251
|
+
test("explicit opts.issuer wins over everything", () => {
|
|
252
|
+
const got = resolveStartupIssuer(
|
|
253
|
+
{ issuer: "https://override.example" },
|
|
254
|
+
{
|
|
255
|
+
PARACHUTE_HUB_ORIGIN: "https://env.example",
|
|
256
|
+
RENDER_EXTERNAL_URL: "https://render.example.onrender.com",
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
expect(got).toBe("https://override.example");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("PARACHUTE_HUB_ORIGIN wins over RENDER_EXTERNAL_URL", () => {
|
|
263
|
+
const got = resolveStartupIssuer(
|
|
264
|
+
{},
|
|
265
|
+
{
|
|
266
|
+
PARACHUTE_HUB_ORIGIN: "https://custom-domain.example",
|
|
267
|
+
RENDER_EXTERNAL_URL: "https://parachute-hub.onrender.com",
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
expect(got).toBe("https://custom-domain.example");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("RENDER_EXTERNAL_URL is used when PARACHUTE_HUB_ORIGIN unset", () => {
|
|
274
|
+
// The load-bearing case: operator clicks Deploy to Render, container
|
|
275
|
+
// boots without an explicit PARACHUTE_HUB_ORIGIN (it doesn't yet
|
|
276
|
+
// appear in render.yaml as a default), Render injects RENDER_EXTERNAL_URL
|
|
277
|
+
// → hub picks it up automatically → supervised modules get the right
|
|
278
|
+
// PARACHUTE_HUB_ORIGIN → vault's iss check passes on the first
|
|
279
|
+
// authenticated request.
|
|
280
|
+
const got = resolveStartupIssuer(
|
|
281
|
+
{},
|
|
282
|
+
{ RENDER_EXTERNAL_URL: "https://parachute-hub.onrender.com" },
|
|
283
|
+
);
|
|
284
|
+
expect(got).toBe("https://parachute-hub.onrender.com");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("strips trailing slashes from any source for canonical form", () => {
|
|
288
|
+
expect(resolveStartupIssuer({ issuer: "https://x.example/" }, {})).toBe("https://x.example");
|
|
289
|
+
expect(resolveStartupIssuer({ issuer: "https://x.example//" }, {})).toBe("https://x.example");
|
|
290
|
+
expect(
|
|
291
|
+
resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "https://x.example/" }),
|
|
292
|
+
).toBe("https://x.example");
|
|
293
|
+
expect(
|
|
294
|
+
resolveStartupIssuer({}, { RENDER_EXTERNAL_URL: "https://x.example/" }),
|
|
295
|
+
).toBe("https://x.example");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("returns undefined when no source has a value", () => {
|
|
299
|
+
expect(resolveStartupIssuer({}, {})).toBeUndefined();
|
|
300
|
+
expect(resolveStartupIssuer({}, { RENDER_EXTERNAL_URL: "" })).toBeUndefined();
|
|
301
|
+
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "" })).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("empty string after slash-strip collapses to undefined (defensive)", () => {
|
|
305
|
+
// `/` alone strips to empty string → `||` evaluates false → undefined.
|
|
306
|
+
// Guards against a misconfigured env where someone sets the var to "/"
|
|
307
|
+
// expecting it to mean "root" (it doesn't — leaves hub without a usable
|
|
308
|
+
// origin, which is the same as not setting it at all).
|
|
309
|
+
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "/" })).toBeUndefined();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -2143,6 +2143,10 @@ describe("typed vault name (hub#267)", () => {
|
|
|
2143
2143
|
const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
|
|
2144
2144
|
expect(vaultSpawn).toBeDefined();
|
|
2145
2145
|
expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBe("smoke-1940");
|
|
2146
|
+
// PORT also injected (hub#356) — supervisor always sets it from the
|
|
2147
|
+
// services.json entry regardless of whether the typed-name path
|
|
2148
|
+
// contributed any additional env vars.
|
|
2149
|
+
expect(vaultSpawn?.env?.PORT).toBe("1940");
|
|
2146
2150
|
} finally {
|
|
2147
2151
|
db.close();
|
|
2148
2152
|
}
|
|
@@ -2254,10 +2258,13 @@ describe("typed vault name (hub#267)", () => {
|
|
|
2254
2258
|
await new Promise((r) => setTimeout(r, 50));
|
|
2255
2259
|
const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
|
|
2256
2260
|
expect(vaultSpawn).toBeDefined();
|
|
2257
|
-
// No
|
|
2261
|
+
// No PARACHUTE_VAULT_NAME override on the default-name path (vault's
|
|
2258
2262
|
// resolveFirstBootVaultName already defaults to "default" when the
|
|
2259
|
-
// env var is absent
|
|
2260
|
-
|
|
2263
|
+
// env var is absent). PORT is set by the supervisor (hub#356) for
|
|
2264
|
+
// every supervised child regardless — assert the empty-name path
|
|
2265
|
+
// doesn't add PARACHUTE_VAULT_NAME.
|
|
2266
|
+
expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBeUndefined();
|
|
2267
|
+
expect(vaultSpawn?.env?.PORT).toBe("1940");
|
|
2261
2268
|
} finally {
|
|
2262
2269
|
db.close();
|
|
2263
2270
|
}
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -401,11 +401,35 @@ async function spawnSupervised(
|
|
|
401
401
|
if (!entry) return undefined;
|
|
402
402
|
const cmd = spec.startCmd?.(entry);
|
|
403
403
|
if (!cmd || cmd.length === 0) return undefined;
|
|
404
|
+
// PORT override (hub#356): in container deploys, hub binds its own port
|
|
405
|
+
// via the PORT env var (Render sets PORT=$PORT, Dockerfile defaults to
|
|
406
|
+
// 1939). Bun.spawn's `env: process.env` propagates that PORT to every
|
|
407
|
+
// supervised child — so vault (which reads `process.env.PORT` in
|
|
408
|
+
// server.ts:230) tries to bind hub's port and crashes EADDRINUSE.
|
|
409
|
+
// Explicitly override with the child's services.json port so children
|
|
410
|
+
// honor their canonical port assignment regardless of hub's PORT.
|
|
411
|
+
//
|
|
412
|
+
// PARACHUTE_HUB_ORIGIN propagation (hub#365): supervised modules
|
|
413
|
+
// (vault, scribe, app) need to know the canonical hub origin to
|
|
414
|
+
// validate the `iss` claim on hub-minted JWTs. Without it, they
|
|
415
|
+
// fall back to a loopback default and reject any token whose iss is
|
|
416
|
+
// the public Render URL — surfaces as "hub JWT verification failed:
|
|
417
|
+
// unexpected 'iss' claim value" on the first authed vault call.
|
|
418
|
+
// `deps.issuer` is per-request, derived via resolveIssuer (which
|
|
419
|
+
// honors X-Forwarded-Proto / Host). Passing it as PARACHUTE_HUB_ORIGIN
|
|
420
|
+
// anchors the child's iss expectation to the same value hub mints with.
|
|
421
|
+
//
|
|
422
|
+
// `deps.spawnEnv` still wins (test seam + first-boot vault-name pass-through).
|
|
423
|
+
const childEnv: Record<string, string> = {
|
|
424
|
+
PORT: String(entry.port),
|
|
425
|
+
...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
|
|
426
|
+
...(deps.spawnEnv ?? {}),
|
|
427
|
+
};
|
|
404
428
|
const req: SpawnRequest = {
|
|
405
429
|
short,
|
|
406
430
|
cmd,
|
|
407
431
|
...(entry.installDir ? { cwd: entry.installDir } : {}),
|
|
408
|
-
|
|
432
|
+
env: childEnv,
|
|
409
433
|
};
|
|
410
434
|
return deps.supervisor.start(req);
|
|
411
435
|
}
|
|
@@ -436,7 +436,13 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
|
|
|
436
436
|
// wrapper for launchd / systemd) — this is idempotent there. Hub-origin
|
|
437
437
|
// override wins on collision; that's the live-exposure source of truth.
|
|
438
438
|
const fileEnv = readEnvFileValues(join(r.configDir, short, ".env"));
|
|
439
|
-
|
|
439
|
+
// PORT override (hub#356): same shape as `spawnSupervised` in
|
|
440
|
+
// api-modules-ops.ts. Without this, operators running `parachute start
|
|
441
|
+
// vault` inside a container that has PORT in env (Render / Fly / etc.)
|
|
442
|
+
// hit EADDRINUSE on hub's port. Local dev typically doesn't set PORT, so
|
|
443
|
+
// this is a no-op there. fileEnv wins on collision so per-service .env
|
|
444
|
+
// can still override if an operator deliberately set PORT in there.
|
|
445
|
+
const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
|
|
440
446
|
if (r.hubOrigin) env[HUB_ORIGIN_ENV] = r.hubOrigin;
|
|
441
447
|
const spawnerOpts: { env?: Record<string, string>; cwd?: string } = {};
|
|
442
448
|
if (Object.keys(env).length > 0) spawnerOpts.env = env;
|
|
@@ -89,11 +89,15 @@ export async function bootSupervisedModules(
|
|
|
89
89
|
continue;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
92
|
+
// PORT override (hub#357 — third spawn site missed by hub#356).
|
|
93
|
+
// Without this, modules that read process.env.PORT (vault, scribe)
|
|
94
|
+
// inherit hub's PORT from Bun.spawn's env: process.env default and
|
|
95
|
+
// crash EADDRINUSE on hub's port. Container deploy was still broken
|
|
96
|
+
// after #356 because this BOOT path runs on hub startup before the
|
|
97
|
+
// supervisor's other spawn paths see any traffic. fileEnv wins on
|
|
98
|
+
// collision so per-service .env can still override.
|
|
95
99
|
const fileEnv = readEnvFileValues(join(opts.configDir, short, ".env"));
|
|
96
|
-
const env: Record<string, string> = { ...fileEnv };
|
|
100
|
+
const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
|
|
97
101
|
if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
|
|
98
102
|
|
|
99
103
|
const req: {
|
package/src/commands/serve.ts
CHANGED
|
@@ -110,6 +110,41 @@ function parsePort(raw: string | undefined): number | undefined {
|
|
|
110
110
|
return n;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Derive the canonical issuer URL hub uses for JWT iss claims + propagation
|
|
115
|
+
* to supervised modules' PARACHUTE_HUB_ORIGIN env.
|
|
116
|
+
*
|
|
117
|
+
* Precedence (highest first):
|
|
118
|
+
* 1. Explicit `--issuer` flag (test override too)
|
|
119
|
+
* 2. `PARACHUTE_HUB_ORIGIN` env (operator-set, typical custom-domain case)
|
|
120
|
+
* 3. Platform-injected public URL — currently Render's RENDER_EXTERNAL_URL.
|
|
121
|
+
* This is the load-bearing tier for container deploys where the
|
|
122
|
+
* operator can't know the URL at deploy time. Render generates the
|
|
123
|
+
* *.onrender.com hostname after service creation and injects it for
|
|
124
|
+
* web services; hub picks it up automatically.
|
|
125
|
+
* 4. None (returns undefined). Hub falls back to per-request derivation
|
|
126
|
+
* via `resolveIssuer` in hub-server.ts — works for `/.well-known`
|
|
127
|
+
* discovery but supervised modules with cached iss expectations
|
|
128
|
+
* won't have a static value to validate against, so OAuth flows
|
|
129
|
+
* through hub-mint → vault-validate will fail with iss-mismatch.
|
|
130
|
+
* This is the "no canonical origin known" degraded mode.
|
|
131
|
+
*
|
|
132
|
+
* Future platforms (Fly's `FLY_APP_NAME` + region, Railway's
|
|
133
|
+
* `RAILWAY_PUBLIC_DOMAIN`, etc.) can extend tier 3 as needed.
|
|
134
|
+
*
|
|
135
|
+
* Trailing slashes are stripped for canonical-form comparison; empty
|
|
136
|
+
* strings collapse to undefined.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveStartupIssuer(
|
|
139
|
+
opts: { issuer?: string },
|
|
140
|
+
env: NodeJS.ProcessEnv,
|
|
141
|
+
): string | undefined {
|
|
142
|
+
return (
|
|
143
|
+
(opts.issuer ?? env.PARACHUTE_HUB_ORIGIN ?? env.RENDER_EXTERNAL_URL)?.replace(/\/+$/, "") ||
|
|
144
|
+
undefined
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
113
148
|
/**
|
|
114
149
|
* Seed the initial admin from env vars when no admin exists. Returns the
|
|
115
150
|
* bootstrap state so the caller can log it for operator visibility.
|
|
@@ -189,7 +224,7 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
189
224
|
|
|
190
225
|
const envPort = parsePort(env.PORT);
|
|
191
226
|
const port = opts.port ?? envPort ?? DEFAULT_PORT;
|
|
192
|
-
const issuer = (opts
|
|
227
|
+
const issuer = resolveStartupIssuer(opts, env);
|
|
193
228
|
// Containers default to 0.0.0.0 so the platform's HTTP forwarder can
|
|
194
229
|
// reach us; the `--hostname` flag / PARACHUTE_BIND_HOST is the escape
|
|
195
230
|
// hatch for setups that want loopback-only inside a sidecar.
|
package/src/hub-server.ts
CHANGED
|
@@ -213,8 +213,14 @@ interface Args {
|
|
|
213
213
|
* Containers should set 0.0.0.0.
|
|
214
214
|
* PARACHUTE_HUB_ORIGIN — canonical https://… origin used as the
|
|
215
215
|
* OAuth issuer claim.
|
|
216
|
+
* RENDER_EXTERNAL_URL — Render auto-injects the public https URL;
|
|
217
|
+
* used as fallback issuer so the standalone
|
|
218
|
+
* `bun src/hub-server.ts` boot path works
|
|
219
|
+
* without operator config. Mirrors the
|
|
220
|
+
* precedence in commands/serve.ts's
|
|
221
|
+
* resolveStartupIssuer.
|
|
216
222
|
*/
|
|
217
|
-
function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): Args {
|
|
223
|
+
export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): Args {
|
|
218
224
|
let port: number | undefined;
|
|
219
225
|
let hostname: string | undefined;
|
|
220
226
|
let wellKnownDir: string | undefined;
|
|
@@ -250,8 +256,9 @@ function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): Args {
|
|
|
250
256
|
if (port === undefined) port = HUB_DEFAULT_PORT;
|
|
251
257
|
if (hostname === undefined) hostname = env.PARACHUTE_BIND_HOST || "127.0.0.1";
|
|
252
258
|
if (wellKnownDir === undefined) wellKnownDir = WELL_KNOWN_DIR;
|
|
253
|
-
if (issuer === undefined
|
|
254
|
-
|
|
259
|
+
if (issuer === undefined) {
|
|
260
|
+
const fromEnv = env.PARACHUTE_HUB_ORIGIN ?? env.RENDER_EXTERNAL_URL;
|
|
261
|
+
if (fromEnv) issuer = fromEnv.replace(/\/+$/, "") || undefined;
|
|
255
262
|
}
|
|
256
263
|
return { port, hostname, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
|
|
257
264
|
}
|
|
@@ -418,9 +425,28 @@ async function proxyRequest(
|
|
|
418
425
|
const path = targetPath ?? url.pathname;
|
|
419
426
|
const upstream = `http://127.0.0.1:${port}${path}${url.search}`;
|
|
420
427
|
const headers = new Headers(req.headers);
|
|
428
|
+
// Capture the public hostname BEFORE deleting Host so we can forward it
|
|
429
|
+
// as X-Forwarded-Host (hub#358). Without this, supervised modules like
|
|
430
|
+
// vault see no X-Forwarded-Host header and fall back to their internal
|
|
431
|
+
// loopback URL when constructing OAuth metadata — publishing
|
|
432
|
+
// `http://127.0.0.1:1940/...` as the issuer instead of the public origin.
|
|
433
|
+
const publicHost = req.headers.get("host");
|
|
421
434
|
// Host comes from the requester (tailnet FQDN); the loopback target wants
|
|
422
435
|
// its own. Bun's fetch fills it in when omitted.
|
|
423
436
|
headers.delete("host");
|
|
437
|
+
// Forward the public origin so downstream services build their public-
|
|
438
|
+
// facing URLs (OAuth metadata, redirect URIs) against the same host the
|
|
439
|
+
// client used. We DON'T overwrite X-Forwarded-Host if already set —
|
|
440
|
+
// some platforms (Render) set it at the edge.
|
|
441
|
+
if (publicHost && !headers.has("x-forwarded-host")) {
|
|
442
|
+
headers.set("x-forwarded-host", publicHost);
|
|
443
|
+
}
|
|
444
|
+
// Same for protocol — if the edge set X-Forwarded-Proto we preserve it,
|
|
445
|
+
// otherwise we set it from isHttpsRequest's signal (direct HTTPS to hub
|
|
446
|
+
// is unusual on Render, but covers local TLS-terminating proxies too).
|
|
447
|
+
if (!headers.has("x-forwarded-proto")) {
|
|
448
|
+
headers.set("x-forwarded-proto", isHttpsRequest(req) ? "https" : "http");
|
|
449
|
+
}
|
|
424
450
|
|
|
425
451
|
const init: RequestInit & { duplex?: "half" } = {
|
|
426
452
|
method: req.method,
|
|
@@ -953,11 +979,16 @@ export function resolveIssuer(
|
|
|
953
979
|
// The `isHttpsRequest` helper is the canonical place where this trust
|
|
954
980
|
// is established (also used for the Secure cookie attribute).
|
|
955
981
|
//
|
|
956
|
-
// We do NOT honor X-Forwarded-Host
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
982
|
+
// We do NOT honor X-Forwarded-Host *for hub's own issuer derivation*.
|
|
983
|
+
// (Note: hub DOES forward X-Forwarded-Host to upstream supervised
|
|
984
|
+
// modules in `proxyRequest` — that's a separate concern, see #358.
|
|
985
|
+
// Here we're deriving hub's own canonical origin from the incoming
|
|
986
|
+
// request, not what to tell downstream services.) Render, Tailscale
|
|
987
|
+
// Funnel, and cloudflared all preserve the Host header end-to-end,
|
|
988
|
+
// so `req.url`'s host already reflects the public hostname.
|
|
989
|
+
// Operators on a proxy that rewrites Host (some nginx / Caddy
|
|
990
|
+
// configs) should set hub_origin via the admin SPA — that path
|
|
991
|
+
// bypasses this fallback entirely.
|
|
961
992
|
const url = new URL(req.url);
|
|
962
993
|
if (isHttpsRequest(req)) {
|
|
963
994
|
url.protocol = "https:";
|