@openparachute/hub 0.5.14-rc.17 → 0.5.14-rc.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.17",
3
+ "version": "0.5.14-rc.18",
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": {
@@ -13,8 +13,16 @@ import {
13
13
  } from "../commands/lifecycle.ts";
14
14
  import { readEnvFileValues } from "../env-file.ts";
15
15
  import { writeHubPort } from "../hub-control.ts";
16
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
17
+ import { validateAccessToken } from "../jwt-sign.ts";
18
+ import {
19
+ OPERATOR_TOKEN_SCOPE_SET_CLAIM,
20
+ issueOperatorToken,
21
+ readOperatorTokenFile,
22
+ } from "../operator-token.ts";
16
23
  import { ensureLogPath, logPath, readPid, writePid } from "../process-state.ts";
17
24
  import { upsertService } from "../services-manifest.ts";
25
+ import { rotateSigningKey } from "../signing-keys.ts";
18
26
 
19
27
  interface Harness {
20
28
  configDir: string;
@@ -1525,6 +1533,149 @@ describe("parachute start|stop|restart hub", () => {
1525
1533
  }
1526
1534
  });
1527
1535
 
1536
+ // hub#481 — `start hub` self-heals a stale operator-token issuer. Tests use
1537
+ // the injectable `hub.selfHealOperatorToken` seam to assert the call happens
1538
+ // (and to make it throw without failing start); a separate test drives the
1539
+ // REAL self-heal against an on-disk operator token + hub.db.
1540
+ test("start hub: invokes operator-token self-heal with the resolved issuer + configDir", async () => {
1541
+ const h = makeHarness();
1542
+ try {
1543
+ const log: string[] = [];
1544
+ const calls: Array<{ issuer: string; configDir: string }> = [];
1545
+ const code = await start("hub", {
1546
+ configDir: h.configDir,
1547
+ manifestPath: h.manifestPath,
1548
+ hubOrigin: "https://hub.example.com",
1549
+ hub: {
1550
+ ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1551
+ selfHealOperatorToken: async (args) => {
1552
+ calls.push({ issuer: args.issuer, configDir: args.configDir });
1553
+ return {
1554
+ kind: "rotated",
1555
+ path: "/x/operator.token",
1556
+ scopeSet: "admin",
1557
+ expiresAt: "z",
1558
+ };
1559
+ },
1560
+ },
1561
+ log: (l) => log.push(l),
1562
+ });
1563
+ expect(code).toBe(0);
1564
+ expect(calls).toEqual([{ issuer: "https://hub.example.com", configDir: h.configDir }]);
1565
+ // Rotation emits an operator-facing line.
1566
+ expect(log.join("\n")).toMatch(
1567
+ /refreshed operator\.token issuer → https:\/\/hub\.example\.com/,
1568
+ );
1569
+ } finally {
1570
+ h.cleanup();
1571
+ }
1572
+ });
1573
+
1574
+ test("start hub: skips operator-token self-heal when no hub origin is resolvable", async () => {
1575
+ const h = makeHarness();
1576
+ try {
1577
+ let called = false;
1578
+ // No hubOrigin override, no expose-state, no hub.port file → resolveHubOrigin
1579
+ // yields undefined, so the self-heal seam must NOT be called.
1580
+ const code = await start("hub", {
1581
+ configDir: h.configDir,
1582
+ manifestPath: h.manifestPath,
1583
+ hub: {
1584
+ ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1585
+ selfHealOperatorToken: async () => {
1586
+ called = true;
1587
+ return { kind: "absent" };
1588
+ },
1589
+ },
1590
+ log: () => {},
1591
+ });
1592
+ expect(code).toBe(0);
1593
+ expect(called).toBe(false);
1594
+ } finally {
1595
+ h.cleanup();
1596
+ }
1597
+ });
1598
+
1599
+ test("start hub: a thrown error inside operator-token self-heal does NOT fail start", async () => {
1600
+ const h = makeHarness();
1601
+ try {
1602
+ const log: string[] = [];
1603
+ const code = await start("hub", {
1604
+ configDir: h.configDir,
1605
+ manifestPath: h.manifestPath,
1606
+ hubOrigin: "https://hub.example.com",
1607
+ hub: {
1608
+ ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1609
+ selfHealOperatorToken: async () => {
1610
+ throw new Error("hub.db is locked");
1611
+ },
1612
+ },
1613
+ log: (l) => log.push(l),
1614
+ });
1615
+ expect(code).toBe(0);
1616
+ // Degrades to a brief note, not a hard failure.
1617
+ expect(log.join("\n")).toMatch(
1618
+ /operator\.token issuer self-heal skipped \(hub\.db is locked\)/,
1619
+ );
1620
+ } finally {
1621
+ h.cleanup();
1622
+ }
1623
+ });
1624
+
1625
+ test("start hub: real self-heal re-mints a stale-iss operator token on disk", async () => {
1626
+ const h = makeHarness();
1627
+ try {
1628
+ // Seed signing keys + a stale-iss operator token in the harness configDir's
1629
+ // hub.db / operator.token, then drive the production self-heal seam.
1630
+ const db = openHubDb(hubDbPath(h.configDir));
1631
+ try {
1632
+ rotateSigningKey(db);
1633
+ await issueOperatorToken(db, "user-abc", {
1634
+ dir: h.configDir,
1635
+ issuer: "http://127.0.0.1:1939",
1636
+ scopeSet: "start",
1637
+ });
1638
+ } finally {
1639
+ db.close();
1640
+ }
1641
+
1642
+ const log: string[] = [];
1643
+ const code = await start("hub", {
1644
+ configDir: h.configDir,
1645
+ manifestPath: h.manifestPath,
1646
+ hubOrigin: "https://gitcoin-parachute.unforced.dev",
1647
+ // No selfHealOperatorToken override → exercises defaultSelfHealOperatorToken
1648
+ // (opens hub.db at <configDir>/hub.db).
1649
+ hub: {
1650
+ ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1651
+ },
1652
+ log: (l) => log.push(l),
1653
+ });
1654
+ expect(code).toBe(0);
1655
+ expect(log.join("\n")).toMatch(
1656
+ /refreshed operator\.token issuer → https:\/\/gitcoin-parachute\.unforced\.dev/,
1657
+ );
1658
+
1659
+ // The on-disk token now validates under the new issuer, scope-set preserved.
1660
+ const verifyDb = openHubDb(hubDbPath(h.configDir));
1661
+ try {
1662
+ const onDisk = await readOperatorTokenFile(h.configDir);
1663
+ expect(onDisk).not.toBeNull();
1664
+ const validated = await validateAccessToken(
1665
+ verifyDb,
1666
+ onDisk as string,
1667
+ "https://gitcoin-parachute.unforced.dev",
1668
+ );
1669
+ expect(validated.payload.iss).toBe("https://gitcoin-parachute.unforced.dev");
1670
+ expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
1671
+ } finally {
1672
+ verifyDb.close();
1673
+ }
1674
+ } finally {
1675
+ h.cleanup();
1676
+ }
1677
+ });
1678
+
1528
1679
  test("stop hub: dispatches to stopHub, true → '✓ hub stopped'", async () => {
1529
1680
  const h = makeHarness();
1530
1681
  try {
@@ -0,0 +1,412 @@
1
+ /**
2
+ * hub#481 — `selfHealOperatorTokenIssuer` re-mints a genuine-but-stale
3
+ * operator token under the hub's current issuer.
4
+ *
5
+ * Background: a box that ran init/setup at loopback and was LATER exposed
6
+ * publicly carries an `operator.token` whose `iss` (e.g. `http://127.0.0.1:1939`)
7
+ * no longer matches the hub's current issuer. The hub rejects it on every CLI
8
+ * auth flow. The self-heal re-issues the hub's OWN credential under the new
9
+ * issuer, preserving scope-set + sub, gated on the token's signature verifying
10
+ * against this hub's current keys (no privilege-escalation surface).
11
+ *
12
+ * Mirrors the existing `operator-token.test.ts` harness shape.
13
+ */
14
+ import { describe, expect, test } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { readFile } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
20
+ import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
21
+ import {
22
+ OPERATOR_TOKEN_AUDIENCE,
23
+ OPERATOR_TOKEN_CLIENT_ID,
24
+ OPERATOR_TOKEN_FILENAME,
25
+ OPERATOR_TOKEN_SCOPE_SET_CLAIM,
26
+ issueOperatorToken,
27
+ operatorTokenPath,
28
+ readOperatorTokenFile,
29
+ selfHealOperatorTokenIssuer,
30
+ writeOperatorTokenFile,
31
+ } from "../operator-token.ts";
32
+ import { rotateSigningKey } from "../signing-keys.ts";
33
+
34
+ interface Harness {
35
+ dir: string;
36
+ cleanup: () => void;
37
+ }
38
+
39
+ function makeHarness(): Harness {
40
+ const dir = mkdtempSync(join(tmpdir(), "phub-op-heal-"));
41
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
42
+ }
43
+
44
+ const LOOPBACK_ISSUER = "http://127.0.0.1:1939";
45
+ const PUBLIC_ISSUER = "https://gitcoin-parachute.unforced.dev";
46
+
47
+ describe("selfHealOperatorTokenIssuer", () => {
48
+ test("stale-iss genuine token + non-loopback new issuer → rotated, scope-set preserved, valid under new issuer", async () => {
49
+ const h = makeHarness();
50
+ try {
51
+ const db = openHubDb(hubDbPath(h.dir));
52
+ try {
53
+ rotateSigningKey(db);
54
+ // Mint at loopback issuer with a non-default scope-set ("start").
55
+ await issueOperatorToken(db, "user-abc", {
56
+ dir: h.dir,
57
+ issuer: LOOPBACK_ISSUER,
58
+ scopeSet: "start",
59
+ });
60
+
61
+ const status = await selfHealOperatorTokenIssuer(db, {
62
+ issuer: PUBLIC_ISSUER,
63
+ configDir: h.dir,
64
+ });
65
+ expect(status.kind).toBe("rotated");
66
+ if (status.kind === "rotated") {
67
+ expect(status.scopeSet).toBe("start");
68
+ expect(status.path).toBe(operatorTokenPath(h.dir));
69
+ }
70
+
71
+ // The on-disk token now has iss=PUBLIC_ISSUER, scope-set preserved,
72
+ // and validates under the new issuer.
73
+ const onDisk = await readOperatorTokenFile(h.dir);
74
+ expect(onDisk).not.toBeNull();
75
+ const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
76
+ expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
77
+ expect(validated.payload.sub).toBe("user-abc");
78
+ expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
79
+ } finally {
80
+ db.close();
81
+ }
82
+ } finally {
83
+ h.cleanup();
84
+ }
85
+ });
86
+
87
+ test("iss already current → fresh, on-disk file byte-identical", async () => {
88
+ const h = makeHarness();
89
+ try {
90
+ const db = openHubDb(hubDbPath(h.dir));
91
+ try {
92
+ rotateSigningKey(db);
93
+ await issueOperatorToken(db, "user-abc", {
94
+ dir: h.dir,
95
+ issuer: PUBLIC_ISSUER,
96
+ scopeSet: "admin",
97
+ });
98
+ const before = await readFile(operatorTokenPath(h.dir));
99
+
100
+ const status = await selfHealOperatorTokenIssuer(db, {
101
+ issuer: PUBLIC_ISSUER,
102
+ configDir: h.dir,
103
+ });
104
+ expect(status.kind).toBe("fresh");
105
+
106
+ const after = await readFile(operatorTokenPath(h.dir));
107
+ expect(after.equals(before)).toBe(true);
108
+ } finally {
109
+ db.close();
110
+ }
111
+ } finally {
112
+ h.cleanup();
113
+ }
114
+ });
115
+
116
+ test("absent token file → absent, no throw", async () => {
117
+ const h = makeHarness();
118
+ try {
119
+ const db = openHubDb(hubDbPath(h.dir));
120
+ try {
121
+ rotateSigningKey(db);
122
+ const status = await selfHealOperatorTokenIssuer(db, {
123
+ issuer: PUBLIC_ISSUER,
124
+ configDir: h.dir,
125
+ });
126
+ expect(status.kind).toBe("absent");
127
+ } finally {
128
+ db.close();
129
+ }
130
+ } finally {
131
+ h.cleanup();
132
+ }
133
+ });
134
+
135
+ test("bad signature (corrupt token) → skipped:unverifiable, disk untouched", async () => {
136
+ const h = makeHarness();
137
+ try {
138
+ const db = openHubDb(hubDbPath(h.dir));
139
+ try {
140
+ rotateSigningKey(db);
141
+ // Mint a real token at loopback, then corrupt its signature segment so
142
+ // it no longer verifies against the hub's keys.
143
+ const issued = await issueOperatorToken(db, "user-abc", {
144
+ dir: h.dir,
145
+ issuer: LOOPBACK_ISSUER,
146
+ scopeSet: "start",
147
+ });
148
+ const parts = issued.token.split(".");
149
+ const hdr = parts[0] ?? "";
150
+ const body = parts[1] ?? "";
151
+ const sig = parts[2] ?? "";
152
+ // Flip a character in the MIDDLE of the signature, keeping it
153
+ // base64url-shaped. Corrupting the last char is unreliable: the final
154
+ // base64url char of an RS256 signature encodes only padding bits, so a
155
+ // flip there can decode to identical bytes and leave the signature
156
+ // valid (~25% of mints). A mid-signature char sits in a full 4-char
157
+ // group with no padding, so any single-char change deterministically
158
+ // alters the decoded bytes and invalidates the signature.
159
+ const mid = Math.floor(sig.length / 2);
160
+ const flipped = sig[mid] === "A" ? "B" : "A";
161
+ const tampered = `${hdr}.${body}.${sig.slice(0, mid)}${flipped}${sig.slice(mid + 1)}`;
162
+ await writeOperatorTokenFile(tampered, h.dir);
163
+
164
+ const status = await selfHealOperatorTokenIssuer(db, {
165
+ issuer: PUBLIC_ISSUER,
166
+ configDir: h.dir,
167
+ });
168
+ expect(status.kind).toBe("skipped");
169
+ if (status.kind === "skipped") expect(status.reason).toBe("unverifiable");
170
+
171
+ const onDisk = await readOperatorTokenFile(h.dir);
172
+ expect(onDisk).toBe(tampered);
173
+ } finally {
174
+ db.close();
175
+ }
176
+ } finally {
177
+ h.cleanup();
178
+ }
179
+ });
180
+
181
+ test("expired token (exp in the past) → skipped:unverifiable (jose throws), disk untouched", async () => {
182
+ const h = makeHarness();
183
+ try {
184
+ const db = openHubDb(hubDbPath(h.dir));
185
+ try {
186
+ rotateSigningKey(db);
187
+ // Mint a token that expired in the past — jose's exp check throws on
188
+ // validate, so the self-heal must classify it unverifiable.
189
+ const issued = await issueOperatorToken(db, "user-abc", {
190
+ dir: h.dir,
191
+ issuer: LOOPBACK_ISSUER,
192
+ scopeSet: "start",
193
+ ttlSeconds: 60,
194
+ now: () => new Date("2026-01-01T00:00:00Z"),
195
+ });
196
+
197
+ const status = await selfHealOperatorTokenIssuer(db, {
198
+ issuer: PUBLIC_ISSUER,
199
+ configDir: h.dir,
200
+ });
201
+ expect(status.kind).toBe("skipped");
202
+ if (status.kind === "skipped") expect(status.reason).toBe("unverifiable");
203
+
204
+ const onDisk = await readOperatorTokenFile(h.dir);
205
+ expect(onDisk).toBe(issued.token);
206
+ } finally {
207
+ db.close();
208
+ }
209
+ } finally {
210
+ h.cleanup();
211
+ }
212
+ });
213
+
214
+ test("aud != operator (aud=scribe, valid sig, stale iss) → skipped:aud-mismatch, untouched", async () => {
215
+ const h = makeHarness();
216
+ try {
217
+ const db = openHubDb(hubDbPath(h.dir));
218
+ try {
219
+ rotateSigningKey(db);
220
+ // A hub-signed token with the WRONG audience must not be re-minted as
221
+ // an operator token (privilege guard).
222
+ const signed = await signAccessToken(db, {
223
+ sub: "user-abc",
224
+ scopes: ["scribe:transcribe"],
225
+ audience: "scribe",
226
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
227
+ issuer: LOOPBACK_ISSUER,
228
+ extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: "admin" },
229
+ });
230
+ await writeOperatorTokenFile(signed.token, h.dir);
231
+
232
+ const status = await selfHealOperatorTokenIssuer(db, {
233
+ issuer: PUBLIC_ISSUER,
234
+ configDir: h.dir,
235
+ });
236
+ expect(status.kind).toBe("skipped");
237
+ if (status.kind === "skipped") expect(status.reason).toBe("aud-mismatch");
238
+
239
+ const onDisk = await readOperatorTokenFile(h.dir);
240
+ expect(onDisk).toBe(signed.token);
241
+ } finally {
242
+ db.close();
243
+ }
244
+ } finally {
245
+ h.cleanup();
246
+ }
247
+ });
248
+
249
+ test("missing/invalid pa_scope_set (stale iss, aud=operator) → skipped:no-scope-set, NOT widened to admin", async () => {
250
+ const h = makeHarness();
251
+ try {
252
+ const db = openHubDb(hubDbPath(h.dir));
253
+ try {
254
+ rotateSigningKey(db);
255
+ // aud=operator + stale iss + NO pa_scope_set claim. Falling back to a
256
+ // default scope-set would silently widen to admin (hub#224); refuse.
257
+ const signed = await signAccessToken(db, {
258
+ sub: "user-abc",
259
+ scopes: ["scribe:transcribe"],
260
+ audience: OPERATOR_TOKEN_AUDIENCE,
261
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
262
+ issuer: LOOPBACK_ISSUER,
263
+ });
264
+ await writeOperatorTokenFile(signed.token, h.dir);
265
+
266
+ const status = await selfHealOperatorTokenIssuer(db, {
267
+ issuer: PUBLIC_ISSUER,
268
+ configDir: h.dir,
269
+ });
270
+ expect(status.kind).toBe("skipped");
271
+ if (status.kind === "skipped") expect(status.reason).toBe("no-scope-set");
272
+
273
+ // On-disk file unchanged — no widening occurred.
274
+ const onDisk = await readOperatorTokenFile(h.dir);
275
+ expect(onDisk).toBe(signed.token);
276
+ } finally {
277
+ db.close();
278
+ }
279
+ } finally {
280
+ h.cleanup();
281
+ }
282
+ });
283
+
284
+ test("missing sub (stale iss, aud=operator, valid scope-set) → skipped:no-sub, untouched", async () => {
285
+ const h = makeHarness();
286
+ try {
287
+ const db = openHubDb(hubDbPath(h.dir));
288
+ try {
289
+ rotateSigningKey(db);
290
+ // aud=operator + recognized scope-set + stale iss but NO sub — we can't
291
+ // re-mint a token we can't attribute.
292
+ const signed = await signAccessToken(db, {
293
+ sub: "",
294
+ scopes: ["parachute:host:start"],
295
+ audience: OPERATOR_TOKEN_AUDIENCE,
296
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
297
+ issuer: LOOPBACK_ISSUER,
298
+ extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: "start" },
299
+ });
300
+ await writeOperatorTokenFile(signed.token, h.dir);
301
+
302
+ const status = await selfHealOperatorTokenIssuer(db, {
303
+ issuer: PUBLIC_ISSUER,
304
+ configDir: h.dir,
305
+ });
306
+ expect(status.kind).toBe("skipped");
307
+ if (status.kind === "skipped") expect(status.reason).toBe("no-sub");
308
+
309
+ const onDisk = await readOperatorTokenFile(h.dir);
310
+ expect(onDisk).toBe(signed.token);
311
+ } finally {
312
+ db.close();
313
+ }
314
+ } finally {
315
+ h.cleanup();
316
+ }
317
+ });
318
+
319
+ test("target issuer loopback (public token on disk) → skipped:issuer-loopback, public token preserved", async () => {
320
+ const h = makeHarness();
321
+ try {
322
+ const db = openHubDb(hubDbPath(h.dir));
323
+ try {
324
+ rotateSigningKey(db);
325
+ // A good PUBLIC-issuer token; calling self-heal with a loopback target
326
+ // must never downgrade it.
327
+ const issued = await issueOperatorToken(db, "user-abc", {
328
+ dir: h.dir,
329
+ issuer: PUBLIC_ISSUER,
330
+ scopeSet: "admin",
331
+ });
332
+
333
+ const status = await selfHealOperatorTokenIssuer(db, {
334
+ issuer: LOOPBACK_ISSUER,
335
+ configDir: h.dir,
336
+ });
337
+ expect(status.kind).toBe("skipped");
338
+ if (status.kind === "skipped") expect(status.reason).toBe("issuer-loopback");
339
+
340
+ const onDisk = await readOperatorTokenFile(h.dir);
341
+ expect(onDisk).toBe(issued.token);
342
+ // Still a public-issuer token.
343
+ const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
344
+ expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
345
+ } finally {
346
+ db.close();
347
+ }
348
+ } finally {
349
+ h.cleanup();
350
+ }
351
+ });
352
+
353
+ test("scope-set preserved verbatim — 'auth' set stays 'auth', not widened to admin", async () => {
354
+ const h = makeHarness();
355
+ try {
356
+ const db = openHubDb(hubDbPath(h.dir));
357
+ try {
358
+ rotateSigningKey(db);
359
+ await issueOperatorToken(db, "user-xyz", {
360
+ dir: h.dir,
361
+ issuer: LOOPBACK_ISSUER,
362
+ scopeSet: "auth",
363
+ });
364
+
365
+ const status = await selfHealOperatorTokenIssuer(db, {
366
+ issuer: PUBLIC_ISSUER,
367
+ configDir: h.dir,
368
+ });
369
+ expect(status.kind).toBe("rotated");
370
+ if (status.kind === "rotated") expect(status.scopeSet).toBe("auth");
371
+
372
+ const onDisk = await readOperatorTokenFile(h.dir);
373
+ const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
374
+ expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("auth");
375
+ // The minted scopes are the "auth" set, NOT the admin superset.
376
+ expect(validated.payload.scope).toBe("parachute:host:auth");
377
+ } finally {
378
+ db.close();
379
+ }
380
+ } finally {
381
+ h.cleanup();
382
+ }
383
+ });
384
+
385
+ test("file path is the canonical operator.token under configDir", async () => {
386
+ const h = makeHarness();
387
+ try {
388
+ const db = openHubDb(hubDbPath(h.dir));
389
+ try {
390
+ rotateSigningKey(db);
391
+ await issueOperatorToken(db, "user-abc", {
392
+ dir: h.dir,
393
+ issuer: LOOPBACK_ISSUER,
394
+ scopeSet: "start",
395
+ });
396
+ const status = await selfHealOperatorTokenIssuer(db, {
397
+ issuer: PUBLIC_ISSUER,
398
+ configDir: h.dir,
399
+ });
400
+ if (status.kind === "rotated") {
401
+ expect(status.path).toBe(join(h.dir, OPERATOR_TOKEN_FILENAME));
402
+ } else {
403
+ throw new Error(`expected rotated, got ${status.kind}`);
404
+ }
405
+ } finally {
406
+ db.close();
407
+ }
408
+ } finally {
409
+ h.cleanup();
410
+ }
411
+ });
412
+ });
@@ -13,8 +13,10 @@ import {
13
13
  readHubPort,
14
14
  stopHub,
15
15
  } from "../hub-control.ts";
16
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
16
17
  import { HUB_ORIGIN_ENV, deriveHubOrigin } from "../hub-origin.ts";
17
18
  import { ModuleManifestError, readModuleManifest } from "../module-manifest.ts";
19
+ import { type OperatorIssuerHealStatus, selfHealOperatorTokenIssuer } from "../operator-token.ts";
18
20
  import {
19
21
  type AliveFn,
20
22
  clearPid,
@@ -271,6 +273,18 @@ export interface LifecycleOpts {
271
273
  hub?: {
272
274
  ensureRunning?: (opts: EnsureHubOpts) => Promise<EnsureHubResult>;
273
275
  stop?: (opts: StopHubOpts) => Promise<boolean>;
276
+ /**
277
+ * Self-heal the operator token's stale `iss` after `start hub` (hub#481).
278
+ * Production opens hub.db at `<configDir>/hub.db` and delegates to
279
+ * `selfHealOperatorTokenIssuer`. Tests inject a stub to assert the call
280
+ * happens — or to make it throw and prove a self-heal failure never fails
281
+ * `start hub`.
282
+ */
283
+ selfHealOperatorToken?: (args: {
284
+ issuer: string;
285
+ configDir: string;
286
+ log: (line: string) => void;
287
+ }) => Promise<OperatorIssuerHealStatus>;
274
288
  };
275
289
  }
276
290
 
@@ -292,6 +306,35 @@ interface Resolved {
292
306
  hubOrigin: string | undefined;
293
307
  ensureHub: (opts: EnsureHubOpts) => Promise<EnsureHubResult>;
294
308
  stopHubFn: (opts: StopHubOpts) => Promise<boolean>;
309
+ selfHealOperatorTokenFn: (args: {
310
+ issuer: string;
311
+ configDir: string;
312
+ log: (line: string) => void;
313
+ }) => Promise<OperatorIssuerHealStatus>;
314
+ }
315
+
316
+ /**
317
+ * Production self-heal: open hub.db at `<configDir>/hub.db`, run
318
+ * `selfHealOperatorTokenIssuer`, and close the db. Derives the db path the
319
+ * same way the rest of the repo does (`hubDbPath(configDir)`); `openHubDb`
320
+ * runs migrations + WAL on open, matching `commands/auth.ts`. Tests override
321
+ * this whole seam, so the db-open only happens on the production path.
322
+ */
323
+ async function defaultSelfHealOperatorToken(args: {
324
+ issuer: string;
325
+ configDir: string;
326
+ log: (line: string) => void;
327
+ }): Promise<OperatorIssuerHealStatus> {
328
+ const db = openHubDb(hubDbPath(args.configDir));
329
+ try {
330
+ return await selfHealOperatorTokenIssuer(db, {
331
+ issuer: args.issuer,
332
+ configDir: args.configDir,
333
+ log: args.log,
334
+ });
335
+ } finally {
336
+ db.close();
337
+ }
295
338
  }
296
339
 
297
340
  function resolve(opts: LifecycleOpts): Resolved {
@@ -328,6 +371,7 @@ function resolve(opts: LifecycleOpts): Resolved {
328
371
  hubOrigin: resolveHubOrigin(opts.hubOrigin, configDir),
329
372
  ensureHub: opts.hub?.ensureRunning ?? ensureHubRunning,
330
373
  stopHubFn: opts.hub?.stop ?? stopHub,
374
+ selfHealOperatorTokenFn: opts.hub?.selfHealOperatorToken ?? defaultSelfHealOperatorToken,
331
375
  };
332
376
  }
333
377
 
@@ -774,6 +818,12 @@ async function startHubSvc(r: Resolved): Promise<number> {
774
818
  } else {
775
819
  r.log(`hub already running (pid ${result.pid}) on port ${result.port}.`);
776
820
  }
821
+ // Self-heal a stale operator-token issuer (hub#481). Runs whether the hub
822
+ // was freshly started OR already running — a token stamped at loopback
823
+ // before exposure must heal even when the hub is already up. The loopback /
824
+ // provenance guards live inside `selfHealOperatorTokenIssuer`, so the only
825
+ // gate here is "is there a real issuer to heal toward?".
826
+ await selfHealOperatorTokenOnStart(r);
777
827
  return 0;
778
828
  } catch (err) {
779
829
  r.log(`✗ hub failed to start: ${err instanceof Error ? err.message : String(err)}`);
@@ -781,6 +831,36 @@ async function startHubSvc(r: Resolved): Promise<number> {
781
831
  }
782
832
  }
783
833
 
834
+ /**
835
+ * Re-issue the operator token under the hub's current origin when its `iss`
836
+ * went stale after an init-at-loopback → expose transition (hub#481). Mirrors
837
+ * `persistVaultHubOriginForStart`'s quiet style: emit a single line only when
838
+ * a rotation actually happens; stay silent for fresh / absent / skipped.
839
+ *
840
+ * The ENTIRE self-heal is wrapped here so it can NEVER block or fail
841
+ * `start hub` — a db-open error, a corrupt token, anything — degrades to a
842
+ * brief warning and `start hub` still returns 0.
843
+ */
844
+ async function selfHealOperatorTokenOnStart(r: Resolved): Promise<void> {
845
+ if (!r.hubOrigin) return;
846
+ try {
847
+ const status = await r.selfHealOperatorTokenFn({
848
+ issuer: r.hubOrigin,
849
+ configDir: r.configDir,
850
+ log: r.log,
851
+ });
852
+ if (status.kind === "rotated") {
853
+ r.log(` refreshed operator.token issuer → ${r.hubOrigin} (was stale after exposure)`);
854
+ }
855
+ } catch (err) {
856
+ r.log(
857
+ ` note: operator.token issuer self-heal skipped (${
858
+ err instanceof Error ? err.message : String(err)
859
+ })`,
860
+ );
861
+ }
862
+ }
863
+
784
864
  /**
785
865
  * Stop the internal hub. `stopHub` returns false when nothing was running
786
866
  * (no pidfile, or stale pidfile cleared) — that's a clean no-op for the
@@ -31,6 +31,7 @@ import { promises as fs } from "node:fs";
31
31
  import { join } from "node:path";
32
32
  import { configDir } from "./config.ts";
33
33
  import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
34
+ import { isLoopbackOrigin } from "./vault-hub-origin-env.ts";
34
35
 
35
36
  export const OPERATOR_TOKEN_FILENAME = "operator.token";
36
37
  /** Default operator-token lifetime — 90 days, was 365d through 0.5.7 (#213). */
@@ -462,3 +463,153 @@ export async function useOperatorTokenWithAutoRotate(
462
463
  status: { kind: "rotated" },
463
464
  };
464
465
  }
466
+
467
+ export interface SelfHealOperatorTokenOpts {
468
+ /**
469
+ * The hub's CURRENT issuer (its public origin once exposed). The stale
470
+ * on-disk token is re-minted under this value. Must be the resolved hub
471
+ * origin, never a raw flag — callers pass `r.hubOrigin` from lifecycle.
472
+ */
473
+ issuer: string;
474
+ /** configDir override (where operator.token lives). Defaults to `configDir()`. */
475
+ configDir?: string;
476
+ /** Operator-facing log sink. Defaults to a no-op (silent). */
477
+ log?: (line: string) => void;
478
+ /** Override the JWT-sign clock — tests pin time. Forwarded to `issueOperatorToken`. */
479
+ now?: () => Date;
480
+ }
481
+
482
+ /**
483
+ * Disambiguated outcome of {@link selfHealOperatorTokenIssuer}. Modelled on
484
+ * {@link RotationStatus} — a small discriminated union the caller logs and
485
+ * tests assert.
486
+ *
487
+ * - `absent` — no `operator.token` on disk; nothing to heal.
488
+ * - `fresh` — the token's `iss` already matches the current issuer; the file
489
+ * is left byte-identical (no rewrite, no log).
490
+ * - `rotated` — the token was genuine-but-stale; re-minted under the current
491
+ * issuer with the SAME scope-set + sub. `path` / `expiresAt` / `scopeSet`
492
+ * describe the freshly-written token.
493
+ * - `skipped` — a guard fired; the on-disk file is left untouched. `reason`:
494
+ * - `unverifiable`: the on-disk token's signature did NOT verify against
495
+ * this hub's current keys (bad/unknown/expired kid, jose `exp` failure,
496
+ * or a revoked jti). We must NOT resurrect or trust it — the operator
497
+ * recovers via `parachute auth rotate-operator`.
498
+ * - `aud-mismatch`: the token carries a non-operator audience. A
499
+ * hand-stashed scope-narrow JWT must not be silently re-minted as a
500
+ * full operator token (parallels the {@link useOperatorTokenWithAutoRotate}
501
+ * privilege guard).
502
+ * - `no-sub`: the token lacks a `sub` claim, so we can't re-mint (don't
503
+ * know who it belongs to).
504
+ * - `no-scope-set`: the token lacks (or has an unrecognized) `pa_scope_set`
505
+ * claim. Falling back to a default would widen scope (hub#224 hardening);
506
+ * refuse instead. Operator recovers via explicit rotate-operator.
507
+ * - `issuer-loopback`: the TARGET issuer is loopback. Re-minting to a
508
+ * loopback `iss` would downgrade a good public token; never do it.
509
+ */
510
+ export type OperatorIssuerHealStatus =
511
+ | { kind: "absent" }
512
+ | { kind: "fresh" }
513
+ | { kind: "rotated"; path: string; scopeSet: OperatorScopeSet; expiresAt: string }
514
+ | {
515
+ kind: "skipped";
516
+ reason: "unverifiable" | "aud-mismatch" | "no-sub" | "no-scope-set" | "issuer-loopback";
517
+ };
518
+
519
+ /**
520
+ * Self-heal the operator token's `iss` when the hub's origin changed after
521
+ * the token was minted.
522
+ *
523
+ * The bug this closes (hub#481, same family as the rc.17 Cloudflare 401 P0):
524
+ * `parachute init`/setup mints `~/.parachute/operator.token` stamped with the
525
+ * hub's origin-at-creation-time (`http://127.0.0.1:1939` on a box set up
526
+ * before exposure). After `parachute expose` brings the hub up on a public
527
+ * origin, on-box services validate incoming bearers' `iss` against the hub's
528
+ * CURRENT origin, so the stale-`iss` operator token is rejected on every CLI
529
+ * auth flow with `bearer token invalid — unexpected "iss" claim value`. That
530
+ * breaks `vault create`, `mcp-install`, and `/api/auth/mint-token`. The token
531
+ * is genuine — it just predates the origin change — so re-issuing it under the
532
+ * current issuer (preserving its scope-set + sub) is the right repair.
533
+ *
534
+ * Hooked into `parachute start hub` (parallel to how rc.17 hooked
535
+ * `selfHealVaultHubOrigin` into `start vault`): existing broken deploys
536
+ * self-correct on the next `start/restart hub`.
537
+ *
538
+ * ## Security invariant (reviewer: verify this holds)
539
+ *
540
+ * Re-mint is gated on `validateAccessToken(db, token)` succeeding WITHOUT an
541
+ * `expectedIssuer` argument — i.e. the on-disk token's SIGNATURE verifies
542
+ * against THIS hub's current public keys (by kid) and passes jose's `exp`
543
+ * check and the revocation check, while NOT pinning `iss`. An attacker cannot
544
+ * forge that signature, so the ONLY tokens that can ever be re-minted are ones
545
+ * this hub itself previously minted. There is no path to mint a token from an
546
+ * untrusted/forged input. Further:
547
+ * - scope-set is preserved verbatim (`payload.pa_scope_set`) — no widening;
548
+ * - expired tokens are refused (jose `exp` → `skipped: unverifiable`);
549
+ * - revoked tokens are refused (validateAccessToken's revocation check);
550
+ * - a non-operator audience is refused (`skipped: aud-mismatch`);
551
+ * - a loopback TARGET issuer is refused (`skipped: issuer-loopback`) — never
552
+ * downgrade a good public token back to loopback.
553
+ * This is strictly a re-issue of the hub's own still-valid credential under
554
+ * the hub's own new issuer.
555
+ */
556
+ export async function selfHealOperatorTokenIssuer(
557
+ db: Database,
558
+ opts: SelfHealOperatorTokenOpts,
559
+ ): Promise<OperatorIssuerHealStatus> {
560
+ const dir = opts.configDir ?? configDir();
561
+ const token = await readOperatorTokenFile(dir);
562
+ if (!token) return { kind: "absent" };
563
+
564
+ // Target-issuer loopback guard FIRST. Re-minting to a loopback `iss` would
565
+ // downgrade a good public token to a non-reachable issuer, recreating the
566
+ // exact iss-mismatch this fix prevents (mirrors `isLoopbackOrigin`'s role in
567
+ // vault-hub-origin-env.ts). Never do it — bail before touching the token.
568
+ if (isLoopbackOrigin(opts.issuer)) return { kind: "skipped", reason: "issuer-loopback" };
569
+
570
+ // Verify the on-disk token WITHOUT pinning `iss`: this checks the signature
571
+ // against the hub's current keys (by kid) + jose's `exp` + revocation, but
572
+ // deliberately does NOT reject a stale issuer. A throw here means the token
573
+ // is unverifiable (bad/unknown/expired kid, expired-by-jose, revoked) — we
574
+ // must NOT resurrect or trust it. Leave the disk file untouched; the operator
575
+ // recovers via `parachute auth rotate-operator`.
576
+ let payload: Awaited<ReturnType<typeof validateAccessToken>>["payload"];
577
+ try {
578
+ ({ payload } = await validateAccessToken(db, token));
579
+ } catch {
580
+ return { kind: "skipped", reason: "unverifiable" };
581
+ }
582
+
583
+ // `iss` already current → no-op. Do NOT rewrite the file; it must stay
584
+ // byte-identical so repeated `start hub`s don't churn it.
585
+ if (payload.iss === opts.issuer) return { kind: "fresh" };
586
+
587
+ // `iss` differs → genuine-but-stale. Apply the same provenance guards
588
+ // `useOperatorTokenWithAutoRotate` uses before re-minting.
589
+ if (payload.aud !== OPERATOR_TOKEN_AUDIENCE) {
590
+ return { kind: "skipped", reason: "aud-mismatch" };
591
+ }
592
+ const sub = typeof payload.sub === "string" && payload.sub.length > 0 ? payload.sub : null;
593
+ if (!sub) return { kind: "skipped", reason: "no-sub" };
594
+ const claimedSet = payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM];
595
+ if (!isOperatorScopeSet(claimedSet)) {
596
+ // No recognized scope-set → falling back to a default would widen scope
597
+ // (hub#224). Refuse; never widen.
598
+ return { kind: "skipped", reason: "no-scope-set" };
599
+ }
600
+
601
+ // Re-mint preserving scope-set + sub. `issueOperatorToken` writes the new
602
+ // token to disk atomically (mint → writeOperatorTokenFile).
603
+ const issued = await issueOperatorToken(db, sub, {
604
+ dir,
605
+ issuer: opts.issuer,
606
+ scopeSet: claimedSet,
607
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
608
+ });
609
+ return {
610
+ kind: "rotated",
611
+ path: issued.path,
612
+ scopeSet: issued.scopeSet,
613
+ expiresAt: issued.expiresAt,
614
+ };
615
+ }