@openparachute/hub 0.7.3-rc.8 → 0.7.3-rc.9

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.7.3-rc.8",
3
+ "version": "0.7.3-rc.9",
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": {
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Multi-origin iss-set (onboarding-streamline 2026-06-25) — hub side.
3
+ *
4
+ * The hub publishes the SET of origins it legitimately answers on to its
5
+ * supervised resource servers via `PARACHUTE_HUB_ORIGINS` (comma-separated),
6
+ * alongside the single canonical `PARACHUTE_HUB_ORIGIN`. A resource server on
7
+ * scope-guard ≥0.5.0 widens its accepted-`iss` check to this set so a token
8
+ * minted under one URL of a multi-URL box validates via another URL of the
9
+ * SAME box.
10
+ *
11
+ * These tests pin two things:
12
+ * 1. The serialize/parse round-trip + the assembly from hub-controlled
13
+ * inputs (issuer ∪ loopback aliases ∪ expose-state ∪ platform).
14
+ * 2. The SECURITY INVARIANT: the set is built ONLY from operator/hub config
15
+ * and on-disk state — never from an unvalidated request `Host` /
16
+ * `X-Forwarded-Host`. We feed an attacker-controlled "Host" through every
17
+ * input channel a request could plausibly reach and assert it never lands
18
+ * in the published set.
19
+ */
20
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
21
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { parseHubOrigins, serializeHubOrigins } from "../hub-origin.ts";
25
+ import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
26
+
27
+ describe("serializeHubOrigins / parseHubOrigins round-trip", () => {
28
+ test("serialize dedupes, drops empties, strips trailing slashes", () => {
29
+ const v = serializeHubOrigins([
30
+ "https://a.example/",
31
+ "https://a.example",
32
+ "",
33
+ "https://b.example",
34
+ ]);
35
+ expect(v).toBe("https://a.example,https://b.example");
36
+ });
37
+
38
+ test("serialize returns undefined when nothing survives", () => {
39
+ expect(serializeHubOrigins([])).toBeUndefined();
40
+ expect(serializeHubOrigins(["", " "])).toBeUndefined();
41
+ });
42
+
43
+ test("parse is the inverse — tolerant of whitespace + trailing slashes + empties", () => {
44
+ expect(parseHubOrigins("https://a.example, https://b.example/ ,,")).toEqual([
45
+ "https://a.example",
46
+ "https://b.example",
47
+ ]);
48
+ });
49
+
50
+ test("parse returns [] for absent/empty/garbage", () => {
51
+ expect(parseHubOrigins(undefined)).toEqual([]);
52
+ expect(parseHubOrigins("")).toEqual([]);
53
+ expect(parseHubOrigins(" , , ")).toEqual([]);
54
+ });
55
+
56
+ test("round-trips a real set", () => {
57
+ const origins = ["https://example.com", "http://127.0.0.1:1939", "http://localhost:1939"];
58
+ const wire = serializeHubOrigins(origins)!;
59
+ expect(parseHubOrigins(wire)).toEqual(origins);
60
+ });
61
+ });
62
+
63
+ describe("buildHubOriginsEnvValue — assembles the hub's legitimate-origin set", () => {
64
+ let dir: string;
65
+ const EXPOSE = () => join(dir, "expose-state.json");
66
+
67
+ /** Write a schema-valid expose-state.json carrying the given hubOrigin. */
68
+ function writeExposeState(hubOrigin: string): void {
69
+ writeFileSync(
70
+ EXPOSE(),
71
+ JSON.stringify({
72
+ version: 1,
73
+ layer: "public",
74
+ mode: "path",
75
+ canonicalFqdn: new URL(hubOrigin).host,
76
+ port: 1939,
77
+ funnel: true,
78
+ entries: [],
79
+ hubOrigin,
80
+ }),
81
+ );
82
+ }
83
+
84
+ beforeEach(() => {
85
+ dir = mkdtempSync(join(tmpdir(), "hub-origins-set-"));
86
+ // No hub.port file in this fresh configDir → readHubPort falls back to
87
+ // HUB_UNIT_DEFAULT_PORT (1939), the deterministic value these cases assert.
88
+ });
89
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
90
+
91
+ test("issuer ∪ loopback aliases when no expose / platform origin", () => {
92
+ const v = buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE());
93
+ const set = parseHubOrigins(v);
94
+ expect(set).toContain("https://example.com");
95
+ expect(set).toContain("http://127.0.0.1:1939");
96
+ expect(set).toContain("http://localhost:1939");
97
+ expect(set).toHaveLength(3);
98
+ });
99
+
100
+ test("loopback aliases are ALWAYS present (the always-present invariant)", () => {
101
+ // Even with a public issuer, loopback must be in the set so the co-located
102
+ // CLI / loopback-proxied request path validates.
103
+ const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
104
+ expect(set).toContain("http://127.0.0.1:1939");
105
+ expect(set).toContain("http://localhost:1939");
106
+ });
107
+
108
+ test("folds in the expose-state public origin", () => {
109
+ writeExposeState("https://box.sslip.io");
110
+ const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
111
+ expect(set).toContain("https://example.com");
112
+ expect(set).toContain("https://box.sslip.io");
113
+ });
114
+
115
+ test("folds in the platform origin (RENDER_EXTERNAL_URL)", () => {
116
+ const set = parseHubOrigins(
117
+ buildHubOriginsEnvValue(
118
+ dir,
119
+ "https://example.com",
120
+ { RENDER_EXTERNAL_URL: "https://app.onrender.com" },
121
+ EXPOSE(),
122
+ ),
123
+ );
124
+ expect(set).toContain("https://app.onrender.com");
125
+ });
126
+
127
+ test("folds in the composed Fly default origin", () => {
128
+ const set = parseHubOrigins(
129
+ buildHubOriginsEnvValue(dir, "https://example.com", { FLY_APP_NAME: "myapp" }, EXPOSE()),
130
+ );
131
+ expect(set).toContain("https://myapp.fly.dev");
132
+ });
133
+
134
+ test("absent issuer → loopback-only set (still useful, never empty on a normal box)", () => {
135
+ const set = parseHubOrigins(buildHubOriginsEnvValue(dir, undefined, {}, EXPOSE()));
136
+ expect(set).toContain("http://127.0.0.1:1939");
137
+ expect(set).toContain("http://localhost:1939");
138
+ // The empty issuer "" is dropped by buildHubBoundOrigins' URL parse.
139
+ expect(set).not.toContain("");
140
+ });
141
+
142
+ test("a malformed expose-state.json never throws — falls back to issuer + loopback", () => {
143
+ writeFileSync(EXPOSE(), "{ not valid json");
144
+ const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
145
+ expect(set).toContain("https://example.com");
146
+ expect(set).toContain("http://127.0.0.1:1939");
147
+ });
148
+
149
+ describe("SECURITY INVARIANT — request Host never enters the set", () => {
150
+ const ATTACKER = "https://attacker.evil";
151
+
152
+ test("an attacker Host smuggled via expose-state IS honored ONLY because it's operator-written on-disk state — but a Host passed nowhere never appears", () => {
153
+ // There is NO request input to buildHubOriginsEnvValue at all — it takes
154
+ // configDir + issuer + env + expose-state-path. None of those is a
155
+ // request header. We assert the function's surface offers no channel for
156
+ // a request Host: feeding the attacker value to the only inputs an
157
+ // attacker might influence (a stray env var, a header-shaped string)
158
+ // never reaches the set unless it's a legitimate operator-config var.
159
+ const set = parseHubOrigins(
160
+ buildHubOriginsEnvValue(
161
+ dir,
162
+ "https://example.com",
163
+ {
164
+ // Header-shaped env vars an attacker might hope are read — none are
165
+ // consulted by the assembler (only RENDER_EXTERNAL_URL / FLY_APP_NAME).
166
+ HTTP_HOST: ATTACKER,
167
+ HTTP_X_FORWARDED_HOST: ATTACKER,
168
+ X_FORWARDED_HOST: ATTACKER,
169
+ HOST: ATTACKER,
170
+ } as NodeJS.ProcessEnv,
171
+ EXPOSE(),
172
+ ),
173
+ );
174
+ expect(set).not.toContain(ATTACKER);
175
+ expect(set.some((o) => o.includes("attacker"))).toBe(false);
176
+ });
177
+
178
+ test("the set is exactly issuer ∪ loopback ∪ expose ∪ platform — no other source", () => {
179
+ writeExposeState("https://box.sslip.io");
180
+ const set = parseHubOrigins(
181
+ buildHubOriginsEnvValue(
182
+ dir,
183
+ "https://example.com",
184
+ { RENDER_EXTERNAL_URL: "https://app.onrender.com" },
185
+ EXPOSE(),
186
+ ),
187
+ );
188
+ // Every member is one of the four sanctioned sources; nothing else.
189
+ const sanctioned = new Set([
190
+ "https://example.com",
191
+ "http://127.0.0.1:1939",
192
+ "http://localhost:1939",
193
+ "https://box.sslip.io",
194
+ "https://app.onrender.com",
195
+ ]);
196
+ for (const o of set) expect(sanctioned.has(o)).toBe(true);
197
+ });
198
+ });
199
+ });
@@ -125,6 +125,51 @@ describe("bootSupervisedModules", () => {
125
125
  expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
126
126
  });
127
127
 
128
+ test("forwards PARACHUTE_HUB_ORIGINS (the multi-origin iss-set) alongside the single origin", async () => {
129
+ // Multi-origin iss-set (onboarding-streamline 2026-06-25): a resource
130
+ // server on scope-guard ≥0.5.0 widens its accepted-`iss` check to this set
131
+ // so a token minted under one URL of a multi-URL box validates via another.
132
+ // The set always carries the issuer + loopback aliases (the deterministic
133
+ // members); expose-state / platform members vary by box.
134
+ writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
135
+ const recorder = makeRecorder();
136
+ const sup = new Supervisor({ spawnFn: recorder.spawn });
137
+
138
+ await bootSupervisedModules(sup, {
139
+ manifestPath: h.manifestPath,
140
+ configDir: h.dir,
141
+ hubOrigin: "https://hub.example",
142
+ });
143
+
144
+ const origins = recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS;
145
+ expect(origins).toBeDefined();
146
+ const set = (origins ?? "").split(",");
147
+ expect(set).toContain("https://hub.example");
148
+ // SECURITY: the set is hub-controlled (issuer + loopback + expose +
149
+ // platform) — never a request Host. Loopback aliases are always present.
150
+ expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
151
+ expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
152
+ // And the single canonical origin is still written for back-compat.
153
+ expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
154
+ });
155
+
156
+ test("no PARACHUTE_HUB_ORIGINS when hubOrigin is unset (no widening, single-origin only)", async () => {
157
+ // Back-compat: a boot with no hubOrigin neither sets PARACHUTE_HUB_ORIGIN
158
+ // nor the set — the child keeps its own loopback default + scope-guard's
159
+ // single-origin behavior.
160
+ writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
161
+ const recorder = makeRecorder();
162
+ const sup = new Supervisor({ spawnFn: recorder.spawn });
163
+
164
+ await bootSupervisedModules(sup, {
165
+ manifestPath: h.manifestPath,
166
+ configDir: h.dir,
167
+ });
168
+
169
+ expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBeUndefined();
170
+ expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS).toBeUndefined();
171
+ });
172
+
128
173
  test("sets PORT in child env from services.json entry (hub#357)", async () => {
129
174
  // Container deploys (Render etc.) set PORT in hub's process.env via
130
175
  // Dockerfile / platform injection. The supervisor's defaultSpawnFn
@@ -76,13 +76,29 @@ describe("persistVaultHubOrigin", () => {
76
76
  expect(existsSync(vaultEnv())).toBe(false);
77
77
  });
78
78
 
79
- test("is idempotent — no rewrite when the value is already current", () => {
79
+ test("is idempotent — no rewrite when both the origin and the set are current", () => {
80
80
  const log: string[] = [];
81
81
  expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(true);
82
+ // First call wrote BOTH the single origin and the multi-origin set.
83
+ expect(
84
+ log.some((l) => /persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/.test(l)),
85
+ ).toBe(true);
86
+ expect(log.some((l) => /persisted PARACHUTE_HUB_ORIGINS=/.test(l))).toBe(true);
87
+ const writesAfterFirst = log.length;
88
+ // Second call is a no-op: both values already current → false, no new logs.
82
89
  expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(false);
83
- // Only the first call logged.
84
- expect(log).toHaveLength(1);
85
- expect(log[0]).toMatch(/persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/);
90
+ expect(log).toHaveLength(writesAfterFirst);
91
+ });
92
+
93
+ test("writes PARACHUTE_HUB_ORIGINS (multi-origin iss-set) alongside the single origin", () => {
94
+ expect(persistVaultHubOrigin(dir, "https://hub.example.com", () => {})).toBe(true);
95
+ const values = readEnvFileValues(vaultEnv());
96
+ expect(values.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example.com");
97
+ // The set carries the issuer + loopback aliases (always present).
98
+ const set = (values.PARACHUTE_HUB_ORIGINS ?? "").split(",");
99
+ expect(set).toContain("https://hub.example.com");
100
+ expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
101
+ expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
86
102
  });
87
103
 
88
104
  test("updates a stale origin in-place and preserves sibling keys", () => {
@@ -113,6 +129,24 @@ describe("clearVaultHubOrigin", () => {
113
129
  expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
114
130
  });
115
131
 
132
+ test("removes BOTH the single origin and the multi-origin set, leaving siblings", () => {
133
+ writeFileSync(
134
+ mkVaultDir(),
135
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://hub.example.com\nPARACHUTE_HUB_ORIGINS=https://hub.example.com,http://127.0.0.1:1939\n",
136
+ );
137
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
138
+ const values = readEnvFileValues(vaultEnv());
139
+ expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
140
+ expect(values.PARACHUTE_HUB_ORIGINS).toBeUndefined();
141
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
142
+ });
143
+
144
+ test("clears a lone PARACHUTE_HUB_ORIGINS even if the single origin is absent", () => {
145
+ writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGINS=https://hub.example.com\n");
146
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
147
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGINS).toBeUndefined();
148
+ });
149
+
116
150
  test("no-op when no origin is present", () => {
117
151
  writeFileSync(mkVaultDir(), "SCRIBE_AUTH_TOKEN=secret\n");
118
152
  expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
@@ -55,6 +55,7 @@ import {
55
55
  import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
56
56
  import { enrichedPath } from "./spawn-path.ts";
57
57
  import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
58
+ import { buildHubOriginsEnvValue } from "./vault-hub-origin-env.ts";
58
59
  import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
59
60
 
60
61
  /**
@@ -609,10 +610,21 @@ async function spawnSupervised(
609
610
  // `process.env.PATH` may ALREADY be enriched by serve startup (serve.ts);
610
611
  // re-enriching here is a harmless no-op — `enrichedPath` is idempotent
611
612
  // (dedupe + append-only), so double-enrichment can't duplicate or reorder.
613
+ // PARACHUTE_HUB_ORIGINS (multi-origin iss-set, onboarding-streamline
614
+ // 2026-06-25): alongside the single canonical PARACHUTE_HUB_ORIGIN, inject
615
+ // the SET of origins the hub legitimately answers on (issuer ∪ loopback ∪
616
+ // expose-state ∪ platform). A resource server on scope-guard ≥0.5.0 widens
617
+ // its accepted-`iss` check to this set so a token minted under one URL of a
618
+ // multi-URL box validates via another URL of the SAME box. Mirrors
619
+ // buildModuleSpawnRequest (serve-boot.ts) — keep the two in sync. SECURITY:
620
+ // the set is hub-controlled config/disk state ONLY, never a request Host
621
+ // (see buildHubOriginsEnvValue). `deps.spawnEnv` still wins.
622
+ const hubOrigins = deps.issuer ? buildHubOriginsEnvValue(deps.configDir, deps.issuer) : undefined;
612
623
  const childEnv: Record<string, string> = {
613
624
  PATH: enrichedPath(),
614
625
  PORT: String(entry.port),
615
626
  ...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
627
+ ...(hubOrigins ? { PARACHUTE_HUB_ORIGINS: hubOrigins } : {}),
616
628
  ...(deps.spawnEnv ?? {}),
617
629
  };
618
630
  const req: SpawnRequest = {
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { join } from "node:path";
20
20
  import { readEnvFileValues } from "../env-file.ts";
21
- import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
21
+ import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV } from "../hub-origin.ts";
22
22
  import { ModuleManifestError } from "../module-manifest.ts";
23
23
  import {
24
24
  type ServiceSpec,
@@ -30,6 +30,7 @@ import {
30
30
  import { type ServiceEntry, readManifestLenient, upsertService } from "../services-manifest.ts";
31
31
  import { enrichedPath } from "../spawn-path.ts";
32
32
  import type { Supervisor } from "../supervisor.ts";
33
+ import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
33
34
 
34
35
  export interface BootOpts {
35
36
  /** Path to services.json. */
@@ -197,7 +198,20 @@ export function buildModuleSpawnRequest(
197
198
  PORT: String(entry.port),
198
199
  ...fileEnvSansPort,
199
200
  };
200
- if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
201
+ if (opts.hubOrigin) {
202
+ env[HUB_ORIGIN_ENV] = opts.hubOrigin;
203
+ // Multi-origin iss-set (onboarding-streamline 2026-06-25): alongside the
204
+ // single canonical origin, inject the SET of origins this hub legitimately
205
+ // answers on (issuer ∪ loopback aliases ∪ expose-state ∪ platform). A
206
+ // resource server on scope-guard ≥0.5.0 widens its accepted-`iss` check to
207
+ // this set, so a token minted under one URL of a multi-URL box validates
208
+ // when the resource is reached via another URL of the SAME box. SECURITY:
209
+ // the set is hub-controlled config/disk state only, NEVER a request Host —
210
+ // see `buildHubOriginsEnvValue`. The single `PARACHUTE_HUB_ORIGIN` above
211
+ // stays for back-compat with older scope-guard.
212
+ const originsValue = buildHubOriginsEnvValue(opts.configDir, opts.hubOrigin);
213
+ if (originsValue) env[HUB_ORIGINS_ENV] = originsValue;
214
+ }
201
215
  if (opts.extraEnv) Object.assign(env, opts.extraEnv);
202
216
 
203
217
  const req: SpawnReqShape = { short, cmd };
package/src/hub-origin.ts CHANGED
@@ -14,6 +14,70 @@
14
14
 
15
15
  export const HUB_ORIGIN_ENV = "PARACHUTE_HUB_ORIGIN";
16
16
 
17
+ /**
18
+ * The env var carrying the SET of origins the hub legitimately answers on,
19
+ * comma-separated (multi-origin iss-set, onboarding-streamline 2026-06-25).
20
+ *
21
+ * Supervised resource servers (vault, scribe) read this in addition to the
22
+ * single canonical `PARACHUTE_HUB_ORIGIN` and widen their accepted-`iss` check
23
+ * from one string to this set — so a token minted under one URL of a
24
+ * multi-URL box (loopback ∪ `<ip>.sslip.io` ∪ a custom domain behind Caddy)
25
+ * validates when the resource is reached via another URL of the SAME box. The
26
+ * signing key is stable + origin-independent, so only the `iss` string varies.
27
+ *
28
+ * SECURITY INVARIANT: the value MUST be the hub's `buildHubBoundOrigins`
29
+ * output — configured issuer ∪ loopback aliases ∪ expose-state public origin ∪
30
+ * platform origin — published BY THE HUB. It must NEVER contain an unvalidated
31
+ * request `Host` / `X-Forwarded-Host`. Accepting `iss ∈ this-set` is safe
32
+ * ONLY because the resource server's JWKS signature verify runs first and
33
+ * proves the hub minted the token.
34
+ *
35
+ * `PARACHUTE_HUB_ORIGIN` (the single canonical origin) is ALWAYS written
36
+ * alongside this var for back-compat: a resource server on an older
37
+ * scope-guard that doesn't read `PARACHUTE_HUB_ORIGINS` keeps its existing
38
+ * single-origin behavior.
39
+ */
40
+ export const HUB_ORIGINS_ENV = "PARACHUTE_HUB_ORIGINS";
41
+
42
+ /**
43
+ * Serialize an origin SET into the comma-separated `PARACHUTE_HUB_ORIGINS`
44
+ * wire form. Dedupes, drops empties, preserves first-seen order. Returns
45
+ * `undefined` when nothing survives (caller skips setting the env var so a
46
+ * resource server falls back to single-origin behavior). Pair with
47
+ * `buildHubBoundOrigins` to produce `origins`.
48
+ */
49
+ export function serializeHubOrigins(origins: readonly string[]): string | undefined {
50
+ const seen = new Set<string>();
51
+ for (const raw of origins) {
52
+ if (typeof raw !== "string") continue;
53
+ const trimmed = raw.trim().replace(/\/+$/, "");
54
+ if (trimmed.length > 0) seen.add(trimmed);
55
+ }
56
+ if (seen.size === 0) return undefined;
57
+ return Array.from(seen).join(",");
58
+ }
59
+
60
+ /**
61
+ * Parse the comma-separated `PARACHUTE_HUB_ORIGINS` wire form back into an
62
+ * origin array. Tolerant of surrounding whitespace + trailing slashes + empty
63
+ * segments. The resource-server inverse of `serializeHubOrigins`; lives here
64
+ * (rather than only in the consumer adapters) so the hub's own injection tests
65
+ * can round-trip against the canonical parser. Returns `[]` for an
66
+ * absent/empty/garbage value.
67
+ */
68
+ export function parseHubOrigins(raw: string | undefined): string[] {
69
+ if (!raw) return [];
70
+ const out: string[] = [];
71
+ const seen = new Set<string>();
72
+ for (const seg of raw.split(",")) {
73
+ const trimmed = seg.trim().replace(/\/+$/, "");
74
+ if (trimmed.length === 0 || seen.has(trimmed)) continue;
75
+ seen.add(trimmed);
76
+ out.push(trimmed);
77
+ }
78
+ return out;
79
+ }
80
+
17
81
  export interface DeriveHubOriginOpts {
18
82
  /** Explicit user override (e.g., `--hub-origin`). Wins over everything else. */
19
83
  override?: string;
package/src/jwt-sign.ts CHANGED
@@ -55,9 +55,20 @@ export interface SignAccessTokenOpts {
55
55
  clientId: string;
56
56
  /**
57
57
  * Hub origin — sets the `iss` claim. Required: every consumer (vault,
58
- * scribe, channel) validates `iss` against `PARACHUTE_HUB_ORIGIN`, and a
59
- * missing claim is rejected. Callers derive this via `deriveHubOrigin()`
60
- * or thread it from `OAuthDeps.issuer`.
58
+ * scribe, agent) validates `iss`, and a missing claim is rejected. Callers
59
+ * derive this via `deriveHubOrigin()` or thread it from `OAuthDeps.issuer`
60
+ * (the per-request origin from `resolveIssuer`).
61
+ *
62
+ * Multi-origin iss-set (onboarding-streamline 2026-06-25): consumers no
63
+ * longer pin `iss` to a SINGLE `PARACHUTE_HUB_ORIGIN`. The hub publishes the
64
+ * SET of origins it legitimately answers on via `PARACHUTE_HUB_ORIGINS`
65
+ * (`buildHubBoundOrigins` — issuer ∪ loopback ∪ expose-state ∪ platform),
66
+ * and a scope-guard ≥0.5.0 consumer accepts `iss` ∈ that set. So a token
67
+ * minted here with one URL of a multi-URL box validates when the resource is
68
+ * reached via another URL of the SAME box. Mint stays per-request as-is: the
69
+ * canonical configured origin (the intended Caddy/zero-SSH deploy) is minted
70
+ * on every request AND is a member of the published set, so mint and validate
71
+ * already align — no clamp needed here.
61
72
  */
62
73
  issuer: string;
63
74
  /**
@@ -29,7 +29,10 @@
29
29
  import { join } from "node:path";
30
30
  import { parseEnvFile, removeEnvLine, upsertEnvLine, writeEnvFile } from "./env-file.ts";
31
31
  import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
32
- import { HUB_ORIGIN_ENV } from "./hub-origin.ts";
32
+ import { readHubPort } from "./hub-control.ts";
33
+ import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV, serializeHubOrigins } from "./hub-origin.ts";
34
+ import { HUB_UNIT_DEFAULT_PORT } from "./hub-unit.ts";
35
+ import { buildHubBoundOrigins } from "./origin-check.ts";
33
36
 
34
37
  /**
35
38
  * Loopback origins (`http://127.0.0.1:<port>`, `localhost`, `[::1]`) are the
@@ -91,11 +94,80 @@ function vaultEnvPath(configDir: string): string {
91
94
  return join(configDir, "vault", ".env");
92
95
  }
93
96
 
97
+ /**
98
+ * Compose `https://${FLY_APP_NAME}.fly.dev` when FLY_APP_NAME is a plausible
99
+ * Fly app slug (no slashes — Fly slugs don't contain them). Mirrors the
100
+ * private helper in operator-token.ts / hub-server.ts; kept local so the
101
+ * origin-SET assembly here doesn't reach across modules for a 3-line guard.
102
+ */
103
+ function flyDefaultOriginFromEnv(env: NodeJS.ProcessEnv): string | undefined {
104
+ const app = env.FLY_APP_NAME;
105
+ if (typeof app !== "string" || app.length === 0 || app.includes("/")) return undefined;
106
+ return `https://${app}.fly.dev`;
107
+ }
108
+
109
+ /**
110
+ * Assemble the comma-separated `PARACHUTE_HUB_ORIGINS` value the hub injects
111
+ * into supervised resource servers (multi-origin iss-set). The SET is the
112
+ * hub's own legitimate origins — the env-injection sibling of the operator
113
+ * token's `buildKnownIssuersForOperatorToken` and the per-request
114
+ * `buildHubBoundOrigins` call in hub-server.ts. Inputs:
115
+ *
116
+ * - `issuer` — the canonical hub origin the child also receives as the
117
+ * single `PARACHUTE_HUB_ORIGIN` (the seed; always included).
118
+ * - loopback aliases — `http://127.0.0.1:<port>` ∪ `http://localhost:<port>`
119
+ * for the hub's port (`readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT`).
120
+ * - the expose-state public origin — `expose-state.json`'s `hubOrigin`.
121
+ * - the platform/env public origin — `RENDER_EXTERNAL_URL` ∪ the composed
122
+ * Fly default (container deploys where the public origin comes from the
123
+ * platform, not expose-state).
124
+ *
125
+ * SECURITY INVARIANT: every input is hub/operator-controlled config or
126
+ * on-disk state — NEVER an unvalidated request `Host` / `X-Forwarded-Host`.
127
+ * The accepted-`iss` widening this enables is safe only because the resource
128
+ * server verifies the JWKS signature first; this set is the belt-and-
129
+ * suspenders allowlist layered on top.
130
+ *
131
+ * Returns `undefined` when the seed issuer is absent AND nothing else resolves
132
+ * (caller skips the env var so the child keeps single-origin behavior).
133
+ */
134
+ export function buildHubOriginsEnvValue(
135
+ configDir: string,
136
+ issuer: string | undefined,
137
+ env: NodeJS.ProcessEnv = process.env,
138
+ exposeStatePath: string = EXPOSE_STATE_PATH,
139
+ ): string | undefined {
140
+ const loopbackPort = readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT;
141
+ let exposeHubOrigin: string | undefined;
142
+ try {
143
+ exposeHubOrigin = readExposeState(exposeStatePath)?.hubOrigin;
144
+ } catch {
145
+ // A malformed expose-state.json must never block a module spawn — the seed
146
+ // issuer + loopback aliases already cover legitimate access.
147
+ exposeHubOrigin = undefined;
148
+ }
149
+ const platformOrigin = env.RENDER_EXTERNAL_URL ?? flyDefaultOriginFromEnv(env);
150
+ const origins = buildHubBoundOrigins({
151
+ // buildHubBoundOrigins requires `issuer`; pass "" when absent (it's dropped
152
+ // by the URL parse) so the loopback aliases still seed the set.
153
+ issuer: issuer ?? "",
154
+ loopbackPort,
155
+ ...(exposeHubOrigin !== undefined ? { exposeHubOrigin } : {}),
156
+ ...(platformOrigin !== undefined ? { platformOrigin } : {}),
157
+ });
158
+ return serializeHubOrigins(origins);
159
+ }
160
+
94
161
  /**
95
162
  * Upsert `PARACHUTE_HUB_ORIGIN=<origin>` into `vault/.env` when `origin` is a
96
- * non-loopback public origin. Idempotent skips the write (and the log) when
97
- * the value is already current so repeated `start`s don't churn the file.
98
- * Returns true iff the file was written this call.
163
+ * non-loopback public origin, AND the `PARACHUTE_HUB_ORIGINS` set (the
164
+ * multi-origin iss-set: origin loopback aliases expose-state platform)
165
+ * so the daemon-boot path validates `iss` against every URL the box answers on
166
+ * (a Caddy-fronted box reached via loopback + sslip.io + a custom domain at
167
+ * once). The set is assembled from hub-controlled inputs only (see
168
+ * `buildHubOriginsEnvValue`'s security invariant). Idempotent — skips the
169
+ * write (and the log) when BOTH values are already current so repeated
170
+ * `start`s don't churn the file. Returns true iff the file was written.
99
171
  */
100
172
  export function persistVaultHubOrigin(
101
173
  configDir: string,
@@ -105,26 +177,47 @@ export function persistVaultHubOrigin(
105
177
  if (isLoopbackOrigin(origin)) return false;
106
178
  const path = vaultEnvPath(configDir);
107
179
  const parsed = parseEnvFile(path);
108
- if (parsed.values[HUB_ORIGIN_ENV] === origin) return false;
109
- writeEnvFile(path, upsertEnvLine(parsed.lines, HUB_ORIGIN_ENV, origin));
110
- log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
180
+ const originsValue = buildHubOriginsEnvValue(configDir, origin);
181
+ const originCurrent = parsed.values[HUB_ORIGIN_ENV] === origin;
182
+ const originsCurrent =
183
+ originsValue === undefined || parsed.values[HUB_ORIGINS_ENV] === originsValue;
184
+ if (originCurrent && originsCurrent) return false;
185
+ let lines = parsed.lines;
186
+ if (!originCurrent) lines = upsertEnvLine(lines, HUB_ORIGIN_ENV, origin);
187
+ if (!originsCurrent && originsValue !== undefined) {
188
+ lines = upsertEnvLine(lines, HUB_ORIGINS_ENV, originsValue);
189
+ }
190
+ writeEnvFile(path, lines);
191
+ if (!originCurrent) {
192
+ log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
193
+ }
194
+ if (!originsCurrent && originsValue !== undefined) {
195
+ log(` persisted ${HUB_ORIGINS_ENV}=${originsValue} to ${path} (multi-origin iss-set)`);
196
+ }
111
197
  return true;
112
198
  }
113
199
 
114
200
  /**
115
- * Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` from `vault/.env`. Called
116
- * on `expose … off`: once exposure is torn down, a local-only hub mints tokens
117
- * with a loopback `iss`, so a stale public origin left in `.env` would itself
118
- * cause the mismatch. Removing the line reverts vault to its loopback default
119
- * (`getHubOrigin`), which matches what the local hub now stamps. No-op (returns
120
- * false) when the key isn't present. Returns true iff the file was rewritten.
201
+ * Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` (and `PARACHUTE_HUB_ORIGINS`)
202
+ * from `vault/.env`. Called on `expose … off`: once exposure is torn down, a
203
+ * local-only hub mints tokens with a loopback `iss`, so a stale public origin
204
+ * left in `.env` would itself cause the mismatch. Removing the lines reverts
205
+ * vault to its loopback default (`getHubOrigin`), which matches what the local
206
+ * hub now stamps. No-op (returns false) when neither key is present. Returns
207
+ * true iff the file was rewritten.
121
208
  */
122
209
  export function clearVaultHubOrigin(configDir: string, log: (line: string) => void): boolean {
123
210
  const path = vaultEnvPath(configDir);
124
211
  const parsed = parseEnvFile(path);
125
- if (parsed.values[HUB_ORIGIN_ENV] === undefined) return false;
126
- writeEnvFile(path, removeEnvLine(parsed.lines, HUB_ORIGIN_ENV));
127
- log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
212
+ const hadOrigin = parsed.values[HUB_ORIGIN_ENV] !== undefined;
213
+ const hadOrigins = parsed.values[HUB_ORIGINS_ENV] !== undefined;
214
+ if (!hadOrigin && !hadOrigins) return false;
215
+ let lines = parsed.lines;
216
+ if (hadOrigin) lines = removeEnvLine(lines, HUB_ORIGIN_ENV);
217
+ if (hadOrigins) lines = removeEnvLine(lines, HUB_ORIGINS_ENV);
218
+ writeEnvFile(path, lines);
219
+ if (hadOrigin) log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
220
+ if (hadOrigins) log(` cleared ${HUB_ORIGINS_ENV} from ${path} (exposure torn down)`);
128
221
  return true;
129
222
  }
130
223