@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 +1 -1
- package/src/__tests__/api-modules-ops.test.ts +44 -0
- package/src/__tests__/hub-server.test.ts +48 -1
- package/src/__tests__/hub.test.ts +19 -0
- package/src/__tests__/serve.test.ts +64 -0
- package/src/api-modules-ops.ts +12 -0
- package/src/commands/serve.ts +36 -1
- package/src/hub-server.ts +10 -3
- package/src/hub.ts +5 -1
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -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 = {
|
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
|
}
|
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
|
-
|
|
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({
|