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

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.34",
3
+ "version": "0.5.13-rc.37",
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": {
@@ -1043,6 +1043,50 @@ describe("well-known regen after module ops", () => {
1043
1043
  expect(spawns[0]?.env?.PORT).toBe("1940");
1044
1044
  });
1045
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
+
1046
1090
  test("runInstall failure: bun add fails -> no well-known regen (no partial state)", async () => {
1047
1091
  const { supervisor } = makeIdleSupervisor();
1048
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 { findServiceUpstream, findVaultUpstream, hubFetch, layerOf } from "../hub-server.ts";
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";
@@ -3212,3 +3218,44 @@ describe("hub-server.ts startup PID/port registration (#148)", () => {
3212
3218
  }
3213
3219
  }, 10_000);
3214
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
+ });
@@ -13,6 +13,25 @@ describe("renderHub", () => {
13
13
  expect(html).toContain("<script>");
14
14
  });
15
15
 
16
+ test("inline <script> body parses as valid JS (regression: hub#366)", () => {
17
+ // The script lives inside HTML_TEMPLATE — a backtick template literal.
18
+ // Backslash escapes inside the template silently mangle regex literals:
19
+ // `/\/+$/` written in the source becomes `//+$/` in the served HTML,
20
+ // which JS parses as the start of a line comment, killing the entire
21
+ // IIFE. Every section that renders inside that IIFE (Get started,
22
+ // Services, Admin) then never paints.
23
+ //
24
+ // Content assertions don't catch this — they pass on the broken
25
+ // string. A parse-check via `new Function(body)` does: it parses
26
+ // (no execution, no DOM needed) and throws SyntaxError on the bad
27
+ // regex. Wrap in a try/catch so future template-escaping regressions
28
+ // surface here with a clear message rather than as a runtime mystery.
29
+ const m = html.match(/<script>([\s\S]*?)<\/script>/);
30
+ const scriptBody = m?.[1];
31
+ expect(scriptBody).toBeDefined();
32
+ expect(() => new Function(scriptBody as string)).not.toThrow();
33
+ });
34
+
16
35
  test("fetches /.well-known/parachute.json for the Use section", () => {
17
36
  expect(html).toContain("/.well-known/parachute.json");
18
37
  expect(html).toContain("doc.services");
@@ -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
+ });
@@ -408,9 +408,21 @@ async function spawnSupervised(
408
408
  // server.ts:230) tries to bind hub's port and crashes EADDRINUSE.
409
409
  // Explicitly override with the child's services.json port so children
410
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
+ //
411
422
  // `deps.spawnEnv` still wins (test seam + first-boot vault-name pass-through).
412
423
  const childEnv: Record<string, string> = {
413
424
  PORT: String(entry.port),
425
+ ...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
414
426
  ...(deps.spawnEnv ?? {}),
415
427
  };
416
428
  const req: SpawnRequest = {
@@ -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.issuer ?? env.PARACHUTE_HUB_ORIGIN)?.replace(/\/+$/, "") || undefined;
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 && env.PARACHUTE_HUB_ORIGIN) {
254
- issuer = env.PARACHUTE_HUB_ORIGIN.replace(/\/+$/, "");
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
  }
package/src/hub.ts CHANGED
@@ -496,7 +496,11 @@ const HTML_TEMPLATE = `<!doctype html>
496
496
  // firstVaultName() shape.
497
497
  let vaultName = 'default';
498
498
  if (typeof vault.path === 'string' && vault.path.startsWith('/vault/')) {
499
- const tail = vault.path.slice('/vault/'.length).replace(/\/+$/, '');
499
+ // Character class for the slash so the template literal can't eat
500
+ // the backslash-escape — \/ collapses to / inside backticks, and
501
+ // /\/+$/ degenerates to a // line comment that breaks the whole
502
+ // IIFE. [/]+$ keeps the same semantics with no escape needed.
503
+ const tail = vault.path.slice('/vault/'.length).replace(/[/]+$/, '');
500
504
  if (tail.length > 0) vaultName = tail;
501
505
  }
502
506
  tiles.push({