@openparachute/hub 0.5.14-rc.4 → 0.5.14-rc.6

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.4",
3
+ "version": "0.5.14-rc.6",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { handleApiReady } from "../api-ready.ts";
3
+ import type { ModuleState, Supervisor } from "../supervisor.ts";
4
+
5
+ function stubSupervisor(states: ModuleState[]): Supervisor {
6
+ return {
7
+ list: () => states,
8
+ get: (short: string) => states.find((s) => s.short === short),
9
+ start: async () => {
10
+ throw new Error("not implemented");
11
+ },
12
+ stop: async () => undefined,
13
+ restart: async () => undefined,
14
+ } as unknown as Supervisor;
15
+ }
16
+
17
+ function req(): Request {
18
+ return new Request("http://127.0.0.1/api/ready", {
19
+ headers: { accept: "application/json" },
20
+ });
21
+ }
22
+
23
+ function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
24
+ return {
25
+ status: "running",
26
+ restartsInWindow: 0,
27
+ ...partial,
28
+ };
29
+ }
30
+
31
+ describe("handleApiReady — no supervisor (CLI mode)", () => {
32
+ test("returns ready=true + empty arrays when supervisor absent", async () => {
33
+ const res = handleApiReady(req());
34
+ expect(res.status).toBe(200);
35
+ const body = (await res.json()) as {
36
+ ready: boolean;
37
+ ready_modules: string[];
38
+ transient_modules: string[];
39
+ persistent_modules: string[];
40
+ };
41
+ expect(body.ready).toBe(true);
42
+ expect(body.ready_modules).toEqual([]);
43
+ expect(body.transient_modules).toEqual([]);
44
+ expect(body.persistent_modules).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe("handleApiReady — supervisor mode", () => {
49
+ test("all modules running past boot window → ready=true", async () => {
50
+ const now = 1_700_000_000_000;
51
+ const startedAt = new Date(now - 60_000).toISOString();
52
+ const sup = stubSupervisor([
53
+ moduleState({ short: "vault", status: "running", startedAt }),
54
+ moduleState({ short: "scribe", status: "running", startedAt }),
55
+ ]);
56
+ const res = handleApiReady(req(), { supervisor: sup, now: () => now });
57
+ const body = (await res.json()) as {
58
+ ready: boolean;
59
+ ready_modules: string[];
60
+ transient_modules: string[];
61
+ persistent_modules: string[];
62
+ };
63
+ expect(body.ready).toBe(true);
64
+ expect(body.ready_modules.sort()).toEqual(["scribe", "vault"]);
65
+ expect(body.transient_modules).toEqual([]);
66
+ expect(body.persistent_modules).toEqual([]);
67
+ });
68
+
69
+ test("module inside boot window → transient, ready=false", async () => {
70
+ const now = 1_700_000_000_000;
71
+ const sup = stubSupervisor([
72
+ moduleState({
73
+ short: "vault",
74
+ status: "running",
75
+ startedAt: new Date(now - 10_000).toISOString(),
76
+ }),
77
+ ]);
78
+ const res = handleApiReady(req(), { supervisor: sup, now: () => now });
79
+ const body = (await res.json()) as {
80
+ ready: boolean;
81
+ ready_modules: string[];
82
+ transient_modules: string[];
83
+ };
84
+ expect(body.ready).toBe(false);
85
+ expect(body.transient_modules).toEqual(["vault"]);
86
+ expect(body.ready_modules).toEqual([]);
87
+ });
88
+
89
+ test("starting + restarting + crashed all classified correctly", async () => {
90
+ const now = 1_700_000_000_000;
91
+ const sup = stubSupervisor([
92
+ moduleState({ short: "vault", status: "starting" }),
93
+ moduleState({ short: "scribe", status: "restarting" }),
94
+ moduleState({ short: "notes", status: "crashed" }),
95
+ moduleState({ short: "channel", status: "stopped" }),
96
+ moduleState({
97
+ short: "runner",
98
+ status: "running",
99
+ startedAt: new Date(now - 60_000).toISOString(),
100
+ }),
101
+ ]);
102
+ const res = handleApiReady(req(), { supervisor: sup, now: () => now });
103
+ const body = (await res.json()) as {
104
+ ready: boolean;
105
+ ready_modules: string[];
106
+ transient_modules: string[];
107
+ persistent_modules: string[];
108
+ };
109
+ expect(body.ready).toBe(false);
110
+ expect(body.ready_modules).toEqual(["runner"]);
111
+ expect(body.transient_modules.sort()).toEqual(["scribe", "vault"]);
112
+ expect(body.persistent_modules.sort()).toEqual(["channel", "notes"]);
113
+ });
114
+
115
+ test("only crashed/stopped + nothing transient → ready=false (still failing)", async () => {
116
+ const sup = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
117
+ const res = handleApiReady(req(), { supervisor: sup });
118
+ const body = (await res.json()) as { ready: boolean };
119
+ expect(body.ready).toBe(false);
120
+ });
121
+ });
122
+
123
+ describe("handleApiReady — method check", () => {
124
+ test("rejects non-GET", () => {
125
+ const r = new Request("http://127.0.0.1/api/ready", { method: "POST" });
126
+ const res = handleApiReady(r);
127
+ expect(res.status).toBe(405);
128
+ });
129
+
130
+ test("accepts HEAD", () => {
131
+ const r = new Request("http://127.0.0.1/api/ready", { method: "HEAD" });
132
+ const res = handleApiReady(r);
133
+ expect(res.status).toBe(200);
134
+ });
135
+ });
@@ -59,10 +59,65 @@ describe("cloudflare detect", () => {
59
59
  }
60
60
  });
61
61
 
62
- test("install hint names brew on darwin and a URL elsewhere", () => {
63
- expect(cloudflaredInstallHint("darwin")).toContain("brew install cloudflared");
64
- expect(cloudflaredInstallHint("linux")).toContain(
65
- "developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads",
66
- );
62
+ describe("cloudflaredInstallHint", () => {
63
+ test("darwin: names brew + points at GitHub releases as fallback", () => {
64
+ const hint = cloudflaredInstallHint("darwin", "arm64");
65
+ expect(hint).toContain("brew install cloudflared");
66
+ expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
67
+ });
68
+
69
+ test("linux x64: writes the curl line with the amd64 artifact suffix", () => {
70
+ // Refresh of stale URLs (2026-05-27). Aaron hit this on a fresh
71
+ // Amazon Linux 2023 install — `sudo dnf install cloudflared`
72
+ // returned 'No match for argument: cloudflared', and the hub's
73
+ // hint pointed at developers.cloudflare.com paths that 404. The
74
+ // GitHub release is the reliable cross-distro path.
75
+ const hint = cloudflaredInstallHint("linux", "x64");
76
+ expect(hint).toContain("curl -L -o /usr/local/bin/cloudflared");
77
+ expect(hint).toContain(
78
+ "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
79
+ );
80
+ expect(hint).toContain("sudo chmod +x /usr/local/bin/cloudflared");
81
+ });
82
+
83
+ test("linux arm64: writes the arm64 artifact suffix", () => {
84
+ const hint = cloudflaredInstallHint("linux", "arm64");
85
+ expect(hint).toContain("cloudflared-linux-arm64");
86
+ });
87
+
88
+ test("linux arm (32-bit): writes the arm artifact suffix", () => {
89
+ const hint = cloudflaredInstallHint("linux", "arm");
90
+ expect(hint).toContain("cloudflared-linux-arm");
91
+ });
92
+
93
+ test("linux exotic arch: falls back to a generic GitHub releases pointer", () => {
94
+ // riscv64 / ppc64 / mips64 — no cloudflared artifact published, so
95
+ // we don't fabricate a 404-bound download URL; we point the user at
96
+ // the releases page and surface what their arch is so they can pick.
97
+ const hint = cloudflaredInstallHint("linux", "riscv64");
98
+ expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
99
+ expect(hint).toContain("riscv64");
100
+ expect(hint).not.toContain("curl -L -o /usr/local/bin/cloudflared");
101
+ });
102
+
103
+ test("no stale developers.cloudflare.com or pkg.cloudflare.com paths anywhere", () => {
104
+ // Aaron caught both URL shapes returning HTML/404 on 2026-05-27 —
105
+ // they had been the hub's installer instructions for months.
106
+ // Hard-assert they're gone so they don't regress.
107
+ for (const platform of ["darwin", "linux"] as const) {
108
+ for (const arch of ["x64", "arm64"] as const) {
109
+ const hint = cloudflaredInstallHint(platform, arch);
110
+ expect(hint).not.toContain("developers.cloudflare.com");
111
+ expect(hint).not.toContain("pkg.cloudflare.com");
112
+ }
113
+ }
114
+ });
115
+
116
+ test("non-Linux, non-darwin platform: GitHub releases pointer with no curl line", () => {
117
+ const hint = cloudflaredInstallHint("win32", "x64");
118
+ expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
119
+ expect(hint).not.toContain("brew install");
120
+ expect(hint).not.toContain("curl -L -o");
121
+ });
67
122
  });
68
123
  });
@@ -14,8 +14,13 @@ import {
14
14
  exposeCloudflareOff,
15
15
  exposeCloudflareUp,
16
16
  } from "../commands/expose-cloudflare.ts";
17
+ import { writeHubPort } from "../hub-control.ts";
17
18
  import type { CommandResult, Runner } from "../tailscale/run.ts";
18
19
 
20
+ // Default seeded hub port used by tests with `skipHub: true`. The cloudflared
21
+ // path reads `<configDir>/hub/run/hub.port` instead of spawning a real hub.
22
+ const TEST_HUB_PORT = 1939;
23
+
19
24
  interface TestEnv {
20
25
  configDir: string;
21
26
  manifestPath: string;
@@ -41,6 +46,11 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
41
46
  require("node:fs").mkdirSync(configDir, { recursive: true });
42
47
  require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
43
48
 
49
+ // Seed the hub port so `skipHub: true` invocations can resolve a port
50
+ // without spawning the actual hub process. Matches the seam pattern used
51
+ // by expose.test.ts (which threads `hubEnsureOpts` for the same purpose).
52
+ writeHubPort(TEST_HUB_PORT, configDir);
53
+
44
54
  if (loggedIn) {
45
55
  writeFileSync(join(cloudflaredHome, "cert.pem"), "---");
46
56
  }
@@ -128,6 +138,8 @@ describe("exposeCloudflareUp", () => {
128
138
  configPath: env.configPath,
129
139
  logPath: env.logPath,
130
140
  cloudflaredHome: env.cloudflaredHome,
141
+ configDir: env.configDir,
142
+ skipHub: true,
131
143
  now: () => new Date("2026-04-22T12:00:00Z"),
132
144
  });
133
145
 
@@ -170,7 +182,12 @@ describe("exposeCloudflareUp", () => {
170
182
  const yaml = readFileSync(env.configPath, "utf8");
171
183
  expect(yaml).toContain(`tunnel: ${uuid}`);
172
184
  expect(yaml).toContain("- hostname: vault.example.com");
173
- expect(yaml).toContain("service: http://localhost:1940");
185
+ // Routes through the hub (not directly at vault). The hub dispatches
186
+ // discovery / admin / OAuth / per-vault proxy / generic /<svc>/* —
187
+ // same shape Tailscale Funnel uses. Pre-2026-05-27 this was
188
+ // http://localhost:1940 (vault's port), which served vault's own 404
189
+ // page on every request that wasn't /vault/<name>/...
190
+ expect(yaml).toContain(`service: http://localhost:${TEST_HUB_PORT}`);
174
191
 
175
192
  // Security copy surfaces both paths plus a pointer to the auth doc.
176
193
  const joined = logs.join("\n");
@@ -209,6 +226,8 @@ describe("exposeCloudflareUp", () => {
209
226
  configPath: env.configPath,
210
227
  logPath: env.logPath,
211
228
  cloudflaredHome: env.cloudflaredHome,
229
+ configDir: env.configDir,
230
+ skipHub: true,
212
231
  });
213
232
  expect(code).toBe(0);
214
233
  // No `tunnel create` — only list + route.
@@ -236,6 +255,8 @@ describe("exposeCloudflareUp", () => {
236
255
  configPath: env.configPath,
237
256
  logPath: env.logPath,
238
257
  cloudflaredHome: env.cloudflaredHome,
258
+ configDir: env.configDir,
259
+ skipHub: true,
239
260
  });
240
261
 
241
262
  expect(code).toBe(1);
@@ -262,6 +283,8 @@ describe("exposeCloudflareUp", () => {
262
283
  configPath: env.configPath,
263
284
  logPath: env.logPath,
264
285
  cloudflaredHome: env.cloudflaredHome,
286
+ configDir: env.configDir,
287
+ skipHub: true,
265
288
  tunnelName: "bad name with spaces",
266
289
  });
267
290
 
@@ -291,6 +314,8 @@ describe("exposeCloudflareUp", () => {
291
314
  configPath: env.configPath,
292
315
  logPath: env.logPath,
293
316
  cloudflaredHome: env.cloudflaredHome,
317
+ configDir: env.configDir,
318
+ skipHub: true,
294
319
  });
295
320
 
296
321
  expect(code).toBe(1);
@@ -317,6 +342,8 @@ describe("exposeCloudflareUp", () => {
317
342
  configPath: env.configPath,
318
343
  logPath: env.logPath,
319
344
  cloudflaredHome: env.cloudflaredHome,
345
+ configDir: env.configDir,
346
+ skipHub: true,
320
347
  });
321
348
 
322
349
  expect(code).toBe(1);
@@ -342,6 +369,8 @@ describe("exposeCloudflareUp", () => {
342
369
  configPath: env.configPath,
343
370
  logPath: env.logPath,
344
371
  cloudflaredHome: env.cloudflaredHome,
372
+ configDir: env.configDir,
373
+ skipHub: true,
345
374
  });
346
375
 
347
376
  expect(code).toBe(1);
@@ -376,6 +405,8 @@ describe("exposeCloudflareUp", () => {
376
405
  configPath: env.configPath,
377
406
  logPath: env.logPath,
378
407
  cloudflaredHome: env.cloudflaredHome,
408
+ configDir: env.configDir,
409
+ skipHub: true,
379
410
  });
380
411
 
381
412
  expect(code).toBe(1);
@@ -425,6 +456,8 @@ describe("exposeCloudflareUp", () => {
425
456
  configPath: env.configPath,
426
457
  logPath: env.logPath,
427
458
  cloudflaredHome: env.cloudflaredHome,
459
+ configDir: env.configDir,
460
+ skipHub: true,
428
461
  });
429
462
 
430
463
  expect(code).toBe(0);
@@ -462,6 +495,8 @@ describe("exposeCloudflareUp", () => {
462
495
  manifestPath: env.manifestPath,
463
496
  statePath: env.statePath,
464
497
  cloudflaredHome: env.cloudflaredHome,
498
+ configDir: env.configDir,
499
+ skipHub: true,
465
500
  // Use defaults for configPath/logPath so they're per-tunnel-derived.
466
501
  });
467
502
  expect(code1).toBe(0);
@@ -487,6 +522,8 @@ describe("exposeCloudflareUp", () => {
487
522
  manifestPath: env.manifestPath,
488
523
  statePath: env.statePath,
489
524
  cloudflaredHome: env.cloudflaredHome,
525
+ configDir: env.configDir,
526
+ skipHub: true,
490
527
  tunnelName: "second",
491
528
  });
492
529
  expect(code2).toBe(0);
@@ -544,6 +581,8 @@ describe("exposeCloudflareUp", () => {
544
581
  configPath: env.configPath,
545
582
  logPath: env.logPath,
546
583
  cloudflaredHome: env.cloudflaredHome,
584
+ configDir: env.configDir,
585
+ skipHub: true,
547
586
  // No password, no 2FA — fully wide open. The warning should still
548
587
  // fire; password-recovery copy already lives in `printAuthGuidance`.
549
588
  vaultAuthStatus: {
@@ -592,6 +631,8 @@ describe("exposeCloudflareUp", () => {
592
631
  configPath: env.configPath,
593
632
  logPath: env.logPath,
594
633
  cloudflaredHome: env.cloudflaredHome,
634
+ configDir: env.configDir,
635
+ skipHub: true,
595
636
  vaultAuthStatus: {
596
637
  hasOwnerPassword: true,
597
638
  hasTotp: true,
@@ -612,6 +653,68 @@ describe("exposeCloudflareUp", () => {
612
653
  }
613
654
  });
614
655
  });
656
+
657
+ describe("routes through hub, not vault", () => {
658
+ test("config.yml targets the hub port; success log mentions Admin + OAuth URLs", async () => {
659
+ // Regression guard for the 2026-05-27 cut. Aaron ran `parachute expose
660
+ // public` on a fresh EC2 box, configured Cloudflare with a custom
661
+ // domain, and hit it — and got vault's 404 page rather than the hub's
662
+ // discovery / admin. The pre-fix cloudflared config routed straight at
663
+ // vault's port; the fix routes at the hub, mirroring the Tailscale
664
+ // Funnel shape (single mount → hub catchall; hub dispatches per-request).
665
+ const env = makeEnv();
666
+ try {
667
+ // Re-seed hub port to a non-default value so the assertion is
668
+ // unambiguous about *which* port got into the yaml.
669
+ writeHubPort(1949, env.configDir);
670
+
671
+ const uuid = "ffff0000-0000-0000-0000-00000000beef";
672
+ const { runner } = queueRunner([
673
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
674
+ { code: 0, stdout: "[]", stderr: "" },
675
+ {
676
+ code: 0,
677
+ stdout: `Created tunnel parachute with id ${uuid}\n`,
678
+ stderr: "",
679
+ },
680
+ { code: 0, stdout: "", stderr: "" },
681
+ ]);
682
+ const { spawner } = fakeSpawner(60001);
683
+ const logs: string[] = [];
684
+
685
+ const code = await exposeCloudflareUp("gitcoin.parachute.computer", {
686
+ runner,
687
+ spawner,
688
+ alive: () => false,
689
+ kill: () => {},
690
+ log: (l) => logs.push(l),
691
+ manifestPath: env.manifestPath,
692
+ statePath: env.statePath,
693
+ configPath: env.configPath,
694
+ logPath: env.logPath,
695
+ cloudflaredHome: env.cloudflaredHome,
696
+ configDir: env.configDir,
697
+ skipHub: true,
698
+ });
699
+
700
+ expect(code).toBe(0);
701
+ const yaml = readFileSync(env.configPath, "utf8");
702
+ // Routes through the hub on its loopback port.
703
+ expect(yaml).toContain("service: http://localhost:1949");
704
+ // Does NOT route directly at vault's port (1940 per makeEnv default).
705
+ expect(yaml).not.toContain("service: http://localhost:1940");
706
+
707
+ const joined = logs.join("\n");
708
+ // Discoverable surfaces: open / admin / vault / OAuth all surfaced.
709
+ expect(joined).toContain("https://gitcoin.parachute.computer/");
710
+ expect(joined).toContain("Admin: https://gitcoin.parachute.computer/admin/");
711
+ expect(joined).toContain("Vault: https://gitcoin.parachute.computer/vault/default");
712
+ expect(joined).toContain("OAuth: https://gitcoin.parachute.computer");
713
+ } finally {
714
+ env.cleanup();
715
+ }
716
+ });
717
+ });
615
718
  });
616
719
 
617
720
  describe("exposeCloudflareOff", () => {
@@ -469,10 +469,16 @@ describe("exposePublicInteractive — neither ready", () => {
469
469
  expect(interactiveCalled).toBe(false);
470
470
  expect(cloudflareCalled).toBe(false);
471
471
  const joined = logs.join("\n");
472
- expect(joined).toMatch(/apt-get|dnf/);
473
- expect(joined).toContain(
474
- "developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads",
475
- );
472
+ // Post 2026-05-27 cloudflared-URL refresh: the install hint moved
473
+ // off apt-get / dnf / developers.cloudflare.com (all unreliable —
474
+ // Aaron hit `No match for argument: cloudflared` on AL2023 and
475
+ // 404s from the docs URL on the same box) onto the static binary
476
+ // from GitHub releases.
477
+ expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
478
+ expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
479
+ expect(joined).not.toContain("developers.cloudflare.com");
480
+ expect(joined).not.toContain("pkg.cloudflare.com");
481
+ expect(joined).not.toContain("sudo dnf install cloudflared");
476
482
  } finally {
477
483
  env.cleanup();
478
484
  }
@@ -126,7 +126,11 @@ describe("exposePublicAutoPick — neither ready", () => {
126
126
  expect(code).toBe(1);
127
127
  const joined = logs.join("\n");
128
128
  expect(joined).toContain("tailscale.com/download");
129
- expect(joined).toContain("developers.cloudflare.com");
129
+ // Post 2026-05-27 cloudflared-URL refresh: the install hint now points
130
+ // at GitHub releases (developers.cloudflare.com / pkg.cloudflare.com
131
+ // both returned HTML/404 on Aaron's fresh AL2023 EC2 box).
132
+ expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
133
+ expect(joined).not.toContain("developers.cloudflare.com");
130
134
  expect(joined).toContain("--skip-provider-check");
131
135
  });
132
136