@openparachute/hub 0.5.14-rc.16 → 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.16",
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": {
@@ -14,6 +14,7 @@ import {
14
14
  exposeCloudflareOff,
15
15
  exposeCloudflareUp,
16
16
  } from "../commands/expose-cloudflare.ts";
17
+ import { readEnvFileValues } from "../env-file.ts";
17
18
  import { readExposeState } from "../expose-state.ts";
18
19
  import { writeHubPort } from "../hub-control.ts";
19
20
  import type { CommandResult, Runner } from "../tailscale/run.ts";
@@ -273,6 +274,121 @@ describe("exposeCloudflareUp", () => {
273
274
  }
274
275
  });
275
276
 
277
+ test("persists the public hub origin to vault/.env + restarts vault (Cloudflare 401 fix)", async () => {
278
+ // The Cloudflare 401 P0: the cloudflare path wrote expose-state.json but —
279
+ // unlike the Tailscale path, which auto-restarts vault and so flows the
280
+ // public origin into vault/.env via lifecycle's persistVaultHubOrigin —
281
+ // never touched vault's .env or restarted it. The launchd/systemd daemon
282
+ // kept booting vault with NO PARACHUTE_HUB_ORIGIN → vault fell back to
283
+ // loopback as its expected issuer → every hub-minted token (iss=public)
284
+ // failed the iss check → 401. This asserts the durable .env write + the
285
+ // running-vault restart that mirrors the Tailscale path.
286
+ const env = makeEnv();
287
+ try {
288
+ // Seed vault as "running" so the restart branch fires. PID lives at
289
+ // <configDir>/vault/run/vault.pid (see process-state.ts:pidPath).
290
+ const vaultRun = join(env.configDir, "vault", "run");
291
+ require("node:fs").mkdirSync(vaultRun, { recursive: true });
292
+ writeFileSync(join(vaultRun, "vault.pid"), "99001");
293
+
294
+ const uuid = "ffffffff-0000-0000-0000-000000000006";
295
+ const { runner } = queueRunner([
296
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
297
+ { code: 0, stdout: "[]", stderr: "" },
298
+ {
299
+ code: 0,
300
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
301
+ stderr: "",
302
+ },
303
+ { code: 0, stdout: "", stderr: "" },
304
+ ]);
305
+ const { spawner } = fakeSpawner(42300);
306
+ const restarted: string[] = [];
307
+
308
+ const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
309
+ runner,
310
+ spawner,
311
+ // `alive` reports the seeded vault pid as running so processState() ===
312
+ // "running" and the restart branch executes.
313
+ alive: (pid) => pid === 99001,
314
+ kill: () => {},
315
+ log: () => {},
316
+ manifestPath: env.manifestPath,
317
+ statePath: env.statePath,
318
+ exposeStatePath: env.exposeStatePath,
319
+ configPath: env.configPath,
320
+ logPath: env.logPath,
321
+ cloudflaredHome: env.cloudflaredHome,
322
+ configDir: env.configDir,
323
+ skipHub: true,
324
+ restartService: async (short) => {
325
+ restarted.push(short);
326
+ return 0;
327
+ },
328
+ });
329
+
330
+ expect(code).toBe(0);
331
+ // Durable half: the public origin is written to vault/.env (NOT loopback,
332
+ // NOT unset) so the daemon boot path validates iss against it.
333
+ expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
334
+ "https://gitcoin-parachute.unforced.dev",
335
+ );
336
+ // Live half: the running vault is restarted to re-read the new origin.
337
+ expect(restarted).toEqual(["vault"]);
338
+ } finally {
339
+ env.cleanup();
340
+ }
341
+ });
342
+
343
+ test("persists vault/.env but does NOT restart when vault isn't running", async () => {
344
+ // No vault pidfile → processState() !== "running" → no restart, but the
345
+ // durable .env write still happens so the next daemon boot is correct.
346
+ const env = makeEnv();
347
+ try {
348
+ const uuid = "ffffffff-0000-0000-0000-000000000007";
349
+ const { runner } = queueRunner([
350
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
351
+ { code: 0, stdout: "[]", stderr: "" },
352
+ {
353
+ code: 0,
354
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
355
+ stderr: "",
356
+ },
357
+ { code: 0, stdout: "", stderr: "" },
358
+ ]);
359
+ const { spawner } = fakeSpawner(42301);
360
+ const restarted: string[] = [];
361
+
362
+ const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
363
+ runner,
364
+ spawner,
365
+ alive: () => false,
366
+ kill: () => {},
367
+ log: () => {},
368
+ manifestPath: env.manifestPath,
369
+ statePath: env.statePath,
370
+ exposeStatePath: env.exposeStatePath,
371
+ configPath: env.configPath,
372
+ logPath: env.logPath,
373
+ cloudflaredHome: env.cloudflaredHome,
374
+ configDir: env.configDir,
375
+ skipHub: true,
376
+ restartService: async (short) => {
377
+ restarted.push(short);
378
+ return 0;
379
+ },
380
+ });
381
+
382
+ expect(code).toBe(0);
383
+ expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
384
+ "https://gitcoin-parachute.unforced.dev",
385
+ );
386
+ expect(restarted).toEqual([]);
387
+ } finally {
388
+ env.cleanup();
389
+ }
390
+ });
391
+
276
392
  test("reuses existing tunnel when name already present", async () => {
277
393
  const env = makeEnv();
278
394
  try {
@@ -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;
@@ -362,6 +370,51 @@ describe("parachute start", () => {
362
370
  }
363
371
  });
364
372
 
373
+ test("self-heals a stale-loopback vault/.env from a cloudflare expose-state on restart", async () => {
374
+ // Existing-broken-deploy shape: a Cloudflare deploy whose vault/.env had a
375
+ // loopback PARACHUTE_HUB_ORIGIN baked in (or was unset and a prior run
376
+ // wrote loopback). expose-state.json carries the real public origin. A
377
+ // plain `parachute start vault` must rewrite vault/.env to the public
378
+ // origin so the daemon stops 401ing hub tokens — the self-heal half of the
379
+ // Cloudflare 401 fix.
380
+ const h = makeHarness();
381
+ try {
382
+ seedVault(h.manifestPath);
383
+ writeFileSync(
384
+ join(h.configDir, "expose-state.json"),
385
+ JSON.stringify({
386
+ version: 1,
387
+ layer: "public",
388
+ mode: "subdomain",
389
+ canonicalFqdn: "gitcoin-parachute.unforced.dev",
390
+ port: 1939,
391
+ funnel: false,
392
+ entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
393
+ hubOrigin: "https://gitcoin-parachute.unforced.dev",
394
+ }),
395
+ );
396
+ // Pre-seed vault/.env with a stale loopback value (the broken state).
397
+ mkdirSync(join(h.configDir, "vault"), { recursive: true });
398
+ writeFileSync(
399
+ join(h.configDir, "vault", ".env"),
400
+ "PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n",
401
+ );
402
+ const spawner = makeSpawner([4242]);
403
+ const code = await start("vault", {
404
+ configDir: h.configDir,
405
+ manifestPath: h.manifestPath,
406
+ spawner,
407
+ log: () => {},
408
+ });
409
+ expect(code).toBe(0);
410
+ expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
411
+ "https://gitcoin-parachute.unforced.dev",
412
+ );
413
+ } finally {
414
+ h.cleanup();
415
+ }
416
+ });
417
+
365
418
  test("does NOT persist a loopback origin into vault/.env (would shadow a later exposure)", async () => {
366
419
  const h = makeHarness();
367
420
  try {
@@ -1480,6 +1533,149 @@ describe("parachute start|stop|restart hub", () => {
1480
1533
  }
1481
1534
  });
1482
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
+
1483
1679
  test("stop hub: dispatches to stopHub, true → '✓ hub stopped'", async () => {
1484
1680
  const h = makeHarness();
1485
1681
  try {