@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) 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-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -22,11 +22,146 @@ function seedVault(vaultHome: string, name: string, opts: { withDb?: boolean } =
22
22
  return dbPath;
23
23
  }
24
24
 
25
- describe("readVaultAuthStatus — config.yaml parse", () => {
26
- test("missing config.yaml hasOwnerPassword + hasTotp both false", () => {
25
+ /**
26
+ * Default tests pass a `probeHubDbHasUserPassword` of `() => undefined`
27
+ * (hub.db unreachable) so existing YAML-fallback behavior is exercised
28
+ * verbatim. Tests that specifically exercise the hub.db path pass their
29
+ * own probe.
30
+ */
31
+ const hubDbUnreachable = () => undefined;
32
+
33
+ describe("readVaultAuthStatus — hub.db source of truth (multi-user Phase 1+)", () => {
34
+ test("hub.db has a user with password_hash → hasOwnerPassword: true (no YAML needed)", () => {
35
+ const env = makeVaultHome();
36
+ try {
37
+ // No config.yaml at all on disk.
38
+ const status = readVaultAuthStatus({
39
+ vaultHome: env.path,
40
+ countTokens: () => 0,
41
+ probeHubDbHasUserPassword: () => true,
42
+ probeHubDbHasTotp: () => false,
43
+ });
44
+ expect(status.hasOwnerPassword).toBe(true);
45
+ // No TOTP enrolled in hub.db (and no legacy YAML) → false.
46
+ expect(status.hasTotp).toBe(false);
47
+ } finally {
48
+ env.cleanup();
49
+ }
50
+ });
51
+
52
+ test("hub.db users empty, YAML has owner_password_hash → falls back to YAML (legacy install)", () => {
53
+ const env = makeVaultHome();
54
+ try {
55
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$legacyhash"\n');
56
+ const status = readVaultAuthStatus({
57
+ vaultHome: env.path,
58
+ countTokens: () => 0,
59
+ probeHubDbHasUserPassword: () => false,
60
+ probeHubDbHasTotp: () => false,
61
+ });
62
+ expect(status.hasOwnerPassword).toBe(true);
63
+ expect(status.hasTotp).toBe(false);
64
+ } finally {
65
+ env.cleanup();
66
+ }
67
+ });
68
+
69
+ test("hub.db unreachable, YAML has owner_password_hash → falls back to YAML", () => {
70
+ const env = makeVaultHome();
71
+ try {
72
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$superlegacyhash"\n');
73
+ const status = readVaultAuthStatus({
74
+ vaultHome: env.path,
75
+ countTokens: () => 0,
76
+ probeHubDbHasUserPassword: hubDbUnreachable,
77
+ });
78
+ expect(status.hasOwnerPassword).toBe(true);
79
+ } finally {
80
+ env.cleanup();
81
+ }
82
+ });
83
+
84
+ test("hub.db users empty AND no YAML → hasOwnerPassword: false (fresh wide-open install)", () => {
85
+ const env = makeVaultHome();
86
+ try {
87
+ const status = readVaultAuthStatus({
88
+ vaultHome: env.path,
89
+ countTokens: () => 0,
90
+ probeHubDbHasUserPassword: () => false,
91
+ probeHubDbHasTotp: () => false,
92
+ });
93
+ expect(status.hasOwnerPassword).toBe(false);
94
+ expect(status.hasTotp).toBe(false);
95
+ } finally {
96
+ env.cleanup();
97
+ }
98
+ });
99
+
100
+ test("hub.db password=true, hub.db TOTP unreachable → TOTP falls back to YAML state", () => {
101
+ const env = makeVaultHome();
102
+ try {
103
+ // hub#473: hub.db is the canonical TOTP source, but when the TOTP probe
104
+ // is unreachable (pre-v11 column absent) it falls back to the legacy
105
+ // vault YAML totp_secret. password=true (hub.db), totp=true (YAML fallback).
106
+ writeConfig(env.path, 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
107
+ const status = readVaultAuthStatus({
108
+ vaultHome: env.path,
109
+ countTokens: () => 0,
110
+ probeHubDbHasUserPassword: () => true,
111
+ probeHubDbHasTotp: () => undefined,
112
+ });
113
+ expect(status.hasOwnerPassword).toBe(true);
114
+ expect(status.hasTotp).toBe(true);
115
+ } finally {
116
+ env.cleanup();
117
+ }
118
+ });
119
+
120
+ test("hub.db TOTP=true is the real signal — overrides absent YAML", () => {
121
+ const env = makeVaultHome();
122
+ try {
123
+ // No YAML totp_secret, but a hub.db user has enrolled real 2FA (hub#473).
124
+ const status = readVaultAuthStatus({
125
+ vaultHome: env.path,
126
+ countTokens: () => 0,
127
+ probeHubDbHasUserPassword: () => true,
128
+ probeHubDbHasTotp: () => true,
129
+ });
130
+ expect(status.hasTotp).toBe(true);
131
+ } finally {
132
+ env.cleanup();
133
+ }
134
+ });
135
+
136
+ test("hub.db TOTP=false (column present, none enrolled) overrides a stale YAML true", () => {
137
+ const env = makeVaultHome();
138
+ try {
139
+ // Legacy YAML totp_secret present, but hub.db definitively says no user
140
+ // has enrolled real hub-login 2FA → report false (the real signal wins).
141
+ writeConfig(env.path, 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
142
+ const status = readVaultAuthStatus({
143
+ vaultHome: env.path,
144
+ countTokens: () => 0,
145
+ probeHubDbHasUserPassword: () => true,
146
+ probeHubDbHasTotp: () => false,
147
+ });
148
+ expect(status.hasTotp).toBe(false);
149
+ } finally {
150
+ env.cleanup();
151
+ }
152
+ });
153
+ });
154
+
155
+ describe("readVaultAuthStatus — YAML fallback (pre-multi-user installs)", () => {
156
+ test("missing config.yaml AND hub.db unreachable → both signals false", () => {
27
157
  const env = makeVaultHome();
28
158
  try {
29
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
159
+ const status = readVaultAuthStatus({
160
+ vaultHome: env.path,
161
+ countTokens: () => 0,
162
+ probeHubDbHasUserPassword: hubDbUnreachable,
163
+ probeHubDbHasTotp: hubDbUnreachable,
164
+ });
30
165
  expect(status.hasOwnerPassword).toBe(false);
31
166
  expect(status.hasTotp).toBe(false);
32
167
  } finally {
@@ -34,7 +169,7 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
34
169
  }
35
170
  });
36
171
 
37
- test("both keys present and non-empty → both true", () => {
172
+ test("both YAML keys present, hub.db unreachable → both true", () => {
38
173
  const env = makeVaultHome();
39
174
  try {
40
175
  writeConfig(
@@ -46,7 +181,12 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
46
181
  "",
47
182
  ].join("\n"),
48
183
  );
49
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
184
+ const status = readVaultAuthStatus({
185
+ vaultHome: env.path,
186
+ countTokens: () => 0,
187
+ probeHubDbHasUserPassword: hubDbUnreachable,
188
+ probeHubDbHasTotp: hubDbUnreachable,
189
+ });
50
190
  expect(status.hasOwnerPassword).toBe(true);
51
191
  expect(status.hasTotp).toBe(true);
52
192
  } finally {
@@ -54,11 +194,16 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
54
194
  }
55
195
  });
56
196
 
57
- test("empty quoted values are treated as absent (matches vault's readGlobalConfig)", () => {
197
+ test("empty quoted YAML values are absent (matches vault's readGlobalConfig)", () => {
58
198
  const env = makeVaultHome();
59
199
  try {
60
200
  writeConfig(env.path, ['owner_password_hash: ""', 'totp_secret: ""', ""].join("\n"));
61
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
201
+ const status = readVaultAuthStatus({
202
+ vaultHome: env.path,
203
+ countTokens: () => 0,
204
+ probeHubDbHasUserPassword: hubDbUnreachable,
205
+ probeHubDbHasTotp: hubDbUnreachable,
206
+ });
62
207
  expect(status.hasOwnerPassword).toBe(false);
63
208
  expect(status.hasTotp).toBe(false);
64
209
  } finally {
@@ -66,11 +211,16 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
66
211
  }
67
212
  });
68
213
 
69
- test("only owner_password_hash present", () => {
214
+ test("only YAML owner_password_hash present, hub.db unreachable", () => {
70
215
  const env = makeVaultHome();
71
216
  try {
72
217
  writeConfig(env.path, 'owner_password_hash: "$2b$12$abc"\n');
73
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
218
+ const status = readVaultAuthStatus({
219
+ vaultHome: env.path,
220
+ countTokens: () => 0,
221
+ probeHubDbHasUserPassword: hubDbUnreachable,
222
+ probeHubDbHasTotp: hubDbUnreachable,
223
+ });
74
224
  expect(status.hasOwnerPassword).toBe(true);
75
225
  expect(status.hasTotp).toBe(false);
76
226
  } finally {
@@ -83,7 +233,11 @@ describe("readVaultAuthStatus — vault discovery", () => {
83
233
  test("no data/ dir → vaultNames empty, tokenCount 0", () => {
84
234
  const env = makeVaultHome();
85
235
  try {
86
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 999 });
236
+ const status = readVaultAuthStatus({
237
+ vaultHome: env.path,
238
+ countTokens: () => 999,
239
+ probeHubDbHasUserPassword: hubDbUnreachable,
240
+ });
87
241
  expect(status.vaultNames).toEqual([]);
88
242
  expect(status.tokenCount).toBe(0);
89
243
  } finally {
@@ -98,7 +252,11 @@ describe("readVaultAuthStatus — vault discovery", () => {
98
252
  seedVault(env.path, "default", { withDb: true });
99
253
  // garbage dir that happens to sit under data/
100
254
  mkdirSync(join(env.path, "data", "stray"), { recursive: true });
101
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
255
+ const status = readVaultAuthStatus({
256
+ vaultHome: env.path,
257
+ countTokens: () => 0,
258
+ probeHubDbHasUserPassword: hubDbUnreachable,
259
+ });
102
260
  expect(status.vaultNames).toEqual(["default"]);
103
261
  } finally {
104
262
  env.cleanup();
@@ -115,6 +273,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
115
273
  const status = readVaultAuthStatus({
116
274
  vaultHome: env.path,
117
275
  countTokens: (dbPath) => (dbPath.includes("/default/") ? 2 : 3),
276
+ probeHubDbHasUserPassword: hubDbUnreachable,
118
277
  });
119
278
  expect(status.tokenCount).toBe(5);
120
279
  expect(new Set(status.vaultNames)).toEqual(new Set(["default", "work"]));
@@ -135,6 +294,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
135
294
  if (dbPath.includes("/default/")) throw new Error("should not open missing DB");
136
295
  return 4;
137
296
  },
297
+ probeHubDbHasUserPassword: hubDbUnreachable,
138
298
  });
139
299
  expect(status.tokenCount).toBe(4);
140
300
  } finally {
@@ -153,6 +313,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
153
313
  if (dbPath.includes("/work/")) throw new Error("locked");
154
314
  return 2;
155
315
  },
316
+ probeHubDbHasUserPassword: hubDbUnreachable,
156
317
  });
157
318
  // Even though "default" succeeded with 2, we return null — callers
158
319
  // shouldn't see a misleading partial count.
@@ -162,3 +323,143 @@ describe("readVaultAuthStatus — token count resilience", () => {
162
323
  }
163
324
  });
164
325
  });
326
+
327
+ describe("readVaultAuthStatus — defaultProbeHubDbHasUserPassword end-to-end", () => {
328
+ // These tests exercise the real `bun:sqlite` probe (no injected fake)
329
+ // to catch breakage in the on-disk read path: schema drift, opening
330
+ // semantics, undefined-returns-on-failure.
331
+
332
+ test("hub.db missing → probe returns undefined → falls back to YAML cleanly", () => {
333
+ const env = makeVaultHome();
334
+ try {
335
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$legacyfromYAML"\n');
336
+ const status = readVaultAuthStatus({
337
+ vaultHome: env.path,
338
+ hubDbPath: join(env.path, "definitely-not-here.db"),
339
+ countTokens: () => 0,
340
+ });
341
+ expect(status.hasOwnerPassword).toBe(true);
342
+ } finally {
343
+ env.cleanup();
344
+ }
345
+ });
346
+
347
+ test("hub.db exists with users.password_hash set → hasOwnerPassword: true", () => {
348
+ const env = makeVaultHome();
349
+ try {
350
+ // Build a real hub.db with the canonical schema + an `unforced` user.
351
+ const { Database } = require("bun:sqlite");
352
+ const dbPath = join(env.path, "hub.db");
353
+ const db = new Database(dbPath);
354
+ db.exec(`
355
+ CREATE TABLE users (
356
+ id TEXT PRIMARY KEY,
357
+ username TEXT UNIQUE NOT NULL,
358
+ password_hash TEXT NOT NULL,
359
+ created_at TEXT NOT NULL,
360
+ updated_at TEXT NOT NULL,
361
+ password_changed INTEGER NOT NULL DEFAULT 0
362
+ );
363
+ INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed)
364
+ VALUES ('u1', 'unforced', '$argon2id$v=19$realhashhere', '2026-05-26T00:00:00Z', '2026-05-26T00:00:00Z', 1);
365
+ `);
366
+ db.close();
367
+ const status = readVaultAuthStatus({
368
+ vaultHome: env.path,
369
+ hubDbPath: dbPath,
370
+ countTokens: () => 0,
371
+ });
372
+ expect(status.hasOwnerPassword).toBe(true);
373
+ } finally {
374
+ env.cleanup();
375
+ }
376
+ });
377
+
378
+ test("hub.db exists but users table empty → falls back to YAML", () => {
379
+ const env = makeVaultHome();
380
+ try {
381
+ const { Database } = require("bun:sqlite");
382
+ const dbPath = join(env.path, "hub.db");
383
+ const db = new Database(dbPath);
384
+ db.exec(`
385
+ CREATE TABLE users (
386
+ id TEXT PRIMARY KEY,
387
+ username TEXT UNIQUE NOT NULL,
388
+ password_hash TEXT NOT NULL,
389
+ created_at TEXT NOT NULL,
390
+ updated_at TEXT NOT NULL,
391
+ password_changed INTEGER NOT NULL DEFAULT 0
392
+ );
393
+ `);
394
+ db.close();
395
+ // YAML provides the password — should still be true.
396
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$fallbackhash"\n');
397
+ const status = readVaultAuthStatus({
398
+ vaultHome: env.path,
399
+ hubDbPath: dbPath,
400
+ countTokens: () => 0,
401
+ });
402
+ expect(status.hasOwnerPassword).toBe(true);
403
+ } finally {
404
+ env.cleanup();
405
+ }
406
+ });
407
+
408
+ test("hub.db exists but schema is missing the users table → probe returns undefined, YAML fallback", () => {
409
+ const env = makeVaultHome();
410
+ try {
411
+ // A hub.db that hasn't run migration v2 yet (only signing_keys, v1).
412
+ const { Database } = require("bun:sqlite");
413
+ const dbPath = join(env.path, "hub.db");
414
+ const db = new Database(dbPath);
415
+ db.exec(`
416
+ CREATE TABLE signing_keys (kid TEXT PRIMARY KEY);
417
+ `);
418
+ db.close();
419
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$onlyinYAML"\n');
420
+ const status = readVaultAuthStatus({
421
+ vaultHome: env.path,
422
+ hubDbPath: dbPath,
423
+ countTokens: () => 0,
424
+ });
425
+ // SELECT against nonexistent `users` throws → probe returns undefined
426
+ // → YAML wins → password reported as set.
427
+ expect(status.hasOwnerPassword).toBe(true);
428
+ } finally {
429
+ env.cleanup();
430
+ }
431
+ });
432
+
433
+ test("hub.db users table has a row with empty password_hash → treated as no password", () => {
434
+ const env = makeVaultHome();
435
+ try {
436
+ const { Database } = require("bun:sqlite");
437
+ const dbPath = join(env.path, "hub.db");
438
+ const db = new Database(dbPath);
439
+ // NOT NULL on password_hash, but allow empty string — schema check
440
+ // is just for "non-empty hash exists."
441
+ db.exec(`
442
+ CREATE TABLE users (
443
+ id TEXT PRIMARY KEY,
444
+ username TEXT UNIQUE NOT NULL,
445
+ password_hash TEXT NOT NULL,
446
+ created_at TEXT NOT NULL,
447
+ updated_at TEXT NOT NULL,
448
+ password_changed INTEGER NOT NULL DEFAULT 0
449
+ );
450
+ INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed)
451
+ VALUES ('u1', 'placeholder', '', '2026-05-26T00:00:00Z', '2026-05-26T00:00:00Z', 0);
452
+ `);
453
+ db.close();
454
+ const status = readVaultAuthStatus({
455
+ vaultHome: env.path,
456
+ hubDbPath: dbPath,
457
+ countTokens: () => 0,
458
+ });
459
+ // No YAML, no non-empty hub.db password → wide open.
460
+ expect(status.hasOwnerPassword).toBe(false);
461
+ } finally {
462
+ env.cleanup();
463
+ }
464
+ });
465
+ });
@@ -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
+ });