@openparachute/hub 0.5.13-rc.29 → 0.5.13-rc.34

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.29",
3
+ "version": "0.5.13-rc.34",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -1005,6 +1005,44 @@ 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
+
1008
1046
  test("runInstall failure: bun add fails -> no well-known regen (no partial state)", async () => {
1009
1047
  const { supervisor } = makeIdleSupervisor();
1010
1048
  const wkPath = join(h.dir, "well-known.json");
@@ -80,13 +80,24 @@ describe("hubFetch routing", () => {
80
80
  // hub#259 rc.6: requires an admin row to bypass the fresh-hub
81
81
  // funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
82
82
  // test continues to exercise the signed-out-but-setup-done branch.
83
+ //
84
+ // Hub also funnels to /admin/setup when no vault is installed
85
+ // (env-seed case). Pass an explicit manifestPath + seed a vault row
86
+ // so the funnel sees an installed vault and lets the discovery page
87
+ // render. Without this, the manifestPath default falls back to
88
+ // `~/.parachute/services.json` — which works on a dev box with an
89
+ // installed vault but fails in CI with a 302 wizard redirect.
83
90
  const h = makeHarness();
84
91
  try {
92
+ writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
85
93
  const db = openHubDb(hubDbPath(h.dir));
86
94
  try {
87
95
  const { createUser } = await import("../users.ts");
88
96
  await createUser(db, "owner", "pw");
89
- const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
97
+ const res = await hubFetch(h.dir, {
98
+ getDb: () => db,
99
+ manifestPath: h.manifestPath,
100
+ })(req("/"));
90
101
  expect(res.status).toBe(200);
91
102
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
92
103
  const body = await res.text();
@@ -102,8 +113,12 @@ describe("hubFetch routing", () => {
102
113
  });
103
114
 
104
115
  test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
116
+ // Same wizard-funnel bypass as the signed-out test above — seed a
117
+ // vault row and pass an explicit manifestPath so CI doesn't fall back
118
+ // to ~/.parachute/services.json.
105
119
  const h = makeHarness();
106
120
  try {
121
+ writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
107
122
  const db = openHubDb(hubDbPath(h.dir));
108
123
  try {
109
124
  const { createUser } = await import("../users.ts");
@@ -113,7 +128,10 @@ describe("hubFetch routing", () => {
113
128
  const user = await createUser(db, "aaron", "pw");
114
129
  const session = createSession(db, { userId: user.id });
115
130
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
116
- const res = await hubFetch(h.dir, { getDb: () => db })(req("/", { headers: { cookie } }));
131
+ const res = await hubFetch(h.dir, {
132
+ getDb: () => db,
133
+ manifestPath: h.manifestPath,
134
+ })(req("/", { headers: { cookie } }));
117
135
  expect(res.status).toBe(200);
118
136
  const body = await res.text();
119
137
  expect(body).toContain("Signed in as");
@@ -1390,7 +1408,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1390
1408
  fetch: async (req) => {
1391
1409
  const u = new URL(req.url);
1392
1410
  // Echo enough metadata for tests to verify path + method + body
1393
- // arrive intact end-to-end.
1411
+ // arrive intact end-to-end. Also echo X-Forwarded-* headers so
1412
+ // tests can assert hub forwards proxy hints (hub#358).
1394
1413
  const body = req.body ? await req.text() : "";
1395
1414
  return new Response(
1396
1415
  JSON.stringify({
@@ -1399,6 +1418,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1399
1418
  pathname: u.pathname,
1400
1419
  search: u.search,
1401
1420
  body,
1421
+ forwardedHost: req.headers.get("x-forwarded-host"),
1422
+ forwardedProto: req.headers.get("x-forwarded-proto"),
1402
1423
  }),
1403
1424
  { status: 200, headers: { "content-type": "application/json" } },
1404
1425
  );
@@ -1409,6 +1430,164 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1409
1430
  return { port: server.port as number, stop: () => server.stop(true) };
1410
1431
  }
1411
1432
 
1433
+ test("forwards X-Forwarded-Host + X-Forwarded-Proto to upstream (hub#358)", async () => {
1434
+ // Container deploys: supervised modules (vault, scribe, app) reverse-
1435
+ // proxy through hub. They need the public origin to construct OAuth
1436
+ // discovery metadata and redirect URIs. Without forwarded headers,
1437
+ // they fall back to their internal loopback URL — breaking OAuth
1438
+ // flows for clients that landed on a hub-proxied URL.
1439
+ const h = makeHarness();
1440
+ const upstream = startUpstream("hdr-test");
1441
+ try {
1442
+ writeManifest(
1443
+ {
1444
+ services: [
1445
+ {
1446
+ name: "parachute-vault",
1447
+ port: upstream.port,
1448
+ paths: ["/vault/default"],
1449
+ health: "/vault/default/health",
1450
+ version: "0.4.0",
1451
+ },
1452
+ ],
1453
+ },
1454
+ h.manifestPath,
1455
+ );
1456
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1457
+ // Simulate Render-shape: page came in over HTTPS at parachute-hub.onrender.com
1458
+ // Render terminates TLS and sets X-Forwarded-Proto: https; the Host
1459
+ // header is preserved as the public hostname.
1460
+ const incoming = new Request("http://parachute-hub.onrender.com/vault/default/.well-known/oauth-authorization-server", {
1461
+ headers: {
1462
+ host: "parachute-hub.onrender.com",
1463
+ "x-forwarded-proto": "https",
1464
+ },
1465
+ });
1466
+ const res = await fetcher(incoming);
1467
+ expect(res.status).toBe(200);
1468
+ const body = (await res.json()) as { forwardedHost: string; forwardedProto: string };
1469
+ // Hub captured the public Host and forwarded it as X-Forwarded-Host.
1470
+ expect(body.forwardedHost).toBe("parachute-hub.onrender.com");
1471
+ // X-Forwarded-Proto preserved (edge already set it).
1472
+ expect(body.forwardedProto).toBe("https");
1473
+ } finally {
1474
+ upstream.stop();
1475
+ h.cleanup();
1476
+ }
1477
+ });
1478
+
1479
+ test("synthesizes X-Forwarded-Proto when edge didn't set it (direct HTTPS to hub)", async () => {
1480
+ // Non-Render shape: hub bound directly to https (e.g. local TLS or a
1481
+ // proxy that doesn't set X-Forwarded-Proto). isHttpsRequest sees the
1482
+ // URL's https scheme; proxyRequest synthesizes X-Forwarded-Proto.
1483
+ const h = makeHarness();
1484
+ const upstream = startUpstream("hdr-test");
1485
+ try {
1486
+ writeManifest(
1487
+ {
1488
+ services: [
1489
+ {
1490
+ name: "parachute-vault",
1491
+ port: upstream.port,
1492
+ paths: ["/vault/default"],
1493
+ health: "/vault/default/health",
1494
+ version: "0.4.0",
1495
+ },
1496
+ ],
1497
+ },
1498
+ h.manifestPath,
1499
+ );
1500
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1501
+ const incoming = new Request("https://hub.example.com/vault/default/health", {
1502
+ headers: { host: "hub.example.com" },
1503
+ });
1504
+ const res = await fetcher(incoming);
1505
+ const body = (await res.json()) as { forwardedHost: string; forwardedProto: string };
1506
+ expect(body.forwardedHost).toBe("hub.example.com");
1507
+ expect(body.forwardedProto).toBe("https");
1508
+ } finally {
1509
+ upstream.stop();
1510
+ h.cleanup();
1511
+ }
1512
+ });
1513
+
1514
+ test("synthesizes X-Forwarded-Proto=http when neither edge nor URL signal HTTPS", async () => {
1515
+ // Plain-HTTP path: no edge, no https URL scheme. isHttpsRequest returns
1516
+ // false; proxyRequest synthesizes "http". This is local-dev shape.
1517
+ const h = makeHarness();
1518
+ const upstream = startUpstream("hdr-test");
1519
+ try {
1520
+ writeManifest(
1521
+ {
1522
+ services: [
1523
+ {
1524
+ name: "parachute-vault",
1525
+ port: upstream.port,
1526
+ paths: ["/vault/default"],
1527
+ health: "/vault/default/health",
1528
+ version: "0.4.0",
1529
+ },
1530
+ ],
1531
+ },
1532
+ h.manifestPath,
1533
+ );
1534
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1535
+ const incoming = new Request("http://127.0.0.1:1939/vault/default/health", {
1536
+ headers: { host: "127.0.0.1:1939" },
1537
+ });
1538
+ const res = await fetcher(incoming);
1539
+ const body = (await res.json()) as { forwardedProto: string };
1540
+ expect(body.forwardedProto).toBe("http");
1541
+ } finally {
1542
+ upstream.stop();
1543
+ h.cleanup();
1544
+ }
1545
+ });
1546
+
1547
+ test("preserves X-Forwarded-Host when edge already set it (nested-proxy chain)", async () => {
1548
+ // Multi-hop chain: client → edge proxy → hub → upstream. Edge captures
1549
+ // the original host as X-Forwarded-Host before its own host-rewrite;
1550
+ // hub MUST preserve that, not overwrite with its own incoming Host
1551
+ // (which may be the edge's hostname). "Don't clobber" semantics.
1552
+ const h = makeHarness();
1553
+ const upstream = startUpstream("hdr-test");
1554
+ try {
1555
+ writeManifest(
1556
+ {
1557
+ services: [
1558
+ {
1559
+ name: "parachute-vault",
1560
+ port: upstream.port,
1561
+ paths: ["/vault/default"],
1562
+ health: "/vault/default/health",
1563
+ version: "0.4.0",
1564
+ },
1565
+ ],
1566
+ },
1567
+ h.manifestPath,
1568
+ );
1569
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1570
+ // Edge already labeled the canonical hop: original-client.example
1571
+ // (this is what an HTTPS termination proxy in front of hub would set).
1572
+ const incoming = new Request("http://edge.local/vault/default/health", {
1573
+ headers: {
1574
+ host: "edge.local",
1575
+ "x-forwarded-host": "original-client.example",
1576
+ "x-forwarded-proto": "https",
1577
+ },
1578
+ });
1579
+ const res = await fetcher(incoming);
1580
+ const body = (await res.json()) as { forwardedHost: string; forwardedProto: string };
1581
+ // Edge's X-Forwarded-Host preserved verbatim, NOT clobbered by the
1582
+ // edge.local Host that hub itself saw.
1583
+ expect(body.forwardedHost).toBe("original-client.example");
1584
+ expect(body.forwardedProto).toBe("https");
1585
+ } finally {
1586
+ upstream.stop();
1587
+ h.cleanup();
1588
+ }
1589
+ });
1590
+
1412
1591
  test("proxies a /vault/<name>/* request to the matching upstream", async () => {
1413
1592
  const h = makeHarness();
1414
1593
  const upstream = startUpstream("default-vault");
@@ -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
- expect(spawner.calls[0]?.env).toBeUndefined();
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
@@ -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 env override on the default-name path (vault's
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, so the override would be redundant).
2260
- expect(vaultSpawn?.env).toBeUndefined();
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
  }
@@ -401,11 +401,23 @@ 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
+ // `deps.spawnEnv` still wins (test seam + first-boot vault-name pass-through).
412
+ const childEnv: Record<string, string> = {
413
+ PORT: String(entry.port),
414
+ ...(deps.spawnEnv ?? {}),
415
+ };
404
416
  const req: SpawnRequest = {
405
417
  short,
406
418
  cmd,
407
419
  ...(entry.installDir ? { cwd: entry.installDir } : {}),
408
- ...(deps.spawnEnv && Object.keys(deps.spawnEnv).length > 0 ? { env: deps.spawnEnv } : {}),
420
+ env: childEnv,
409
421
  };
410
422
  return deps.supervisor.start(req);
411
423
  }
@@ -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
- const env: Record<string, string> = { ...fileEnv };
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
- // Per-module .env layers under HUB_ORIGIN (hub-origin wins on
93
- // collision it's the canonical issuer source, and a stale .env
94
- // shouldn't override the live `parachute serve` env).
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/hub-server.ts CHANGED
@@ -418,9 +418,28 @@ async function proxyRequest(
418
418
  const path = targetPath ?? url.pathname;
419
419
  const upstream = `http://127.0.0.1:${port}${path}${url.search}`;
420
420
  const headers = new Headers(req.headers);
421
+ // Capture the public hostname BEFORE deleting Host so we can forward it
422
+ // as X-Forwarded-Host (hub#358). Without this, supervised modules like
423
+ // vault see no X-Forwarded-Host header and fall back to their internal
424
+ // loopback URL when constructing OAuth metadata — publishing
425
+ // `http://127.0.0.1:1940/...` as the issuer instead of the public origin.
426
+ const publicHost = req.headers.get("host");
421
427
  // Host comes from the requester (tailnet FQDN); the loopback target wants
422
428
  // its own. Bun's fetch fills it in when omitted.
423
429
  headers.delete("host");
430
+ // Forward the public origin so downstream services build their public-
431
+ // facing URLs (OAuth metadata, redirect URIs) against the same host the
432
+ // client used. We DON'T overwrite X-Forwarded-Host if already set —
433
+ // some platforms (Render) set it at the edge.
434
+ if (publicHost && !headers.has("x-forwarded-host")) {
435
+ headers.set("x-forwarded-host", publicHost);
436
+ }
437
+ // Same for protocol — if the edge set X-Forwarded-Proto we preserve it,
438
+ // otherwise we set it from isHttpsRequest's signal (direct HTTPS to hub
439
+ // is unusual on Render, but covers local TLS-terminating proxies too).
440
+ if (!headers.has("x-forwarded-proto")) {
441
+ headers.set("x-forwarded-proto", isHttpsRequest(req) ? "https" : "http");
442
+ }
424
443
 
425
444
  const init: RequestInit & { duplex?: "half" } = {
426
445
  method: req.method,
@@ -953,11 +972,16 @@ export function resolveIssuer(
953
972
  // The `isHttpsRequest` helper is the canonical place where this trust
954
973
  // is established (also used for the Secure cookie attribute).
955
974
  //
956
- // We do NOT honor X-Forwarded-Host. Render, Tailscale Funnel, and
957
- // cloudflared all preserve the Host header end-to-end, so `req.url`'s
958
- // host already reflects the public hostname. Operators on a proxy that
959
- // rewrites Host (some nginx / Caddy configs) should set hub_origin via
960
- // the admin SPA that path bypasses this fallback entirely.
975
+ // We do NOT honor X-Forwarded-Host *for hub's own issuer derivation*.
976
+ // (Note: hub DOES forward X-Forwarded-Host to upstream supervised
977
+ // modules in `proxyRequest` that's a separate concern, see #358.
978
+ // Here we're deriving hub's own canonical origin from the incoming
979
+ // request, not what to tell downstream services.) Render, Tailscale
980
+ // Funnel, and cloudflared all preserve the Host header end-to-end,
981
+ // so `req.url`'s host already reflects the public hostname.
982
+ // Operators on a proxy that rewrites Host (some nginx / Caddy
983
+ // configs) should set hub_origin via the admin SPA — that path
984
+ // bypasses this fallback entirely.
961
985
  const url = new URL(req.url);
962
986
  if (isHttpsRequest(req)) {
963
987
  url.protocol = "https:";