@openparachute/hub 0.6.3-rc.2 → 0.6.3-rc.3

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.6.3-rc.2",
3
+ "version": "0.6.3-rc.3",
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": {
@@ -70,6 +70,36 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
70
70
  return signed.token;
71
71
  }
72
72
 
73
+ /**
74
+ * Mint a host-admin bearer at a chosen `iss` (hub#516). The CLI presents the
75
+ * operator token on loopback; after `expose` its `iss` is the public origin
76
+ * while the loopback request resolves the loopback issuer.
77
+ */
78
+ async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
79
+ const signed = await signAccessToken(h.db, {
80
+ sub: h.userId,
81
+ scopes,
82
+ audience: "operator",
83
+ clientId: "parachute-hub",
84
+ issuer: iss,
85
+ ttlSeconds: 3600,
86
+ });
87
+ recordTokenMint(h.db, {
88
+ jti: signed.jti,
89
+ createdVia: "operator_mint",
90
+ subject: "operator",
91
+ clientId: "parachute-hub",
92
+ scopes,
93
+ expiresAt: signed.expiresAt,
94
+ });
95
+ return signed.token;
96
+ }
97
+
98
+ /** The hub's public origin after `expose` — what the operator token's `iss` becomes. */
99
+ const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
100
+ /** A foreign origin the hub never answers on. */
101
+ const FOREIGN_ORIGIN = "https://evil.example.com";
102
+
73
103
  function postReq(path: string, headers: Record<string, string>): Request {
74
104
  return new Request(`http://localhost${path}`, { method: "POST", headers });
75
105
  }
@@ -967,6 +997,97 @@ describe("POST /api/modules/:short/stop", () => {
967
997
  });
968
998
  });
969
999
 
1000
+ /**
1001
+ * hub#516 — the module-ops `authorize` accepts the operator token's `iss`
1002
+ * against the SET of origins the hub answers on (knownIssuers), not just the
1003
+ * single per-request issuer. Exercised through `handleStop` (the simplest sync
1004
+ * op that goes through `authorize`). The per-request `issuer` here is loopback
1005
+ * (mirroring a loopback CLI request); `knownIssuers` carries loopback + the
1006
+ * public expose-state origin.
1007
+ */
1008
+ describe("operator-token iss validation against knownIssuers (hub#516)", () => {
1009
+ let h: Harness;
1010
+ beforeEach(async () => {
1011
+ h = await makeHarness();
1012
+ _resetOperationsRegistryForTests();
1013
+ });
1014
+ afterEach(() => h.cleanup());
1015
+
1016
+ // The live repro: operator token's iss = PUBLIC origin, loopback request
1017
+ // (per-request issuer = loopback), knownIssuers includes the public origin
1018
+ // → ACCEPTED. Was rejected (`unexpected "iss" claim value`) pre-fix.
1019
+ test("live repro: public-iss operator token on a loopback request → ACCEPTED", async () => {
1020
+ const { supervisor } = makeIdleSupervisor();
1021
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
1022
+ const res = await handleStop(
1023
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1024
+ "vault",
1025
+ {
1026
+ db: h.db,
1027
+ issuer: ISSUER, // loopback per-request issuer
1028
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
1029
+ manifestPath: h.manifestPath,
1030
+ configDir: h.dir,
1031
+ supervisor,
1032
+ },
1033
+ );
1034
+ // Not a 401 — authorize passed. (stopped:false because nothing's running.)
1035
+ expect(res.status).toBe(200);
1036
+ });
1037
+
1038
+ test("loopback-iss operator token, loopback knownIssuers → accepted (unchanged)", async () => {
1039
+ const { supervisor } = makeIdleSupervisor();
1040
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], ISSUER);
1041
+ const res = await handleStop(
1042
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1043
+ "vault",
1044
+ {
1045
+ db: h.db,
1046
+ issuer: ISSUER,
1047
+ knownIssuers: [ISSUER, "http://localhost:1939"],
1048
+ manifestPath: h.manifestPath,
1049
+ configDir: h.dir,
1050
+ supervisor,
1051
+ },
1052
+ );
1053
+ expect(res.status).toBe(200);
1054
+ });
1055
+
1056
+ test("FOREIGN-iss operator token → 401 (no widening to arbitrary issuers)", async () => {
1057
+ const { supervisor } = makeIdleSupervisor();
1058
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], FOREIGN_ORIGIN);
1059
+ const res = await handleStop(
1060
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1061
+ "vault",
1062
+ {
1063
+ db: h.db,
1064
+ issuer: ISSUER,
1065
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
1066
+ manifestPath: h.manifestPath,
1067
+ configDir: h.dir,
1068
+ supervisor,
1069
+ },
1070
+ );
1071
+ expect(res.status).toBe(401);
1072
+ const body = (await res.json()) as { error_description: string };
1073
+ expect(body.error_description).toMatch(/unexpected "iss" claim value/);
1074
+ });
1075
+
1076
+ test("knownIssuers absent → falls back to strict per-request issuer (back-compat)", async () => {
1077
+ const { supervisor } = makeIdleSupervisor();
1078
+ // Public-iss token, loopback per-request issuer, NO knownIssuers wired →
1079
+ // the strict single-issuer fallback rejects (the pre-fix behavior the
1080
+ // non-HTTP install path relies on).
1081
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
1082
+ const res = await handleStop(
1083
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1084
+ "vault",
1085
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
1086
+ );
1087
+ expect(res.status).toBe(401);
1088
+ });
1089
+ });
1090
+
970
1091
  describe("POST /api/modules/:short/restart", () => {
971
1092
  let h: Harness;
972
1093
  beforeEach(async () => {
@@ -63,6 +63,32 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
63
63
  return signed.token;
64
64
  }
65
65
 
66
+ /** The hub's public origin after `expose` — what the operator token's `iss` becomes (hub#516). */
67
+ const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
68
+ /** A foreign origin the hub never answers on (hub#516). */
69
+ const FOREIGN_ORIGIN = "https://evil.example.com";
70
+
71
+ /** Mint a host-admin (operator-shaped) bearer at a chosen `iss` (hub#516). */
72
+ async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
73
+ const signed = await signAccessToken(h.db, {
74
+ sub: h.userId,
75
+ scopes,
76
+ audience: "operator",
77
+ clientId: "parachute-hub",
78
+ issuer: iss,
79
+ ttlSeconds: 3600,
80
+ });
81
+ recordTokenMint(h.db, {
82
+ jti: signed.jti,
83
+ createdVia: "operator_mint",
84
+ subject: "operator",
85
+ clientId: "parachute-hub",
86
+ scopes,
87
+ expiresAt: signed.expiresAt,
88
+ });
89
+ return signed.token;
90
+ }
91
+
66
92
  function writeManifest(path: string, services: unknown[]): void {
67
93
  writeFileSync(path, JSON.stringify({ services }));
68
94
  }
@@ -147,6 +173,47 @@ describe("GET /api/modules", () => {
147
173
  expect(body.error).toBe("insufficient_scope");
148
174
  });
149
175
 
176
+ // hub#516: `parachute status` reads /api/modules on loopback presenting the
177
+ // operator token, whose `iss` is the PUBLIC origin after `expose`. The
178
+ // host-admin bearer's iss is validated against `knownIssuers` (loopback ∪
179
+ // expose-state public ∪ env), not the single per-request loopback issuer.
180
+ test("live repro: public-iss operator token on a loopback request → 200 (hub#516)", async () => {
181
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
182
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
183
+ db: h.db,
184
+ issuer: ISSUER, // loopback per-request issuer
185
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
186
+ manifestPath: h.manifestPath,
187
+ fetchLatestVersion: async () => null,
188
+ });
189
+ expect(res.status).toBe(200);
190
+ });
191
+
192
+ test("FOREIGN-iss operator token → 401 (no widening) (hub#516)", async () => {
193
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], FOREIGN_ORIGIN);
194
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
195
+ db: h.db,
196
+ issuer: ISSUER,
197
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
198
+ manifestPath: h.manifestPath,
199
+ fetchLatestVersion: async () => null,
200
+ });
201
+ expect(res.status).toBe(401);
202
+ const body = (await res.json()) as { error_description: string };
203
+ expect(body.error_description).toMatch(/unexpected "iss" claim value/);
204
+ });
205
+
206
+ test("knownIssuers absent → strict per-request issuer fallback rejects public-iss (hub#516)", async () => {
207
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
208
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
209
+ db: h.db,
210
+ issuer: ISSUER, // no knownIssuers → falls back to [issuer]
211
+ manifestPath: h.manifestPath,
212
+ fetchLatestVersion: async () => null,
213
+ });
214
+ expect(res.status).toBe(401);
215
+ });
216
+
150
217
  test("200 + curated list on fresh container (empty services.json)", async () => {
151
218
  // The v0.6 hot path: brand-new Render container, no services.json
152
219
  // yet. UI must render "install vault / scribe" cards even though
@@ -0,0 +1,218 @@
1
+ /**
2
+ * hub#516 — `validateHostAdminToken` accepts the operator / SPA host-admin
3
+ * token's `iss` against the SET of origins the hub answers on (loopback ∪
4
+ * expose-state public ∪ env/platform), not the single per-request issuer, so
5
+ * the loopback CLI works on an exposed box. OAuth-token validation
6
+ * (`validateAccessToken` with a pinned `expectedIssuer`) is NOT touched — see
7
+ * the strict-iss regression at the bottom of this file.
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { validateHostAdminToken } from "../host-admin-token-validation.ts";
14
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
15
+ import { recordTokenMint, signAccessToken, validateAccessToken } from "../jwt-sign.ts";
16
+ import { rotateSigningKey } from "../signing-keys.ts";
17
+ import { createUser } from "../users.ts";
18
+
19
+ const LOOPBACK = "http://127.0.0.1:1939";
20
+ const PUBLIC_TS = "https://parachute.taildf9ce2.ts.net";
21
+ const FOREIGN = "https://evil.example.com";
22
+
23
+ interface H {
24
+ db: ReturnType<typeof openHubDb>;
25
+ userId: string;
26
+ cleanup: () => void;
27
+ }
28
+
29
+ async function makeH(): Promise<H> {
30
+ const dir = mkdtempSync(join(tmpdir(), "phub-host-admin-tok-"));
31
+ const db = openHubDb(hubDbPath(dir));
32
+ rotateSigningKey(db);
33
+ const user = await createUser(db, "owner", "pw");
34
+ return {
35
+ db,
36
+ userId: user.id,
37
+ cleanup: () => {
38
+ db.close();
39
+ rmSync(dir, { recursive: true, force: true });
40
+ },
41
+ };
42
+ }
43
+
44
+ /** Mint an operator-shaped token (aud: "operator", host:admin) at `iss`. */
45
+ async function mintOperatorAt(h: H, iss: string): Promise<string> {
46
+ const signed = await signAccessToken(h.db, {
47
+ sub: h.userId,
48
+ scopes: ["parachute:host:admin", "parachute:host:auth"],
49
+ audience: "operator",
50
+ clientId: "parachute-hub",
51
+ issuer: iss,
52
+ ttlSeconds: 3600,
53
+ });
54
+ recordTokenMint(h.db, {
55
+ jti: signed.jti,
56
+ createdVia: "operator_mint",
57
+ subject: "operator",
58
+ clientId: "parachute-hub",
59
+ scopes: ["parachute:host:admin", "parachute:host:auth"],
60
+ expiresAt: signed.expiresAt,
61
+ });
62
+ return signed.token;
63
+ }
64
+
65
+ describe("validateHostAdminToken (hub#516)", () => {
66
+ // The live repro: operator token minted with the PUBLIC origin as `iss`,
67
+ // presented on a LOOPBACK request (known-origins set built from the loopback
68
+ // per-request issuer + the expose-state public origin) → ACCEPTED.
69
+ test("live repro: public-iss operator token accepted when public origin is in the known set", async () => {
70
+ const h = await makeH();
71
+ try {
72
+ const token = await mintOperatorAt(h, PUBLIC_TS);
73
+ const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
74
+ const { payload } = await validateHostAdminToken(h.db, token, knownIssuers);
75
+ expect(payload.iss).toBe(PUBLIC_TS);
76
+ expect(payload.sub).toBe(h.userId);
77
+ } finally {
78
+ h.cleanup();
79
+ }
80
+ });
81
+
82
+ test("loopback-iss operator token, loopback known set → accepted (unchanged)", async () => {
83
+ const h = await makeH();
84
+ try {
85
+ const token = await mintOperatorAt(h, LOOPBACK);
86
+ const { payload } = await validateHostAdminToken(h.db, token, [
87
+ LOOPBACK,
88
+ "http://localhost:1939",
89
+ ]);
90
+ expect(payload.iss).toBe(LOOPBACK);
91
+ } finally {
92
+ h.cleanup();
93
+ }
94
+ });
95
+
96
+ test("FOREIGN iss → REJECTED (no widening to arbitrary issuers)", async () => {
97
+ const h = await makeH();
98
+ try {
99
+ // A token the hub itself signed but whose iss is NOT one of its known
100
+ // origins. The signature verifies, but the belt-and-suspenders iss ∈
101
+ // known-origins check must still reject it.
102
+ const token = await mintOperatorAt(h, FOREIGN);
103
+ const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
104
+ await expect(validateHostAdminToken(h.db, token, knownIssuers)).rejects.toThrow(
105
+ /unexpected "iss" claim value/,
106
+ );
107
+ } finally {
108
+ h.cleanup();
109
+ }
110
+ });
111
+
112
+ test("empty known-origins set rejects every token (fails closed)", async () => {
113
+ const h = await makeH();
114
+ try {
115
+ const token = await mintOperatorAt(h, LOOPBACK);
116
+ await expect(validateHostAdminToken(h.db, token, [])).rejects.toThrow(
117
+ /unexpected "iss" claim value/,
118
+ );
119
+ } finally {
120
+ h.cleanup();
121
+ }
122
+ });
123
+
124
+ test("expose-state absent (loopback-only set): public-iss token rejected; loopback-iss accepted", async () => {
125
+ const h = await makeH();
126
+ try {
127
+ // Before `expose`, the known set is loopback-only — a public-iss token
128
+ // shouldn't exist yet, and if presented, it's not in the set → reject.
129
+ const publicTok = await mintOperatorAt(h, PUBLIC_TS);
130
+ const loopbackOnly = [LOOPBACK, "http://localhost:1939"];
131
+ await expect(validateHostAdminToken(h.db, publicTok, loopbackOnly)).rejects.toThrow(
132
+ /unexpected "iss" claim value/,
133
+ );
134
+
135
+ const loopbackTok = await mintOperatorAt(h, LOOPBACK);
136
+ const { payload } = await validateHostAdminToken(h.db, loopbackTok, loopbackOnly);
137
+ expect(payload.iss).toBe(LOOPBACK);
138
+ } finally {
139
+ h.cleanup();
140
+ }
141
+ });
142
+
143
+ test("signature still enforced: a token signed by an unknown key is rejected", async () => {
144
+ const h = await makeH();
145
+ try {
146
+ // Mint against a SECOND hub's key, then try to validate against the
147
+ // first hub's JWKS. The known-origins set includes the iss, but the
148
+ // signature check (step 1) must reject it before iss is even considered.
149
+ const other = await makeH();
150
+ try {
151
+ const foreignSigned = await mintOperatorAt(other, PUBLIC_TS);
152
+ await expect(validateHostAdminToken(h.db, foreignSigned, [PUBLIC_TS])).rejects.toThrow();
153
+ } finally {
154
+ other.cleanup();
155
+ }
156
+ } finally {
157
+ h.cleanup();
158
+ }
159
+ });
160
+
161
+ // Host-injection defense, made explicit. Simulate an attacker who got their
162
+ // own issuer into the known-issuers set via a forged Host header (the set is
163
+ // built from the per-request Host-derived issuer, so `iss ∈ knownIssuers`
164
+ // holds). They present a token signed by a DIFFERENT hub's key whose `iss`
165
+ // is exactly that injected origin. It is STILL REJECTED — the signature /
166
+ // JWKS check (step 1) fails before the `iss ∈ knownIssuers` check (step 2)
167
+ // is ever reached. This pins that known-issuers acceptance can never bypass
168
+ // the signature gate: membership in the set is necessary but not sufficient.
169
+ test("Host-injection: foreign-signed token with iss IN knownIssuers is STILL rejected (signature gate is first)", async () => {
170
+ const h = await makeH();
171
+ try {
172
+ const other = await makeH();
173
+ try {
174
+ // Token minted + signed by the OTHER hub at PUBLIC_TS.
175
+ const foreignSigned = await mintOperatorAt(other, PUBLIC_TS);
176
+ // The attacker's iss IS in our known set (e.g. injected via Host), yet
177
+ // validation against OUR JWKS must reject it on the signature check.
178
+ const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
179
+ expect(knownIssuers).toContain(PUBLIC_TS); // precondition: iss ∈ knownIssuers
180
+ await expect(validateHostAdminToken(h.db, foreignSigned, knownIssuers)).rejects.toThrow();
181
+ } finally {
182
+ other.cleanup();
183
+ }
184
+ } finally {
185
+ h.cleanup();
186
+ }
187
+ });
188
+
189
+ // Pin the invariant: OAuth/access-token validation stays STRICT per-request
190
+ // issuer. The relaxation lives only in validateHostAdminToken; the core
191
+ // primitive validateAccessToken(db, token, expectedIssuer) still rejects a
192
+ // mismatched iss. (Mirrors jwt-sign.test.ts's defense-in-depth test; kept
193
+ // here so the hub#516 guard travels with the relaxation.)
194
+ test("OAuth-token validation UNCHANGED: validateAccessToken pins iss strictly", async () => {
195
+ const h = await makeH();
196
+ try {
197
+ // A vault-aud OAuth-shaped token minted at the public origin.
198
+ const signed = await signAccessToken(h.db, {
199
+ sub: h.userId,
200
+ scopes: ["vault:read"],
201
+ audience: "vault.default",
202
+ clientId: "some-mcp-client",
203
+ issuer: PUBLIC_TS,
204
+ ttlSeconds: 3600,
205
+ });
206
+ // Strict per-request validation against a DIFFERENT (loopback) issuer
207
+ // still rejects — the relaxation must NOT leak to this path.
208
+ await expect(validateAccessToken(h.db, signed.token, LOOPBACK)).rejects.toThrow(
209
+ /unexpected "iss" claim value/,
210
+ );
211
+ // And it accepts when the issuer matches (sanity).
212
+ const { payload } = await validateAccessToken(h.db, signed.token, PUBLIC_TS);
213
+ expect(payload.aud).toBe("vault.default");
214
+ } finally {
215
+ h.cleanup();
216
+ }
217
+ });
218
+ });
@@ -296,6 +296,7 @@ describe("migrate — interactive + flag behavior", () => {
296
296
  throw new Error("prompt must not be called");
297
297
  },
298
298
  isTty: true,
299
+ hubUnitState: () => ({ state: "inactive" }),
299
300
  });
300
301
  expect(code).toBe(0);
301
302
  expect(logs.join("\n")).toMatch(/nothing to archive/i);
@@ -325,6 +326,7 @@ describe("migrate — interactive + flag behavior", () => {
325
326
  },
326
327
  list: true,
327
328
  isTty: true,
329
+ hubUnitState: () => ({ state: "inactive" }),
328
330
  });
329
331
  expect(code).toBe(0);
330
332
  expect(logs.join("\n")).toMatch(/--list — no changes made/);
@@ -351,6 +353,7 @@ describe("migrate — interactive + flag behavior", () => {
351
353
  },
352
354
  dryRun: true,
353
355
  isTty: true,
356
+ hubUnitState: () => ({ state: "inactive" }),
354
357
  });
355
358
  expect(code).toBe(0);
356
359
  expect(logs.join("\n")).toMatch(/dry-run/);
@@ -383,6 +386,7 @@ describe("migrate — interactive + flag behavior", () => {
383
386
  },
384
387
  yes: true,
385
388
  isTty: false,
389
+ hubUnitState: () => ({ state: "inactive" }),
386
390
  });
387
391
  expect(code).toBe(0);
388
392
  const archive = join(h.configDir, ".archive-2026-04-19");
@@ -510,6 +514,7 @@ describe("migrate — interactive + flag behavior", () => {
510
514
  throw new Error("prompt must not be called");
511
515
  },
512
516
  isTty: false,
517
+ hubUnitState: () => ({ state: "inactive" }),
513
518
  });
514
519
  expect(code).toBe(1);
515
520
  expect(logs.join("\n")).toMatch(/refusing to sweep without a TTY/i);
@@ -533,6 +538,7 @@ describe("migrate — interactive + flag behavior", () => {
533
538
  log: () => {},
534
539
  prompt: async () => answers.shift() ?? "n",
535
540
  isTty: true,
541
+ hubUnitState: () => ({ state: "inactive" }),
536
542
  });
537
543
  expect(code).toBe(1);
538
544
  // Aborted before any rename — daily.db still there.
@@ -557,6 +563,7 @@ describe("migrate — interactive + flag behavior", () => {
557
563
  log: () => {},
558
564
  prompt: async () => answers.shift() ?? "y",
559
565
  isTty: true,
566
+ hubUnitState: () => ({ state: "inactive" }),
560
567
  });
561
568
  expect(code).toBe(0);
562
569
  const archive = join(h.configDir, ".archive-2026-04-19");
@@ -588,6 +595,7 @@ describe("migrate — interactive + flag behavior", () => {
588
595
  },
589
596
  yes: true,
590
597
  isTty: false,
598
+ hubUnitState: () => ({ state: "inactive" }),
591
599
  });
592
600
  expect(code).toBe(0);
593
601
  const archivedLink = join(h.configDir, ".archive-2026-04-19", "logs");
@@ -621,6 +629,7 @@ describe("migrate — interactive + flag behavior", () => {
621
629
  },
622
630
  yes: true,
623
631
  isTty: false,
632
+ hubUnitState: () => ({ state: "inactive" }),
624
633
  });
625
634
  expect(code).toBe(0);
626
635
  // The "nothing recognized" exit branch — no archive directory created.
@@ -647,6 +656,7 @@ describe("migrate — interactive + flag behavior", () => {
647
656
  log: (l) => logs.push(l),
648
657
  prompt: async () => "n",
649
658
  isTty: true,
659
+ hubUnitState: () => ({ state: "inactive" }),
650
660
  });
651
661
  expect(code).toBe(1);
652
662
  expect(logs.join("\n")).toMatch(/aborted/i);
@@ -668,6 +678,7 @@ describe("migrate — interactive + flag behavior", () => {
668
678
  log: () => {},
669
679
  prompt: async () => "y",
670
680
  isTty: true,
681
+ hubUnitState: () => ({ state: "inactive" }),
671
682
  });
672
683
  expect(code).toBe(0);
673
684
  expect(existsSync(join(h.configDir, ".archive-2026-04-19", "server.yaml"))).toBe(true);
@@ -687,6 +698,7 @@ describe("migrate — interactive + flag behavior", () => {
687
698
  log: () => {},
688
699
  yes: true,
689
700
  isTty: false,
701
+ hubUnitState: () => ({ state: "inactive" }),
690
702
  });
691
703
  // Add more cruft and sweep again the same day
692
704
  touch(join(h.configDir, "channel.log"), "2");
@@ -696,6 +708,7 @@ describe("migrate — interactive + flag behavior", () => {
696
708
  log: () => {},
697
709
  yes: true,
698
710
  isTty: false,
711
+ hubUnitState: () => ({ state: "inactive" }),
699
712
  });
700
713
  const archive = join(h.configDir, ".archive-2026-04-19");
701
714
  expect(existsSync(join(archive, "server.yaml"))).toBe(true);
@@ -719,6 +732,7 @@ describe("migrate — interactive + flag behavior", () => {
719
732
  log: () => {},
720
733
  yes: true,
721
734
  isTty: false,
735
+ hubUnitState: () => ({ state: "inactive" }),
722
736
  });
723
737
  touch(join(h.configDir, "channel.log"), "2");
724
738
  await migrate({
@@ -727,6 +741,7 @@ describe("migrate — interactive + flag behavior", () => {
727
741
  log: () => {},
728
742
  yes: true,
729
743
  isTty: false,
744
+ hubUnitState: () => ({ state: "inactive" }),
730
745
  });
731
746
  expect(existsSync(join(h.configDir, ".archive-2026-04-19", "server.yaml"))).toBe(true);
732
747
  expect(existsSync(join(h.configDir, ".archive-2026-04-20", "channel.log"))).toBe(true);
@@ -749,6 +764,7 @@ describe("migrate — interactive + flag behavior", () => {
749
764
  log: () => {},
750
765
  yes: true,
751
766
  isTty: false,
767
+ hubUnitState: () => ({ state: "inactive" }),
752
768
  });
753
769
  const archive = join(h.configDir, ".archive-2026-04-19");
754
770
  const contents = readdirSync(archive);
@@ -3,6 +3,8 @@ import { chmodSync, mkdtempSync, rmSync, statSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import type { ExposeState } from "../expose-state.ts";
7
+ import { writeExposeState } from "../expose-state.ts";
6
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
7
9
  import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
8
10
  import {
@@ -14,6 +16,7 @@ import {
14
16
  OPERATOR_TOKEN_SCOPE_SETS,
15
17
  OPERATOR_TOKEN_SCOPE_SET_CLAIM,
16
18
  OPERATOR_TOKEN_TTL_SECONDS,
19
+ buildKnownIssuersForOperatorToken,
17
20
  issueOperatorToken,
18
21
  mintOperatorToken,
19
22
  operatorTokenPath,
@@ -478,6 +481,280 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
478
481
  });
479
482
  });
480
483
 
484
+ // hub#516 — the operator token's `iss` is the hub's PUBLIC origin after
485
+ // `parachute expose`, but callers resolve `issuer` inconsistently (status →
486
+ // loopback, lifecycle → public). The client-side validation now accepts the
487
+ // token if its `iss` is ANY of the hub's known origins (loopback aliases ∪
488
+ // expose-state public origin ∪ env), gated FIRST on the JWKS signature. These
489
+ // tests pin the four-corner matrix: public-iss accepted with loopback config
490
+ // (the status bug), loopback-iss accepted, foreign-iss rejected, and
491
+ // foreign-SIGNATURE rejected even when its iss is in the known set.
492
+ const PUBLIC_ISSUER = "https://parachute.taildf9ce2.ts.net";
493
+
494
+ /** Minimal valid expose-state advertising `hubOrigin` (the public origin). */
495
+ function exposeStateForOrigin(hubOrigin: string): ExposeState {
496
+ return {
497
+ version: 1,
498
+ layer: "tailnet",
499
+ mode: "path",
500
+ canonicalFqdn: new URL(hubOrigin).host,
501
+ port: 1939,
502
+ funnel: false,
503
+ entries: [],
504
+ hubOrigin,
505
+ };
506
+ }
507
+
508
+ describe("useOperatorTokenWithAutoRotate known-issuer set (hub#516)", () => {
509
+ // The PARACHUTE_HUB_ORIGIN / RENDER_EXTERNAL_URL / FLY_APP_NAME env vars feed
510
+ // the platform-origin seed of the known-issuer set. Tests that assert a
511
+ // public-iss is REJECTED when expose-state is absent must not have a stray
512
+ // env public origin leaking the iss back in — clear them around each test.
513
+ function withCleanPlatformEnv<T>(fn: () => T): T {
514
+ const saved = {
515
+ hub: process.env.PARACHUTE_HUB_ORIGIN,
516
+ render: process.env.RENDER_EXTERNAL_URL,
517
+ fly: process.env.FLY_APP_NAME,
518
+ };
519
+ // Computed-key delete (not `delete process.env.FOO`) so biome's noDelete
520
+ // doesn't fire — matches spawn-env-propagation.test.ts. A `= undefined`
521
+ // assignment would coerce to the string "undefined" and leak a bogus
522
+ // origin into the known-issuer set, so a real delete is required here.
523
+ for (const k of ["PARACHUTE_HUB_ORIGIN", "RENDER_EXTERNAL_URL", "FLY_APP_NAME"]) {
524
+ delete process.env[k];
525
+ }
526
+ try {
527
+ return fn();
528
+ } finally {
529
+ if (saved.hub !== undefined) process.env.PARACHUTE_HUB_ORIGIN = saved.hub;
530
+ if (saved.render !== undefined) process.env.RENDER_EXTERNAL_URL = saved.render;
531
+ if (saved.fly !== undefined) process.env.FLY_APP_NAME = saved.fly;
532
+ }
533
+ }
534
+
535
+ test("accepts a PUBLIC-iss operator token when config resolves loopback (the status bug)", async () => {
536
+ await withCleanPlatformEnv(async () => {
537
+ const h = makeHarness();
538
+ try {
539
+ const db = openHubDb(hubDbPath(h.dir));
540
+ try {
541
+ rotateSigningKey(db);
542
+ // Mint the operator token under the hub's PUBLIC origin — what
543
+ // happens on an exposed box (selfHealOperatorTokenIssuer re-mints to
544
+ // the public iss).
545
+ const issued = await issueOperatorToken(db, "user-abc", {
546
+ dir: h.dir,
547
+ issuer: PUBLIC_ISSUER,
548
+ });
549
+ // Expose-state advertises the public origin (so it lands in the
550
+ // known set). The CALLER resolves loopback (status's hardcoded path).
551
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
552
+
553
+ const used = await useOperatorTokenWithAutoRotate(db, {
554
+ configDir: h.dir,
555
+ issuer: TEST_ISSUER, // loopback — the status scenario
556
+ });
557
+ expect(used).not.toBeNull();
558
+ expect(used?.status.kind).toBe("fresh");
559
+ expect(used?.token).toBe(issued.token);
560
+ expect(used?.payload.iss).toBe(PUBLIC_ISSUER);
561
+ } finally {
562
+ db.close();
563
+ }
564
+ } finally {
565
+ h.cleanup();
566
+ }
567
+ });
568
+ });
569
+
570
+ test("accepts a loopback-iss operator token with loopback config", async () => {
571
+ await withCleanPlatformEnv(async () => {
572
+ const h = makeHarness();
573
+ try {
574
+ const db = openHubDb(hubDbPath(h.dir));
575
+ try {
576
+ rotateSigningKey(db);
577
+ const issued = await issueOperatorToken(db, "user-abc", {
578
+ dir: h.dir,
579
+ issuer: TEST_ISSUER,
580
+ });
581
+ // No expose-state — known set is loopback aliases only.
582
+ const used = await useOperatorTokenWithAutoRotate(db, {
583
+ configDir: h.dir,
584
+ issuer: TEST_ISSUER,
585
+ });
586
+ expect(used).not.toBeNull();
587
+ expect(used?.status.kind).toBe("fresh");
588
+ expect(used?.token).toBe(issued.token);
589
+ } finally {
590
+ db.close();
591
+ }
592
+ } finally {
593
+ h.cleanup();
594
+ }
595
+ });
596
+ });
597
+
598
+ test("rejects a token whose iss is FOREIGN to the known set", async () => {
599
+ await withCleanPlatformEnv(async () => {
600
+ const h = makeHarness();
601
+ try {
602
+ const db = openHubDb(hubDbPath(h.dir));
603
+ try {
604
+ rotateSigningKey(db);
605
+ // Hub-SIGNED (so the signature gate passes) but stamped with an iss
606
+ // that's neither loopback nor in expose-state nor env. Must reject.
607
+ const issued = await issueOperatorToken(db, "user-abc", {
608
+ dir: h.dir,
609
+ issuer: "https://evil.example.com",
610
+ });
611
+ expect(issued.token.length).toBeGreaterThan(0);
612
+ // expose-state advertises the PUBLIC origin (not the evil one), so
613
+ // the foreign iss is not in the known set.
614
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
615
+
616
+ await expect(
617
+ useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
618
+ ).rejects.toThrow(/unexpected "iss" claim value/);
619
+ } finally {
620
+ db.close();
621
+ }
622
+ } finally {
623
+ h.cleanup();
624
+ }
625
+ });
626
+ });
627
+
628
+ test("rejects a FOREIGN-SIGNED token even when its iss is in the known set (signature gate first)", async () => {
629
+ await withCleanPlatformEnv(async () => {
630
+ const h = makeHarness();
631
+ const foreign = makeHarness();
632
+ try {
633
+ const db = openHubDb(hubDbPath(h.dir));
634
+ // A DIFFERENT hub (different signing key) mints a token stamped with an
635
+ // iss that IS in our known set. The signature won't verify against our
636
+ // JWKS, so it must be rejected at the signature gate regardless of iss.
637
+ const foreignDb = openHubDb(hubDbPath(foreign.dir));
638
+ try {
639
+ rotateSigningKey(db);
640
+ rotateSigningKey(foreignDb);
641
+ const foreignToken = await mintOperatorToken(foreignDb, "user-abc", {
642
+ issuer: PUBLIC_ISSUER,
643
+ });
644
+ await writeOperatorTokenFile(foreignToken.token, h.dir);
645
+ // Our expose-state advertises PUBLIC_ISSUER — so the iss WOULD pass
646
+ // the belt-and-suspenders check. The signature gate must still reject.
647
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
648
+
649
+ await expect(
650
+ useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
651
+ ).rejects.toThrow();
652
+ } finally {
653
+ db.close();
654
+ foreignDb.close();
655
+ }
656
+ } finally {
657
+ h.cleanup();
658
+ foreign.cleanup();
659
+ }
660
+ });
661
+ });
662
+
663
+ test("expose-state absent: loopback-iss accepted, public-iss rejected (no public origin known)", async () => {
664
+ await withCleanPlatformEnv(async () => {
665
+ const h = makeHarness();
666
+ try {
667
+ const db = openHubDb(hubDbPath(h.dir));
668
+ try {
669
+ rotateSigningKey(db);
670
+ // A loopback-iss token is accepted (loopback alias is always in the set).
671
+ const loopbackTok = await issueOperatorToken(db, "user-abc", {
672
+ dir: h.dir,
673
+ issuer: TEST_ISSUER,
674
+ });
675
+ const usedLoopback = await useOperatorTokenWithAutoRotate(db, {
676
+ configDir: h.dir,
677
+ issuer: TEST_ISSUER,
678
+ });
679
+ expect(usedLoopback?.token).toBe(loopbackTok.token);
680
+
681
+ // Overwrite with a public-iss token. No expose-state, no env public
682
+ // origin → the public iss is NOT known → reject. (Correct: with no
683
+ // exposure configured, the hub doesn't legitimately answer on it.)
684
+ const publicTok = await issueOperatorToken(db, "user-abc", {
685
+ dir: h.dir,
686
+ issuer: PUBLIC_ISSUER,
687
+ });
688
+ expect(publicTok.token.length).toBeGreaterThan(0);
689
+ await expect(
690
+ useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
691
+ ).rejects.toThrow(/unexpected "iss" claim value/);
692
+ } finally {
693
+ db.close();
694
+ }
695
+ } finally {
696
+ h.cleanup();
697
+ }
698
+ });
699
+ });
700
+
701
+ test("auto-rotate still fires for a near-expiry token validated via the known set", async () => {
702
+ await withCleanPlatformEnv(async () => {
703
+ const h = makeHarness();
704
+ try {
705
+ const db = openHubDb(hubDbPath(h.dir));
706
+ try {
707
+ rotateSigningKey(db);
708
+ // Public-iss + 1-day TTL (below the 7d threshold). Validates via the
709
+ // known set (expose-state public origin), then auto-rotates.
710
+ const original = await issueOperatorToken(db, "user-abc", {
711
+ dir: h.dir,
712
+ issuer: PUBLIC_ISSUER,
713
+ scopeSet: "start",
714
+ ttlSeconds: 24 * 60 * 60,
715
+ });
716
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
717
+
718
+ const used = await useOperatorTokenWithAutoRotate(db, {
719
+ configDir: h.dir,
720
+ issuer: PUBLIC_ISSUER, // lifecycle's public-origin scenario
721
+ });
722
+ expect(used).not.toBeNull();
723
+ expect(used?.status.kind).toBe("rotated");
724
+ expect(used?.rotated?.scopeSet).toBe("start");
725
+ expect(used?.token).not.toBe(original.token);
726
+ // Re-mint stamps opts.issuer as the new iss; still validates.
727
+ const validated = await validateAccessToken(db, used!.token, PUBLIC_ISSUER);
728
+ expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
729
+ } finally {
730
+ db.close();
731
+ }
732
+ } finally {
733
+ h.cleanup();
734
+ }
735
+ });
736
+ });
737
+
738
+ test("buildKnownIssuersForOperatorToken includes loopback aliases + expose-state public origin", async () => {
739
+ await withCleanPlatformEnv(() => {
740
+ const h = makeHarness();
741
+ try {
742
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
743
+ const set = buildKnownIssuersForOperatorToken(h.dir, TEST_ISSUER);
744
+ expect(set).toContain("http://127.0.0.1:1939");
745
+ expect(set).toContain("http://localhost:1939");
746
+ expect(set).toContain(PUBLIC_ISSUER);
747
+ // The seed issuer is included too.
748
+ expect(set).toContain(TEST_ISSUER);
749
+ // A foreign origin is NOT present.
750
+ expect(set).not.toContain("https://evil.example.com");
751
+ } finally {
752
+ h.cleanup();
753
+ }
754
+ });
755
+ });
756
+ });
757
+
481
758
  // closes #212 Phase 1 — operator-mint paths write to the unified token
482
759
  // registry so they show up in the revocation list and admin UI alongside
483
760
  // OAuth refresh tokens and CLI mints.
@@ -40,8 +40,8 @@ import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
40
40
  import { isLinked as defaultIsLinked } from "./bun-link.ts";
41
41
  import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
42
42
  import { buildModuleSpawnRequest } from "./commands/serve-boot.ts";
43
+ import { validateHostAdminToken } from "./host-admin-token-validation.ts";
43
44
  import { getModuleInstallChannel } from "./hub-settings.ts";
44
- import { validateAccessToken } from "./jwt-sign.ts";
45
45
  import { readModuleManifest } from "./module-manifest.ts";
46
46
  import { refreshWellKnown, stampInstallDirOnRow } from "./post-install.ts";
47
47
  import {
@@ -180,6 +180,21 @@ export interface RunOpts {
180
180
  export interface ApiModulesOpsDeps {
181
181
  db: Database;
182
182
  issuer: string;
183
+ /**
184
+ * The SET of origins the hub legitimately answers on — loopback aliases ∪
185
+ * expose-state public origin ∪ platform/env origin ∪ the per-request
186
+ * `issuer`. The host-admin bearer's `iss` is validated against THIS set, not
187
+ * the single per-request `issuer` (hub#516): the CLI drives these endpoints
188
+ * on loopback presenting the operator token, whose `iss` is the hub's public
189
+ * origin after `expose`. Built via `buildHubBoundOrigins` at the call site.
190
+ *
191
+ * Optional for back-compat with callers that don't construct it (the
192
+ * first-boot wizard's `runInstall`, tests). When absent, `authorize` falls
193
+ * back to the single-element `[issuer]` set — i.e. the prior strict
194
+ * per-request behavior — so the relaxation is opt-in at the HTTP call site
195
+ * and the non-HTTP install path is unaffected.
196
+ */
197
+ knownIssuers?: readonly string[];
183
198
  manifestPath: string;
184
199
  configDir: string;
185
200
  supervisor: Supervisor;
@@ -280,7 +295,18 @@ async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Respons
280
295
  const bearer = auth.slice("Bearer ".length).trim();
281
296
  if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
282
297
  try {
283
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
298
+ // Host-admin (operator / SPA) token validation: accept the `iss` against
299
+ // the SET of origins the hub answers on, not the single per-request issuer
300
+ // (hub#516). This surface only ever accepts the hub's own self-issued
301
+ // host-admin credentials (the `parachute:host:admin` scope below is
302
+ // non-requestable via OAuth), so the relaxation cannot reach an OAuth
303
+ // token's validation. Falls back to the strict single-issuer set when
304
+ // `knownIssuers` isn't wired (non-HTTP install path / tests).
305
+ const validated = await validateHostAdminToken(
306
+ deps.db,
307
+ bearer,
308
+ deps.knownIssuers ?? [deps.issuer],
309
+ );
284
310
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
285
311
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
286
312
  }
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import type { Database } from "bun:sqlite";
28
+ import { validateHostAdminToken } from "./host-admin-token-validation.ts";
28
29
  import {
29
30
  type ModuleInstallChannel,
30
31
  getModuleInstallChannel,
@@ -116,6 +117,18 @@ export type CuratedModuleShort = (typeof CURATED_MODULES)[number];
116
117
  export interface ApiModulesDeps {
117
118
  db: Database;
118
119
  issuer: string;
120
+ /**
121
+ * The SET of origins the hub legitimately answers on — loopback aliases ∪
122
+ * expose-state public origin ∪ platform/env origin ∪ the per-request
123
+ * `issuer`. The host-admin bearer's `iss` is validated against THIS set, not
124
+ * the single per-request `issuer` (hub#516): `parachute status` reads this
125
+ * endpoint on loopback presenting the operator token, whose `iss` is the
126
+ * hub's public origin after `expose`. Built via `buildHubBoundOrigins` at the
127
+ * call site. When absent, falls back to the single-element `[issuer]` set
128
+ * (the prior strict per-request behavior) so non-HTTP callers / tests are
129
+ * unaffected.
130
+ */
131
+ knownIssuers?: readonly string[];
119
132
  manifestPath: string;
120
133
  supervisor?: Supervisor;
121
134
  /**
@@ -312,10 +325,20 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
312
325
  return jsonError(401, "unauthenticated", "empty bearer token");
313
326
  }
314
327
 
315
- // Bearer validation.
328
+ // Bearer validation. Host-admin (operator / SPA) token: accept the `iss`
329
+ // against the SET of origins the hub answers on, not the single per-request
330
+ // issuer (hub#516) — `parachute status` reads this on loopback presenting the
331
+ // operator token, whose `iss` is the hub's public origin after `expose`. This
332
+ // surface gates on the non-requestable `parachute:host:auth` scope below, so
333
+ // the relaxation only ever touches the hub's own self-issued host-admin
334
+ // credentials and cannot reach an OAuth token's validation.
316
335
  let bearerScopes: string[];
317
336
  try {
318
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
337
+ const validated = await validateHostAdminToken(
338
+ deps.db,
339
+ bearer,
340
+ deps.knownIssuers ?? [deps.issuer],
341
+ );
319
342
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
320
343
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
321
344
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Issuer validation for the hub's OWN host-admin credentials (the operator
3
+ * token + the SPA host-admin token) on the loopback module-ops surfaces.
4
+ *
5
+ * ## Why this is its own helper (hub#516)
6
+ *
7
+ * The on-box CLI drives the hub on loopback (`127.0.0.1:1939`) presenting
8
+ * `~/.parachute/operator.token` — a hub-SELF-issued JWT (`aud: "operator"`,
9
+ * scope-set carries `parachute:host:admin`). After `parachute expose`, the
10
+ * operator token's `iss` is the hub's PUBLIC origin (e.g.
11
+ * `https://parachute.taildf9ce2.ts.net`), because §3.1 self-heals it there so
12
+ * on-box services validating public-origin bearers accept it.
13
+ *
14
+ * But the hub resolves its issuer PER-REQUEST from the Host header
15
+ * (`resolveIssuer` in hub-server.ts, "closes #245") — so a LOOPBACK request
16
+ * resolves the issuer to `http://127.0.0.1:1939`. The strict per-request
17
+ * `validateAccessToken(db, token, <loopback-issuer>)` then rejects the
18
+ * operator token's PUBLIC `iss` as `unexpected "iss" claim value`. Net:
19
+ * `parachute status` / `start|stop|restart <svc>` fail on ANY exposed box
20
+ * (tailnet or Cloudflare), even though the credential is the hub's own,
21
+ * presented on the hub's own loopback.
22
+ *
23
+ * ## The scoped relaxation
24
+ *
25
+ * The operator token (and the SPA host-admin token) are SELF-issued — the hub
26
+ * signs them with its own key, and {@link validateAccessToken} verifies that
27
+ * signature against the hub's JWKS. The signature already proves provenance:
28
+ * the only tokens that can verify are ones THIS hub minted. So for these
29
+ * host-admin credentials, the `iss` claim should be accepted if it matches ANY
30
+ * origin the hub legitimately answers on — loopback ∪ expose-state public
31
+ * origin ∪ platform/env origin — not just the single per-request one.
32
+ *
33
+ * We deliberately do NOT drop the `iss` check entirely (belt-and-suspenders):
34
+ * a token whose `iss` is none of the hub's known origins is still rejected,
35
+ * so a hypothetical hub-signed token minted for a DIFFERENT origin can't be
36
+ * replayed here.
37
+ *
38
+ * ## What this does NOT touch
39
+ *
40
+ * OAuth / access-token validation (vault / MCP tokens, `aud: "vault.<name>"`)
41
+ * stays STRICT per-request-issuer and lives on entirely separate code paths
42
+ * (the resource servers' own validators, hub's `/api/auth/*`, etc.). This
43
+ * helper is invoked ONLY from the two loopback host-admin module surfaces
44
+ * (`/api/modules` GET — the `status` read; `/api/modules/:short/*` POST — the
45
+ * lifecycle ops), both of which already gate on the non-requestable
46
+ * `parachute:host:admin` / `parachute:host:auth` scopes that no OAuth token
47
+ * can carry. The relaxation cannot reach an OAuth token's validation.
48
+ */
49
+ import type { Database } from "bun:sqlite";
50
+ import { type ValidatedAccessToken, validateAccessToken } from "./jwt-sign.ts";
51
+
52
+ /**
53
+ * Validate a host-admin bearer (operator token / SPA host-admin token)
54
+ * presented on a loopback module surface, accepting its `iss` against the SET
55
+ * of origins the hub legitimately answers on rather than the single
56
+ * per-request issuer.
57
+ *
58
+ * Verification order:
59
+ * 1. Signature + `exp` + revocation, via {@link validateAccessToken} WITHOUT
60
+ * an `expectedIssuer` — the signature proves the hub minted it (only this
61
+ * hub's key can produce a JWS that verifies against its JWKS). A throw
62
+ * here (bad/unknown/expired kid, jose `exp`, revoked jti) propagates
63
+ * unchanged.
64
+ * 2. `iss` ∈ `knownIssuers` — belt-and-suspenders. Even though the signature
65
+ * proves provenance, we still require the issuer to be one of the hub's
66
+ * own origins. A foreign/garbage `iss` throws (matching the per-request
67
+ * strict check's message shape so callers' error rendering is unchanged).
68
+ *
69
+ * `knownIssuers` is the hub's own valid origin set — typically built from
70
+ * `buildHubBoundOrigins` (per-request issuer ∪ loopback aliases ∪
71
+ * expose-state public origin ∪ platform/env origin). Empty/garbage entries are
72
+ * the caller's responsibility to filter; an empty set rejects every token
73
+ * (fails closed).
74
+ *
75
+ * @throws Error when the signature/exp/revocation check fails, or when `iss`
76
+ * is absent / not a string / not in `knownIssuers`.
77
+ */
78
+ export async function validateHostAdminToken(
79
+ db: Database,
80
+ token: string,
81
+ knownIssuers: readonly string[],
82
+ ): Promise<ValidatedAccessToken> {
83
+ // Step 1: signature + exp + revocation, NOT pinning iss. Provenance is
84
+ // proved by the signature verifying against the hub's own JWKS.
85
+ const validated = await validateAccessToken(db, token);
86
+
87
+ // Step 2: belt-and-suspenders iss ∈ known-origins. Never widen to arbitrary
88
+ // issuers — the token's iss must be one of the hub's own legitimate origins.
89
+ const iss = validated.payload.iss;
90
+ if (typeof iss !== "string" || !knownIssuers.includes(iss)) {
91
+ // Mirror jose's wording so the CLI's bearer-invalid error path renders the
92
+ // same way it did for the strict per-request check.
93
+ throw new Error('unexpected "iss" claim value');
94
+ }
95
+ return validated;
96
+ }
package/src/hub-server.ts CHANGED
@@ -1783,9 +1783,16 @@ export function hubFetch(
1783
1783
 
1784
1784
  if (pathname === "/api/modules") {
1785
1785
  if (!getDb) return dbNotConfigured();
1786
+ const od = oauthDeps(req);
1786
1787
  const modulesDeps: Parameters<typeof handleApiModules>[1] = {
1787
1788
  db: getDb(),
1788
- issuer: oauthDeps(req).issuer,
1789
+ issuer: od.issuer,
1790
+ // hub#516: validate the host-admin bearer's `iss` against the SET of
1791
+ // origins the hub answers on (loopback ∪ expose-state ∪ env/platform ∪
1792
+ // per-request issuer), so `parachute status` works on an exposed box
1793
+ // where the operator token carries the public origin but the loopback
1794
+ // request resolves the loopback issuer.
1795
+ knownIssuers: od.hubBoundOrigins(),
1789
1796
  manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1790
1797
  };
1791
1798
  if (deps?.supervisor !== undefined) modulesDeps.supervisor = deps.supervisor;
@@ -1841,9 +1848,13 @@ export function hubFetch(
1841
1848
  }
1842
1849
  const opId = decodeURIComponent(pathname.slice("/api/modules/operations/".length));
1843
1850
  if (!opId || opId.includes("/")) return new Response("not found", { status: 404 });
1851
+ const od = oauthDeps(req);
1844
1852
  return handleOperationGet(req, opId, {
1845
1853
  db: getDb(),
1846
- issuer: oauthDeps(req).issuer,
1854
+ issuer: od.issuer,
1855
+ // hub#516: see the `/api/modules` deps note — the CLI polls async ops
1856
+ // on loopback with the operator token (public `iss`).
1857
+ knownIssuers: od.hubBoundOrigins(),
1847
1858
  manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1848
1859
  configDir: CONFIG_DIR,
1849
1860
  supervisor: deps.supervisor,
@@ -1888,9 +1899,14 @@ export function hubFetch(
1888
1899
  }
1889
1900
  const match = parseModulesPath(pathname);
1890
1901
  if (!match) return new Response("not found", { status: 404 });
1902
+ const od = oauthDeps(req);
1891
1903
  const opsDeps = {
1892
1904
  db: getDb(),
1893
- issuer: oauthDeps(req).issuer,
1905
+ issuer: od.issuer,
1906
+ // hub#516: the CLI drives start/stop/restart/install/upgrade/uninstall
1907
+ // on loopback with the operator token, whose `iss` is the hub's public
1908
+ // origin after `expose`. Validate against the hub's known-origin set.
1909
+ knownIssuers: od.hubBoundOrigins(),
1894
1910
  manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1895
1911
  configDir: CONFIG_DIR,
1896
1912
  supervisor: deps.supervisor,
@@ -30,7 +30,12 @@ import type { Database } from "bun:sqlite";
30
30
  import { promises as fs } from "node:fs";
31
31
  import { join } from "node:path";
32
32
  import { configDir } from "./config.ts";
33
+ import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
34
+ import { validateHostAdminToken } from "./host-admin-token-validation.ts";
35
+ import { readHubPort } from "./hub-control.ts";
36
+ import { HUB_UNIT_DEFAULT_PORT } from "./hub-unit.ts";
33
37
  import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
38
+ import { buildHubBoundOrigins } from "./origin-check.ts";
34
39
  import { isLoopbackOrigin } from "./vault-hub-origin-env.ts";
35
40
 
36
41
  export const OPERATOR_TOKEN_FILENAME = "operator.token";
@@ -279,7 +284,14 @@ export class OperatorTokenExpiredError extends Error {
279
284
  }
280
285
 
281
286
  export interface UseOperatorTokenOpts {
282
- /** Hub origin used as `iss` validator. Required. */
287
+ /**
288
+ * Hub origin the caller resolved. Required. As of hub#516 this is a SEED of
289
+ * the known-issuer SET the token's `iss` is validated against
290
+ * ({@link buildKnownIssuersForOperatorToken}), not the sole `iss` validator —
291
+ * so a caller that resolves loopback (`status`) still accepts a public-`iss`
292
+ * operator token from `expose-state.json`, and vice versa. It also remains
293
+ * the `iss` stamped on an auto-rotated re-mint.
294
+ */
283
295
  issuer: string;
284
296
  /** configDir override (where operator.token lives). Defaults to `configDir()`. */
285
297
  configDir?: string;
@@ -345,9 +357,78 @@ export interface UsedOperatorToken {
345
357
  status: RotationStatus;
346
358
  }
347
359
 
360
+ /**
361
+ * Compose the Fly.io default public origin from `FLY_APP_NAME`, mirroring the
362
+ * server-side `flyDefaultOrigin` (hub-server.ts) so the client-side
363
+ * known-issuer set matches the origin the hub stamps tokens with on Fly. Kept
364
+ * local (a one-liner) rather than imported to avoid pulling hub-server.ts /
365
+ * serve.ts into the CLI auth path. Fly slugs never contain `/`; anything with
366
+ * one is spoofed or malformed.
367
+ */
368
+ function flyDefaultOrigin(env: NodeJS.ProcessEnv): string | undefined {
369
+ const app = env.FLY_APP_NAME;
370
+ if (typeof app !== "string" || app.length === 0 || app.includes("/")) return undefined;
371
+ return `https://${app}.fly.dev`;
372
+ }
373
+
374
+ /**
375
+ * Assemble the SET of origins this hub legitimately answers on, from on-disk
376
+ * client state — the client-side mirror of hub-server.ts's per-request
377
+ * `buildHubBoundOrigins` call (hub#516). The operator token's `iss` is
378
+ * validated against this set rather than a single issuer, so a token whose
379
+ * `iss` is the hub's PUBLIC origin (stamped after `parachute expose`) is
380
+ * accepted even when the CLI command resolved a loopback `issuer` (the
381
+ * `status` case) — and vice versa.
382
+ *
383
+ * The set is:
384
+ * - `seedIssuer` — the issuer the caller resolved (loopback for `status`,
385
+ * `r.hubOrigin` / public for lifecycle). Kept as a seed so callers that
386
+ * pass a value still contribute it; never the sole gate.
387
+ * - loopback aliases — `http://127.0.0.1:<port>` AND `http://localhost:<port>`
388
+ * for the hub's port (`readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT`),
389
+ * matching the hub's own loopback alias set (`buildHubBoundOrigins`).
390
+ * - the expose-state public origin — `expose-state.json`'s `hubOrigin`, the
391
+ * public URL the hub stamps on tokens once exposed.
392
+ * - the platform/env public origin — `PARACHUTE_HUB_ORIGIN` ∪
393
+ * `RENDER_EXTERNAL_URL` ∪ the composed Fly default — for container deploys
394
+ * where the public origin comes from the platform, not expose-state.
395
+ *
396
+ * Provenance is NOT established here: `validateHostAdminToken` runs the JWKS
397
+ * signature check FIRST and unconditionally. This set is the belt-and-suspenders
398
+ * `iss` allowlist layered on top — a foreign `iss` (not loopback / expose /
399
+ * env) is rejected, and an empty set fails closed.
400
+ */
401
+ export function buildKnownIssuersForOperatorToken(
402
+ configDirOverride: string | undefined,
403
+ seedIssuer: string,
404
+ ): readonly string[] {
405
+ const dir = configDirOverride ?? configDir();
406
+ const loopbackPort = readHubPort(dir) ?? HUB_UNIT_DEFAULT_PORT;
407
+ let exposeHubOrigin: string | undefined;
408
+ try {
409
+ exposeHubOrigin = readExposeState(join(dir, "expose-state.json"))?.hubOrigin;
410
+ } catch {
411
+ // A malformed expose-state.json must never lock the operator out of the
412
+ // CLI — the seed issuer + loopback aliases already cover legitimate
413
+ // loopback access; treat it as "no public origin known."
414
+ exposeHubOrigin = undefined;
415
+ }
416
+ const platformOrigin =
417
+ process.env.PARACHUTE_HUB_ORIGIN ??
418
+ process.env.RENDER_EXTERNAL_URL ??
419
+ flyDefaultOrigin(process.env);
420
+ return buildHubBoundOrigins({
421
+ issuer: seedIssuer,
422
+ loopbackPort,
423
+ ...(exposeHubOrigin !== undefined ? { exposeHubOrigin } : {}),
424
+ ...(platformOrigin !== undefined ? { platformOrigin } : {}),
425
+ });
426
+ }
427
+
348
428
  /**
349
429
  * The canonical "use the operator token in a CLI flow" helper. Reads
350
- * `~/.parachute/operator.token`, validates against `db` + `issuer`, and:
430
+ * `~/.parachute/operator.token`, validates against `db` + the hub's
431
+ * known-issuer SET, and:
351
432
  *
352
433
  * - If the token has fully expired: throws `OperatorTokenExpiredError`
353
434
  * with an actionable message. Does NOT auto-rotate from a dead token —
@@ -397,9 +478,19 @@ export async function useOperatorTokenWithAutoRotate(
397
478
  if (!token) return null;
398
479
  const now = opts.now ?? (() => new Date());
399
480
 
400
- // Validation failures (signature mismatch, wrong issuer, missing kid,
401
- // expired-by-jose) bubble out for the caller to render the right message.
402
- const validated = await validateAccessToken(db, token, opts.issuer);
481
+ // Validate against the hub's KNOWN-ISSUER SET, not a single `opts.issuer`
482
+ // (hub#516). The operator token is the hub's OWN self-issued credential; its
483
+ // `iss` is the hub's loopback origin before `expose` and its PUBLIC origin
484
+ // after. Callers resolve `opts.issuer` inconsistently — `status` hardcodes
485
+ // loopback, lifecycle uses `r.hubOrigin` (public when exposed) — so a single
486
+ // per-issuer check rejected the public-`iss` operator token on `status` even
487
+ // though `restart` worked. `validateHostAdminToken` (the #517 helper) gates
488
+ // on the JWKS SIGNATURE first+unconditionally (provenance), then accepts the
489
+ // `iss` if it's ANY origin the hub legitimately answers on. Validation
490
+ // failures (signature mismatch, missing kid, expired-by-jose, revoked, or an
491
+ // `iss` foreign to the whole set) bubble out for the caller to render.
492
+ const knownIssuers = buildKnownIssuersForOperatorToken(opts.configDir, opts.issuer);
493
+ const validated = await validateHostAdminToken(db, token, knownIssuers);
403
494
  const { payload } = validated;
404
495
 
405
496
  const exp = typeof payload.exp === "number" ? payload.exp : 0;
@@ -74,6 +74,16 @@ export function buildHubBoundOrigins(opts: {
74
74
  // Malformed URL — skip.
75
75
  }
76
76
  };
77
+ // `opts.issuer` is the PER-REQUEST issuer, which `resolveIssuer` derives
78
+ // from the request's Host header (hub-server.ts, "closes #245"). Including a
79
+ // Host-derived value in the known-issuers set is SAFE — it is NOT a
80
+ // forged-`iss` bypass. Token provenance is signature-gated: the JWKS verify
81
+ // in `validateAccessToken` / `validateHostAdminToken` runs UNCONDITIONALLY
82
+ // FIRST, before `iss` is ever checked against this set. So a token whose
83
+ // `iss` matches an attacker-injected Host (and thus lands in this set) but
84
+ // which isn't signed by THIS hub's key is still rejected at the signature
85
+ // step. The known-issuers membership check is belt-and-suspenders layered on
86
+ // top of the signature gate, never a substitute for it.
77
87
  add(opts.issuer);
78
88
  add(opts.exposeHubOrigin);
79
89
  add(opts.platformOrigin);