@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.45",
3
+ "version": "0.5.13-rc.46",
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": {
@@ -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
- let services: readonly ServiceEntry[];
586
- try {
587
- services = readManifest(manifestPath).services;
588
- } catch (err) {
589
- const msg = err instanceof Error ? err.message : String(err);
590
- return new Response(JSON.stringify({ error: `service routing failed: ${msg}` }), {
591
- status: 500,
592
- headers: { "content-type": "application/json" },
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;
@@ -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;
@@ -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
- * Pure, idempotent — re-running the wizard after partial setup picks up
145
- * where it left off.
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 -----------------------------------------------------------