@openparachute/hub 0.5.13-rc.45 → 0.5.13-rc.46
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
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type UiSubUnit,
|
|
9
9
|
findService,
|
|
10
10
|
readManifest,
|
|
11
|
+
readManifestLenient,
|
|
11
12
|
removeService,
|
|
12
13
|
upsertService,
|
|
13
14
|
writeManifest,
|
|
@@ -1395,3 +1396,95 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1395
1396
|
}
|
|
1396
1397
|
});
|
|
1397
1398
|
});
|
|
1399
|
+
|
|
1400
|
+
describe("readManifestLenient — skips bad entries instead of throwing (hub#406)", () => {
|
|
1401
|
+
test("returns the healthy entries when one row has port=0 (the rc.4 app bug)", () => {
|
|
1402
|
+
// Reproduces what hub saw 2026-05-26: a fresh deploy installed
|
|
1403
|
+
// @openparachute/app@0.2.0-rc.4 which wrote a row with name="app"
|
|
1404
|
+
// (wrong) + port=0 (wrong). Strict readManifest threw on the bad
|
|
1405
|
+
// entry — every request to every service 500'd, not just app.
|
|
1406
|
+
// Lenient reader skips the bad row + keeps routing healthy ones.
|
|
1407
|
+
const { path, cleanup } = makeTempPath();
|
|
1408
|
+
try {
|
|
1409
|
+
writeFileSync(
|
|
1410
|
+
path,
|
|
1411
|
+
JSON.stringify({
|
|
1412
|
+
services: [
|
|
1413
|
+
{ name: "parachute-vault", port: 1940, paths: ["/vault/default"], health: "/vault/default/health", version: "0.4.8-rc.10" },
|
|
1414
|
+
{ name: "parachute-app", port: 1946, paths: ["/app"], health: "/app/healthz", version: "0.2.0-rc.13" },
|
|
1415
|
+
{ name: "app", port: 0, paths: ["/app"], health: "/app/healthz", version: "0.2.0-rc.4" },
|
|
1416
|
+
],
|
|
1417
|
+
}),
|
|
1418
|
+
);
|
|
1419
|
+
const warnings: string[] = [];
|
|
1420
|
+
const log = { warn: (m: string) => warnings.push(m) };
|
|
1421
|
+
const m = readManifestLenient(path, log);
|
|
1422
|
+
const names = m.services.map((s) => s.name).sort();
|
|
1423
|
+
expect(names).toEqual(["parachute-app", "parachute-vault"]);
|
|
1424
|
+
expect(warnings.some((w) => w.includes("port") && w.includes("integer"))).toBe(true);
|
|
1425
|
+
} finally {
|
|
1426
|
+
cleanup();
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
test("returns empty services when the file is malformed JSON, logs the parse error", () => {
|
|
1431
|
+
const { path, cleanup } = makeTempPath();
|
|
1432
|
+
try {
|
|
1433
|
+
writeFileSync(path, "{not valid json");
|
|
1434
|
+
const warnings: string[] = [];
|
|
1435
|
+
const m = readManifestLenient(path, { warn: (msg) => warnings.push(msg) });
|
|
1436
|
+
expect(m.services).toEqual([]);
|
|
1437
|
+
expect(warnings.some((w) => w.includes("failed to parse"))).toBe(true);
|
|
1438
|
+
} finally {
|
|
1439
|
+
cleanup();
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
test("returns empty services when the file is missing", () => {
|
|
1444
|
+
const { path, cleanup } = makeTempPath();
|
|
1445
|
+
try {
|
|
1446
|
+
// path not yet written
|
|
1447
|
+
const m = readManifestLenient(path, { warn: () => {} });
|
|
1448
|
+
expect(m.services).toEqual([]);
|
|
1449
|
+
} finally {
|
|
1450
|
+
cleanup();
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
test("drops duplicate-port entries with a warning instead of throwing", () => {
|
|
1455
|
+
const { path, cleanup } = makeTempPath();
|
|
1456
|
+
try {
|
|
1457
|
+
writeFileSync(
|
|
1458
|
+
path,
|
|
1459
|
+
JSON.stringify({
|
|
1460
|
+
services: [
|
|
1461
|
+
{ name: "first", port: 1940, paths: ["/x"], health: "/x/health", version: "1.0.0" },
|
|
1462
|
+
{ name: "second", port: 1940, paths: ["/y"], health: "/y/health", version: "1.0.0" },
|
|
1463
|
+
],
|
|
1464
|
+
}),
|
|
1465
|
+
);
|
|
1466
|
+
const warnings: string[] = [];
|
|
1467
|
+
const m = readManifestLenient(path, { warn: (msg) => warnings.push(msg) });
|
|
1468
|
+
expect(m.services).toHaveLength(1);
|
|
1469
|
+
expect(m.services[0]?.name).toBe("first");
|
|
1470
|
+
expect(warnings.some((w) => w.includes("duplicate-port"))).toBe(true);
|
|
1471
|
+
} finally {
|
|
1472
|
+
cleanup();
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
test("strict readManifest still throws on the same bad entry (contract preserved)", () => {
|
|
1477
|
+
const { path, cleanup } = makeTempPath();
|
|
1478
|
+
try {
|
|
1479
|
+
writeFileSync(
|
|
1480
|
+
path,
|
|
1481
|
+
JSON.stringify({
|
|
1482
|
+
services: [{ name: "app", port: 0, paths: ["/app"], health: "/app/healthz", version: "0.2.0-rc.4" }],
|
|
1483
|
+
}),
|
|
1484
|
+
);
|
|
1485
|
+
expect(() => readManifest(path)).toThrow(ServicesManifestError);
|
|
1486
|
+
} finally {
|
|
1487
|
+
cleanup();
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
});
|
|
@@ -30,6 +30,7 @@ import { writeManifest } from "../services-manifest.ts";
|
|
|
30
30
|
import { SESSION_COOKIE_NAME } from "../sessions.ts";
|
|
31
31
|
import {
|
|
32
32
|
deriveWizardState,
|
|
33
|
+
detectAutoExposeMode,
|
|
33
34
|
handleSetupAccountPost,
|
|
34
35
|
handleSetupExposePost,
|
|
35
36
|
handleSetupGet,
|
|
@@ -172,6 +173,62 @@ describe("deriveWizardState", () => {
|
|
|
172
173
|
}
|
|
173
174
|
});
|
|
174
175
|
|
|
176
|
+
test("auto-skips expose step when RENDER_EXTERNAL_URL is set (hub#406 follow-up)", async () => {
|
|
177
|
+
// Aaron's UX concern: on Render the "How will this hub be reached?"
|
|
178
|
+
// step asks the operator to pick between localhost / tailnet /
|
|
179
|
+
// public-with-custom-domain — none of which describe the actual
|
|
180
|
+
// setup. The platform owns the public URL via RENDER_EXTERNAL_URL.
|
|
181
|
+
// deriveWizardState now auto-seeds `setup_expose_mode = "public"`
|
|
182
|
+
// when that env var is present, so the wizard skips straight to
|
|
183
|
+
// the done screen instead of surfacing an irrelevant choice.
|
|
184
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
185
|
+
try {
|
|
186
|
+
await createUser(db, "owner", "pw");
|
|
187
|
+
writeManifest(
|
|
188
|
+
{
|
|
189
|
+
services: [
|
|
190
|
+
{
|
|
191
|
+
name: "parachute-vault",
|
|
192
|
+
version: "0.1.0",
|
|
193
|
+
port: 1940,
|
|
194
|
+
paths: ["/vault/default"],
|
|
195
|
+
health: "/health",
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
h.manifestPath,
|
|
200
|
+
);
|
|
201
|
+
// Simulate Render env. detectAutoExposeMode reads RENDER_EXTERNAL_URL.
|
|
202
|
+
const renderEnv = { RENDER_EXTERNAL_URL: "https://parachute-hub.onrender.com" };
|
|
203
|
+
const s = deriveWizardState({ db, manifestPath: h.manifestPath, env: renderEnv });
|
|
204
|
+
expect(s.step).toBe("done");
|
|
205
|
+
expect(s.hasExposeMode).toBe(true);
|
|
206
|
+
} finally {
|
|
207
|
+
db.close();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("does NOT auto-skip expose when RENDER_EXTERNAL_URL is unset (local install path)", async () => {
|
|
212
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
213
|
+
try {
|
|
214
|
+
await createUser(db, "owner", "pw");
|
|
215
|
+
writeManifest(
|
|
216
|
+
{
|
|
217
|
+
services: [
|
|
218
|
+
{ name: "parachute-vault", version: "0.1.0", port: 1940, paths: ["/vault/default"], health: "/health" },
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
h.manifestPath,
|
|
222
|
+
);
|
|
223
|
+
const s = deriveWizardState({ db, manifestPath: h.manifestPath, env: {} });
|
|
224
|
+
// Local install path — the operator still gets to choose
|
|
225
|
+
expect(s.step).toBe("expose");
|
|
226
|
+
expect(s.hasExposeMode).toBe(false);
|
|
227
|
+
} finally {
|
|
228
|
+
db.close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
175
232
|
test("done step once admin + vault + expose mode all exist", async () => {
|
|
176
233
|
const db = openHubDb(hubDbPath(h.dir));
|
|
177
234
|
try {
|
|
@@ -2944,3 +3001,31 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
|
|
|
2944
3001
|
}
|
|
2945
3002
|
});
|
|
2946
3003
|
});
|
|
3004
|
+
|
|
3005
|
+
describe("detectAutoExposeMode — Render env detection edge cases (hub#407 nit)", () => {
|
|
3006
|
+
test("returns 'public' for a real https Render URL", () => {
|
|
3007
|
+
expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "https://parachute-hub.onrender.com" })).toBe("public");
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
test("returns 'public' for an http:// URL (defensive — if Render ever emits one)", () => {
|
|
3011
|
+
expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "http://local.test:1939" })).toBe("public");
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
test("returns undefined when RENDER_EXTERNAL_URL is absent", () => {
|
|
3015
|
+
expect(detectAutoExposeMode({})).toBeUndefined();
|
|
3016
|
+
});
|
|
3017
|
+
|
|
3018
|
+
test("returns undefined when RENDER_EXTERNAL_URL is empty", () => {
|
|
3019
|
+
expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "" })).toBeUndefined();
|
|
3020
|
+
});
|
|
3021
|
+
|
|
3022
|
+
test("returns undefined for a non-http scheme (httpx://, ftp://, etc.)", () => {
|
|
3023
|
+
expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "httpx://foo.example" })).toBeUndefined();
|
|
3024
|
+
expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "ftp://foo.example" })).toBeUndefined();
|
|
3025
|
+
expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "javascript:alert(1)" })).toBeUndefined();
|
|
3026
|
+
});
|
|
3027
|
+
|
|
3028
|
+
test("returns undefined when value is non-string (defensive)", () => {
|
|
3029
|
+
expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: undefined })).toBeUndefined();
|
|
3030
|
+
});
|
|
3031
|
+
});
|
package/src/hub-server.ts
CHANGED
|
@@ -177,7 +177,7 @@ import {
|
|
|
177
177
|
effectivePublicExposure,
|
|
178
178
|
shortNameForManifest,
|
|
179
179
|
} from "./service-spec.ts";
|
|
180
|
-
import { type ServiceEntry, readManifest } from "./services-manifest.ts";
|
|
180
|
+
import { type ServiceEntry, readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
181
181
|
import { findActiveSession } from "./sessions.ts";
|
|
182
182
|
import {
|
|
183
183
|
type SetupWizardDeps,
|
|
@@ -582,16 +582,19 @@ export function findServiceUpstream(
|
|
|
582
582
|
* Returns `undefined` when no service claims the pathname; caller 404s.
|
|
583
583
|
*/
|
|
584
584
|
async function proxyToService(req: Request, manifestPath: string): Promise<Response | undefined> {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
585
|
+
// Lenient read on the hot-path — a single malformed services.json
|
|
586
|
+
// entry (e.g. a module installed at a buggy version that wrote
|
|
587
|
+
// `port: 0`) used to cascade into 500s for every route on this hub
|
|
588
|
+
// because the strict throw bailed BEFORE we could dispatch to the
|
|
589
|
+
// healthy entries. `readManifestLenient` skips + logs bad rows so
|
|
590
|
+
// unrelated services keep working. The strict `readManifest` is
|
|
591
|
+
// still used by write paths + admin surfaces that want errors
|
|
592
|
+
// surfaced immediately. See hub#406.
|
|
593
|
+
//
|
|
594
|
+
// The default `log` is `console`, which under Render's container
|
|
595
|
+
// routing surfaces in the Logs panel — operators see the warning
|
|
596
|
+
// about the skipped entry.
|
|
597
|
+
const services = readManifestLenient(manifestPath).services;
|
|
595
598
|
const url = new URL(req.url);
|
|
596
599
|
const match = findServiceUpstream(services, url.pathname);
|
|
597
600
|
if (!match) return undefined;
|
package/src/services-manifest.ts
CHANGED
|
@@ -405,6 +405,88 @@ function validateManifest(raw: unknown, where: string): ServicesManifest {
|
|
|
405
405
|
return { services: entries };
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Lenient counterpart to `readManifest` — used by hub's hot-path service
|
|
410
|
+
* routing (`proxyToService`). The strict `readManifest` throws when ANY
|
|
411
|
+
* entry fails validation; an entire bad row (e.g. an installed module
|
|
412
|
+
* that wrote `port: 0` to its services.json row before the module's
|
|
413
|
+
* own selfRegister gained validation) takes down ALL routing because
|
|
414
|
+
* the routing call site catches the throw and returns 500.
|
|
415
|
+
*
|
|
416
|
+
* This lenient reader:
|
|
417
|
+
* - parses the file the same way (JSON parse + cleanup passes)
|
|
418
|
+
* - validates each entry independently
|
|
419
|
+
* - skips entries that fail validation, logging a warning per skip
|
|
420
|
+
* - returns the validated remainder
|
|
421
|
+
*
|
|
422
|
+
* The trade-off: strict callers (admin SPA write paths, init flows,
|
|
423
|
+
* tests) keep the throw — they want bugs surfaced immediately. The
|
|
424
|
+
* routing path uses this so a single bad row doesn't cascade into
|
|
425
|
+
* "the whole hub appears broken to users." Operators see the rest
|
|
426
|
+
* of their services keep working + a warning in the logs pointing at
|
|
427
|
+
* the offending entry.
|
|
428
|
+
*
|
|
429
|
+
* Caught 2026-05-26 (hub#406) when @openparachute/app@0.2.0-rc.4 wrote
|
|
430
|
+
* a row with `name: "app"` (instead of `parachute-app`) + `port: 0`
|
|
431
|
+
* (instead of bound port). Hub's routing throw on services.json read
|
|
432
|
+
* meant every request to every service 500'd — not just app — because
|
|
433
|
+
* one row's bad shape took out the whole manifest read.
|
|
434
|
+
*
|
|
435
|
+
* One behavioral difference from strict `readManifest`: this function
|
|
436
|
+
* does NOT write cleanup mutations back to disk. The bad row persists
|
|
437
|
+
* on disk until a write-path call (upsertService, etc.) exercises the
|
|
438
|
+
* strict path. That's intentional — a hot-path read should not mutate
|
|
439
|
+
* state — but worth knowing: a fix upstream (e.g. app@rc.13 overwriting
|
|
440
|
+
* the bad row on its next selfRegister) is what finally clears it.
|
|
441
|
+
*/
|
|
442
|
+
export function readManifestLenient(
|
|
443
|
+
path: string = SERVICES_MANIFEST_PATH,
|
|
444
|
+
log: { warn?: (msg: string) => void } = console,
|
|
445
|
+
): ServicesManifest {
|
|
446
|
+
if (!existsSync(path)) return { services: [] };
|
|
447
|
+
let raw: unknown;
|
|
448
|
+
try {
|
|
449
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
450
|
+
} catch (err) {
|
|
451
|
+
log.warn?.(
|
|
452
|
+
`[services-manifest] failed to parse ${path}: ${err instanceof Error ? err.message : String(err)} — treating as empty`,
|
|
453
|
+
);
|
|
454
|
+
return { services: [] };
|
|
455
|
+
}
|
|
456
|
+
const afterRetired = dropRetiredModuleRows(raw, path);
|
|
457
|
+
const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
|
|
458
|
+
// `typeof null === "object"` in JS, so the `!cleaned.raw` part of this
|
|
459
|
+
// guard is load-bearing for the null case — not a typo or redundancy.
|
|
460
|
+
if (!cleaned.raw || typeof cleaned.raw !== "object") return { services: [] };
|
|
461
|
+
const services = (cleaned.raw as Record<string, unknown>).services;
|
|
462
|
+
if (!Array.isArray(services)) return { services: [] };
|
|
463
|
+
const valid: ServiceEntry[] = [];
|
|
464
|
+
for (let i = 0; i < services.length; i++) {
|
|
465
|
+
try {
|
|
466
|
+
valid.push(validateEntry(services[i], `${path} services[${i}]`));
|
|
467
|
+
} catch (err) {
|
|
468
|
+
log.warn?.(
|
|
469
|
+
`[services-manifest] skipping bad entry: ${err instanceof Error ? err.message : String(err)}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Best-effort duplicate-port detection — log + drop the duplicate
|
|
474
|
+
// rather than throw.
|
|
475
|
+
const seenPorts = new Set<number>();
|
|
476
|
+
const dedup: ServiceEntry[] = [];
|
|
477
|
+
for (const e of valid) {
|
|
478
|
+
if (seenPorts.has(e.port)) {
|
|
479
|
+
log.warn?.(
|
|
480
|
+
`[services-manifest] dropping duplicate-port entry: name=${JSON.stringify(e.name)} port=${e.port}`,
|
|
481
|
+
);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
seenPorts.add(e.port);
|
|
485
|
+
dedup.push(e);
|
|
486
|
+
}
|
|
487
|
+
return { services: dedup };
|
|
488
|
+
}
|
|
489
|
+
|
|
408
490
|
export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesManifest {
|
|
409
491
|
if (!existsSync(path)) return { services: [] };
|
|
410
492
|
let raw: unknown;
|
package/src/setup-wizard.ts
CHANGED
|
@@ -141,12 +141,21 @@ export const FIRST_VAULT_SHORT: CuratedModuleShort = "vault";
|
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
143
|
* Read DB + services.json to decide which step the wizard should render.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
144
|
+
* Idempotent — re-running after partial setup picks up where it left
|
|
145
|
+
* off. Mostly read-only, with one specific write: on Render (or any
|
|
146
|
+
* platform `detectAutoExposeMode` recognizes), the first call auto-
|
|
147
|
+
* seeds `setup_expose_mode = "public"` so the wizard skips the expose
|
|
148
|
+
* step. Subsequent calls find the setting present and are read-only.
|
|
146
149
|
*/
|
|
147
150
|
export function deriveWizardState(deps: {
|
|
148
151
|
db: Database;
|
|
149
152
|
manifestPath: string;
|
|
153
|
+
/**
|
|
154
|
+
* Optional env-override. When undefined, falls through to `process.env`.
|
|
155
|
+
* Used by tests + by handleSetupGet which threads through the full
|
|
156
|
+
* SetupWizardDeps.env.
|
|
157
|
+
*/
|
|
158
|
+
env?: Record<string, string | undefined>;
|
|
150
159
|
}): DerivedWizardState {
|
|
151
160
|
const hasAdmin = userCount(deps.db) > 0;
|
|
152
161
|
// The wizard's first-vault provisioning uses the curated `vault` short,
|
|
@@ -156,7 +165,19 @@ export function deriveWizardState(deps: {
|
|
|
156
165
|
const hasVault = vaultEntry !== undefined;
|
|
157
166
|
// Expose-mode is the operator's "how will this hub be reached?" answer
|
|
158
167
|
// (hub#268 Item 2). Stored as a hub_setting; the wizard's expose step
|
|
159
|
-
// sets it; absence means we should still ask.
|
|
168
|
+
// sets it; absence means we should still ask. EXCEPT — if we're
|
|
169
|
+
// running on a platform where the answer is pre-determined (e.g.
|
|
170
|
+
// Render exposes the service at $RENDER_EXTERNAL_URL automatically),
|
|
171
|
+
// auto-seed `setup_expose_mode = "public"` so the wizard skips the
|
|
172
|
+
// expose step entirely. The operator landed here through a deploy
|
|
173
|
+
// path that already answered the question; asking again wastes a
|
|
174
|
+
// click and surfaces irrelevant options (localhost, tailnet).
|
|
175
|
+
if (
|
|
176
|
+
getSetting(deps.db, "setup_expose_mode") === undefined &&
|
|
177
|
+
detectAutoExposeMode(deps.env ?? process.env) === "public"
|
|
178
|
+
) {
|
|
179
|
+
setSetting(deps.db, "setup_expose_mode", "public");
|
|
180
|
+
}
|
|
160
181
|
const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
|
|
161
182
|
let step: WizardStep;
|
|
162
183
|
// Note: `"account"` is a visual-only step in the progress header —
|
|
@@ -200,6 +221,43 @@ export interface SetupWizardDeps {
|
|
|
200
221
|
registry?: OperationsRegistry;
|
|
201
222
|
/** Test seam: stub `bun add` / `bun remove` runner. */
|
|
202
223
|
run?: (cmd: readonly string[]) => Promise<number>;
|
|
224
|
+
/**
|
|
225
|
+
* Test seam: override the process env that `detectAutoExposeMode`
|
|
226
|
+
* consults. Production omits this and the helper reads `process.env`
|
|
227
|
+
* directly. Setting in tests lets the auto-skip branch be exercised
|
|
228
|
+
* without mutating the real process env.
|
|
229
|
+
*/
|
|
230
|
+
env?: Record<string, string | undefined>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns `"public"` when the runtime env indicates the hub is deployed
|
|
235
|
+
* on a platform where the "how will this hub be reached?" answer is
|
|
236
|
+
* pre-determined by the platform. Today: Render (sets RENDER_EXTERNAL_URL
|
|
237
|
+
* for any web service). Returns `undefined` otherwise — the wizard's
|
|
238
|
+
* expose step asks the operator.
|
|
239
|
+
*
|
|
240
|
+
* Why this matters: on Render, none of the three radio options
|
|
241
|
+
* (localhost, tailnet, public-with-custom-domain) match the actual
|
|
242
|
+
* setup. The hub is reached at `*.onrender.com` automatically. Asking
|
|
243
|
+
* the operator wastes a click and surfaces three options that don't
|
|
244
|
+
* speak to their situation. Auto-pinning `public` skips the step.
|
|
245
|
+
*
|
|
246
|
+
* Add more platforms here when we encounter them — e.g. Fly.io
|
|
247
|
+
* (FLY_APP_NAME), Railway (RAILWAY_ENVIRONMENT), etc. Each only auto-
|
|
248
|
+
* detects when the platform clearly owns the public URL.
|
|
249
|
+
*/
|
|
250
|
+
export function detectAutoExposeMode(env: Record<string, string | undefined>): "public" | undefined {
|
|
251
|
+
// Render always sets `RENDER_EXTERNAL_URL` to a real `https://` URL on
|
|
252
|
+
// any web service. `startsWith("https://")` is the precise shape; we
|
|
253
|
+
// also accept `http://` as a defensive fallback in case Render ever
|
|
254
|
+
// changes the scheme on some plan tier. Anything else (empty, weird,
|
|
255
|
+
// not a URL) → don't auto-skip; let the operator choose.
|
|
256
|
+
const url = env.RENDER_EXTERNAL_URL;
|
|
257
|
+
if (typeof url === "string" && (url.startsWith("https://") || url.startsWith("http://"))) {
|
|
258
|
+
return "public";
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
203
261
|
}
|
|
204
262
|
|
|
205
263
|
// --- rendering -----------------------------------------------------------
|