@openparachute/hub 0.5.14-rc.8 → 0.6.0

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.
Files changed (87) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-revoke-token.test.ts +384 -0
  9. package/src/__tests__/api-users.test.ts +7 -2
  10. package/src/__tests__/auth.test.ts +157 -30
  11. package/src/__tests__/cli.test.ts +44 -5
  12. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  13. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  14. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  15. package/src/__tests__/expose.test.ts +52 -2
  16. package/src/__tests__/hub-server.test.ts +97 -0
  17. package/src/__tests__/hub.test.ts +85 -6
  18. package/src/__tests__/init.test.ts +102 -1
  19. package/src/__tests__/lifecycle.test.ts +464 -2
  20. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  21. package/src/__tests__/oauth-ui.test.ts +12 -1
  22. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  23. package/src/__tests__/resource-binding.test.ts +97 -0
  24. package/src/__tests__/scope-explanations.test.ts +77 -12
  25. package/src/__tests__/services-manifest.test.ts +122 -4
  26. package/src/__tests__/setup-wizard.test.ts +335 -15
  27. package/src/__tests__/status.test.ts +36 -0
  28. package/src/__tests__/two-factor-flow.test.ts +602 -0
  29. package/src/__tests__/two-factor.test.ts +183 -0
  30. package/src/__tests__/upgrade.test.ts +78 -1
  31. package/src/__tests__/users.test.ts +68 -0
  32. package/src/__tests__/vault-auth-status.test.ts +47 -6
  33. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  34. package/src/account-home-ui.ts +488 -38
  35. package/src/account-vault-token.ts +282 -0
  36. package/src/admin-handlers.ts +159 -4
  37. package/src/admin-login-ui.ts +49 -5
  38. package/src/admin-vaults.ts +48 -15
  39. package/src/api-account.ts +14 -0
  40. package/src/api-mint-token.ts +132 -24
  41. package/src/api-modules-ops.ts +49 -11
  42. package/src/api-revoke-token.ts +107 -21
  43. package/src/api-users.ts +29 -3
  44. package/src/cli.ts +26 -21
  45. package/src/clients.ts +18 -6
  46. package/src/cloudflare/config.ts +10 -4
  47. package/src/cloudflare/detect.ts +39 -44
  48. package/src/commands/auth.ts +165 -24
  49. package/src/commands/expose-2fa-warning.ts +34 -32
  50. package/src/commands/expose-auth-preflight.ts +89 -78
  51. package/src/commands/expose-cloudflare.ts +370 -12
  52. package/src/commands/expose.ts +8 -0
  53. package/src/commands/init.ts +33 -2
  54. package/src/commands/lifecycle.ts +386 -17
  55. package/src/commands/status.ts +22 -0
  56. package/src/commands/upgrade.ts +55 -11
  57. package/src/commands/wizard.ts +8 -4
  58. package/src/env-file.ts +10 -0
  59. package/src/help.ts +3 -1
  60. package/src/hub-db.ts +39 -1
  61. package/src/hub-server.ts +52 -0
  62. package/src/hub.ts +82 -14
  63. package/src/oauth-handlers.ts +298 -21
  64. package/src/oauth-ui.ts +10 -0
  65. package/src/operator-token.ts +151 -0
  66. package/src/pending-login.ts +116 -0
  67. package/src/rate-limit.ts +51 -0
  68. package/src/resource-binding.ts +134 -0
  69. package/src/scope-attenuation.ts +85 -0
  70. package/src/scope-explanations.ts +131 -14
  71. package/src/services-manifest.ts +112 -0
  72. package/src/setup-wizard.ts +77 -7
  73. package/src/tailscale/run.ts +28 -11
  74. package/src/totp.ts +201 -0
  75. package/src/two-factor-handlers.ts +287 -0
  76. package/src/two-factor-store.ts +181 -0
  77. package/src/two-factor-ui.ts +462 -0
  78. package/src/users.ts +58 -0
  79. package/src/vault/auth-status.ts +71 -19
  80. package/src/vault-hub-origin-env.ts +163 -0
  81. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  82. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  83. package/web/ui/dist/index.html +2 -2
  84. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  85. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  86. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  87. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Tests for the durable half of the OAuth issuer-mismatch fix: persisting the
3
+ * hub's PUBLIC origin into `<configDir>/vault/.env` so the launchd / systemd
4
+ * daemon — which boots vault out-of-band and never sees the `parachute start`
5
+ * spawn env — validates hub-minted JWTs' `iss` against the public origin
6
+ * instead of vault's loopback default. See `vault-hub-origin-env.ts`.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { readEnvFileValues } from "../env-file.ts";
13
+ import type { ExposeState } from "../expose-state.ts";
14
+ import { writeExposeState } from "../expose-state.ts";
15
+ import {
16
+ clearVaultHubOrigin,
17
+ isLoopbackOrigin,
18
+ persistVaultHubOrigin,
19
+ publicOriginFromExposeState,
20
+ selfHealVaultHubOrigin,
21
+ } from "../vault-hub-origin-env.ts";
22
+
23
+ let dir: string;
24
+
25
+ beforeEach(() => {
26
+ dir = mkdtempSync(join(tmpdir(), "pcli-vhoe-"));
27
+ });
28
+ afterEach(() => {
29
+ rmSync(dir, { recursive: true, force: true });
30
+ });
31
+
32
+ function vaultEnv(): string {
33
+ return join(dir, "vault", ".env");
34
+ }
35
+
36
+ describe("isLoopbackOrigin", () => {
37
+ test("flags 127.0.0.1 / localhost / [::1] / 0.0.0.0", () => {
38
+ expect(isLoopbackOrigin("http://127.0.0.1:1939")).toBe(true);
39
+ expect(isLoopbackOrigin("http://localhost:1939")).toBe(true);
40
+ expect(isLoopbackOrigin("http://[::1]:1939")).toBe(true);
41
+ // 0.0.0.0 is a bind-all wildcard, not a reachable origin.
42
+ expect(isLoopbackOrigin("http://0.0.0.0:1939")).toBe(true);
43
+ });
44
+
45
+ test("does not flag a public FQDN", () => {
46
+ expect(isLoopbackOrigin("https://parachute-aaron.tailc75afc.ts.net")).toBe(false);
47
+ expect(isLoopbackOrigin("https://hub.example.com")).toBe(false);
48
+ });
49
+
50
+ test("non-URL strings are treated as non-loopback (don't block persistence)", () => {
51
+ expect(isLoopbackOrigin("not a url")).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe("persistVaultHubOrigin", () => {
56
+ test("writes a non-loopback public origin into vault/.env", () => {
57
+ const wrote = persistVaultHubOrigin(dir, "https://parachute-aaron.tailc75afc.ts.net", () => {});
58
+ expect(wrote).toBe(true);
59
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
60
+ "https://parachute-aaron.tailc75afc.ts.net",
61
+ );
62
+ });
63
+
64
+ test("refuses to persist a loopback origin (would shadow a later exposure)", () => {
65
+ const wrote = persistVaultHubOrigin(dir, "http://127.0.0.1:1939", () => {});
66
+ expect(wrote).toBe(false);
67
+ expect(existsSync(vaultEnv())).toBe(false);
68
+ });
69
+
70
+ test("refuses to persist a 0.0.0.0 origin (--hub-origin flows straight through)", () => {
71
+ // `--hub-origin http://0.0.0.0:1939` bypasses deriveHubOrigin and reaches
72
+ // here verbatim; baking a bind-all wildcard into vault/.env would advertise
73
+ // a non-functional issuer and recreate the iss-mismatch class.
74
+ const wrote = persistVaultHubOrigin(dir, "http://0.0.0.0:1939", () => {});
75
+ expect(wrote).toBe(false);
76
+ expect(existsSync(vaultEnv())).toBe(false);
77
+ });
78
+
79
+ test("is idempotent — no rewrite when the value is already current", () => {
80
+ const log: string[] = [];
81
+ expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(true);
82
+ expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(false);
83
+ // Only the first call logged.
84
+ expect(log).toHaveLength(1);
85
+ expect(log[0]).toMatch(/persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/);
86
+ });
87
+
88
+ test("updates a stale origin in-place and preserves sibling keys", () => {
89
+ writeFileSync(
90
+ mkVaultDir(),
91
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://old.example.com\nSCRIBE_URL=http://127.0.0.1:1943\n",
92
+ );
93
+ const wrote = persistVaultHubOrigin(dir, "https://new.example.com", () => {});
94
+ expect(wrote).toBe(true);
95
+ const values = readEnvFileValues(vaultEnv());
96
+ expect(values.PARACHUTE_HUB_ORIGIN).toBe("https://new.example.com");
97
+ // Sibling keys untouched.
98
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
99
+ expect(values.SCRIBE_URL).toBe("http://127.0.0.1:1943");
100
+ });
101
+ });
102
+
103
+ describe("clearVaultHubOrigin", () => {
104
+ test("removes a persisted origin and leaves sibling keys", () => {
105
+ writeFileSync(
106
+ mkVaultDir(),
107
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://hub.example.com\n",
108
+ );
109
+ const wrote = clearVaultHubOrigin(dir, () => {});
110
+ expect(wrote).toBe(true);
111
+ const values = readEnvFileValues(vaultEnv());
112
+ expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
113
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
114
+ });
115
+
116
+ test("no-op when no origin is present", () => {
117
+ writeFileSync(mkVaultDir(), "SCRIBE_AUTH_TOKEN=secret\n");
118
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
119
+ });
120
+
121
+ test("no-op when vault/.env doesn't exist", () => {
122
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
123
+ });
124
+ });
125
+
126
+ /** Create `<dir>/vault/` and return the `.env` path so writeFileSync lands. */
127
+ function mkVaultDir(): string {
128
+ mkdirSync(join(dir, "vault"), { recursive: true });
129
+ return vaultEnv();
130
+ }
131
+
132
+ function exposeStatePath(): string {
133
+ return join(dir, "expose-state.json");
134
+ }
135
+
136
+ /** Cloudflare-shaped expose state (subdomain mode, single hub-catchall entry). */
137
+ function cloudflareState(overrides: Partial<ExposeState> = {}): ExposeState {
138
+ return {
139
+ version: 1,
140
+ layer: "public",
141
+ mode: "subdomain",
142
+ canonicalFqdn: "gitcoin-parachute.unforced.dev",
143
+ port: 1939,
144
+ funnel: false,
145
+ entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
146
+ hubOrigin: "https://gitcoin-parachute.unforced.dev",
147
+ ...overrides,
148
+ };
149
+ }
150
+
151
+ /** Tailnet-shaped expose state (path mode). */
152
+ function tailnetState(overrides: Partial<ExposeState> = {}): ExposeState {
153
+ return {
154
+ version: 1,
155
+ layer: "tailnet",
156
+ mode: "path",
157
+ canonicalFqdn: "parachute-aaron.tailc75afc.ts.net",
158
+ port: 1939,
159
+ funnel: false,
160
+ entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
161
+ hubOrigin: "https://parachute-aaron.tailc75afc.ts.net",
162
+ ...overrides,
163
+ };
164
+ }
165
+
166
+ describe("publicOriginFromExposeState", () => {
167
+ test("undefined when no expose-state file exists", () => {
168
+ expect(publicOriginFromExposeState(exposeStatePath())).toBeUndefined();
169
+ });
170
+
171
+ test("returns the cloudflare hubOrigin", () => {
172
+ writeExposeState(cloudflareState(), exposeStatePath());
173
+ expect(publicOriginFromExposeState(exposeStatePath())).toBe(
174
+ "https://gitcoin-parachute.unforced.dev",
175
+ );
176
+ });
177
+
178
+ test("returns the tailnet hubOrigin", () => {
179
+ writeExposeState(tailnetState(), exposeStatePath());
180
+ expect(publicOriginFromExposeState(exposeStatePath())).toBe(
181
+ "https://parachute-aaron.tailc75afc.ts.net",
182
+ );
183
+ });
184
+
185
+ test("synthesizes https://<canonicalFqdn> when hubOrigin is absent (pre-Phase-0 state)", () => {
186
+ // hubOrigin is optional on older state files; canonicalFqdn is mandatory.
187
+ const { hubOrigin, ...rest } = cloudflareState();
188
+ void hubOrigin;
189
+ writeExposeState(rest as ExposeState, exposeStatePath());
190
+ expect(publicOriginFromExposeState(exposeStatePath())).toBe(
191
+ "https://gitcoin-parachute.unforced.dev",
192
+ );
193
+ });
194
+ });
195
+
196
+ describe("selfHealVaultHubOrigin (Cloudflare 401 self-heal)", () => {
197
+ test("writes the cloudflare public origin when vault/.env is UNSET", () => {
198
+ // The exact broken-deploy shape: expose-state carries a public cloudflare
199
+ // hubOrigin but vault/.env has no PARACHUTE_HUB_ORIGIN, so the daemon falls
200
+ // back to loopback and 401s every hub token. Restart self-corrects it.
201
+ writeExposeState(cloudflareState(), exposeStatePath());
202
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
203
+ expect(wrote).toBe(true);
204
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
205
+ "https://gitcoin-parachute.unforced.dev",
206
+ );
207
+ });
208
+
209
+ test("overwrites a LOOPBACK value already persisted in vault/.env", () => {
210
+ writeExposeState(cloudflareState(), exposeStatePath());
211
+ writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n");
212
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
213
+ expect(wrote).toBe(true);
214
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
215
+ "https://gitcoin-parachute.unforced.dev",
216
+ );
217
+ });
218
+
219
+ test("tailnet shape still self-heals (no regression)", () => {
220
+ writeExposeState(tailnetState(), exposeStatePath());
221
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
222
+ expect(wrote).toBe(true);
223
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
224
+ "https://parachute-aaron.tailc75afc.ts.net",
225
+ );
226
+ });
227
+
228
+ test("does NOT persist when there's no exposure (genuine loopback / local dev)", () => {
229
+ // No expose-state file → no public origin → vault keeps its loopback
230
+ // default. Persisting loopback would shadow a later exposure.
231
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
232
+ expect(wrote).toBe(false);
233
+ expect(existsSync(vaultEnv())).toBe(false);
234
+ });
235
+
236
+ test("leaves a DIFFERENT non-loopback value alone (deliberate --hub-origin override)", () => {
237
+ writeExposeState(cloudflareState(), exposeStatePath());
238
+ writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=https://custom.example.com\n");
239
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
240
+ expect(wrote).toBe(false);
241
+ // Untouched — self-heal only fixes unset/loopback, never clobbers a public
242
+ // value an operator may have set on purpose.
243
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe("https://custom.example.com");
244
+ });
245
+
246
+ test("no-op (no double-write) when the persisted value already equals the public origin", () => {
247
+ writeExposeState(cloudflareState(), exposeStatePath());
248
+ writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=https://gitcoin-parachute.unforced.dev\n");
249
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
250
+ expect(wrote).toBe(false);
251
+ });
252
+
253
+ test("expose-state with a loopback hubOrigin is treated as no public exposure", () => {
254
+ // A loopback hubOrigin (local-dev hub) must never be persisted — it would
255
+ // recreate the iss mismatch on the daemon boot path.
256
+ writeExposeState(cloudflareState({ hubOrigin: "http://127.0.0.1:1939" }), exposeStatePath());
257
+ // canonicalFqdn is still public here, but hubOrigin wins — we honor the
258
+ // explicit value the writer chose.
259
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
260
+ expect(wrote).toBe(false);
261
+ expect(existsSync(vaultEnv())).toBe(false);
262
+ });
263
+ });