@openparachute/hub 0.5.13 → 0.5.14-rc.10

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 (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -22,11 +22,108 @@ 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
+ });
43
+ expect(status.hasOwnerPassword).toBe(true);
44
+ // No hub-side TOTP column yet (Phase 3). Stays false on the hub.db
45
+ // path until the schema gains a column.
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
+ });
61
+ expect(status.hasOwnerPassword).toBe(true);
62
+ expect(status.hasTotp).toBe(false);
63
+ } finally {
64
+ env.cleanup();
65
+ }
66
+ });
67
+
68
+ test("hub.db unreachable, YAML has owner_password_hash → falls back to YAML", () => {
69
+ const env = makeVaultHome();
70
+ try {
71
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$superlegacyhash"\n');
72
+ const status = readVaultAuthStatus({
73
+ vaultHome: env.path,
74
+ countTokens: () => 0,
75
+ probeHubDbHasUserPassword: hubDbUnreachable,
76
+ });
77
+ expect(status.hasOwnerPassword).toBe(true);
78
+ } finally {
79
+ env.cleanup();
80
+ }
81
+ });
82
+
83
+ test("hub.db users empty AND no YAML → hasOwnerPassword: false (fresh wide-open install)", () => {
84
+ const env = makeVaultHome();
85
+ try {
86
+ const status = readVaultAuthStatus({
87
+ vaultHome: env.path,
88
+ countTokens: () => 0,
89
+ probeHubDbHasUserPassword: () => false,
90
+ });
91
+ expect(status.hasOwnerPassword).toBe(false);
92
+ expect(status.hasTotp).toBe(false);
93
+ } finally {
94
+ env.cleanup();
95
+ }
96
+ });
97
+
98
+ test("hub.db says yes overrides absent YAML — TOTP still reflects YAML state", () => {
99
+ const env = makeVaultHome();
100
+ try {
101
+ // TOTP-only YAML: vault-side 2FA was configured but hub.db is the
102
+ // canonical password source. Should report password=true (hub.db),
103
+ // totp=true (YAML).
104
+ writeConfig(env.path, 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
105
+ const status = readVaultAuthStatus({
106
+ vaultHome: env.path,
107
+ countTokens: () => 0,
108
+ probeHubDbHasUserPassword: () => true,
109
+ });
110
+ expect(status.hasOwnerPassword).toBe(true);
111
+ expect(status.hasTotp).toBe(true);
112
+ } finally {
113
+ env.cleanup();
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("readVaultAuthStatus — YAML fallback (pre-multi-user installs)", () => {
119
+ test("missing config.yaml AND hub.db unreachable → both signals false", () => {
27
120
  const env = makeVaultHome();
28
121
  try {
29
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
122
+ const status = readVaultAuthStatus({
123
+ vaultHome: env.path,
124
+ countTokens: () => 0,
125
+ probeHubDbHasUserPassword: hubDbUnreachable,
126
+ });
30
127
  expect(status.hasOwnerPassword).toBe(false);
31
128
  expect(status.hasTotp).toBe(false);
32
129
  } finally {
@@ -34,7 +131,7 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
34
131
  }
35
132
  });
36
133
 
37
- test("both keys present and non-empty → both true", () => {
134
+ test("both YAML keys present, hub.db unreachable → both true", () => {
38
135
  const env = makeVaultHome();
39
136
  try {
40
137
  writeConfig(
@@ -46,7 +143,11 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
46
143
  "",
47
144
  ].join("\n"),
48
145
  );
49
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
146
+ const status = readVaultAuthStatus({
147
+ vaultHome: env.path,
148
+ countTokens: () => 0,
149
+ probeHubDbHasUserPassword: hubDbUnreachable,
150
+ });
50
151
  expect(status.hasOwnerPassword).toBe(true);
51
152
  expect(status.hasTotp).toBe(true);
52
153
  } finally {
@@ -54,11 +155,15 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
54
155
  }
55
156
  });
56
157
 
57
- test("empty quoted values are treated as absent (matches vault's readGlobalConfig)", () => {
158
+ test("empty quoted YAML values are absent (matches vault's readGlobalConfig)", () => {
58
159
  const env = makeVaultHome();
59
160
  try {
60
161
  writeConfig(env.path, ['owner_password_hash: ""', 'totp_secret: ""', ""].join("\n"));
61
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
162
+ const status = readVaultAuthStatus({
163
+ vaultHome: env.path,
164
+ countTokens: () => 0,
165
+ probeHubDbHasUserPassword: hubDbUnreachable,
166
+ });
62
167
  expect(status.hasOwnerPassword).toBe(false);
63
168
  expect(status.hasTotp).toBe(false);
64
169
  } finally {
@@ -66,11 +171,15 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
66
171
  }
67
172
  });
68
173
 
69
- test("only owner_password_hash present", () => {
174
+ test("only YAML owner_password_hash present, hub.db unreachable", () => {
70
175
  const env = makeVaultHome();
71
176
  try {
72
177
  writeConfig(env.path, 'owner_password_hash: "$2b$12$abc"\n');
73
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
178
+ const status = readVaultAuthStatus({
179
+ vaultHome: env.path,
180
+ countTokens: () => 0,
181
+ probeHubDbHasUserPassword: hubDbUnreachable,
182
+ });
74
183
  expect(status.hasOwnerPassword).toBe(true);
75
184
  expect(status.hasTotp).toBe(false);
76
185
  } finally {
@@ -83,7 +192,11 @@ describe("readVaultAuthStatus — vault discovery", () => {
83
192
  test("no data/ dir → vaultNames empty, tokenCount 0", () => {
84
193
  const env = makeVaultHome();
85
194
  try {
86
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 999 });
195
+ const status = readVaultAuthStatus({
196
+ vaultHome: env.path,
197
+ countTokens: () => 999,
198
+ probeHubDbHasUserPassword: hubDbUnreachable,
199
+ });
87
200
  expect(status.vaultNames).toEqual([]);
88
201
  expect(status.tokenCount).toBe(0);
89
202
  } finally {
@@ -98,7 +211,11 @@ describe("readVaultAuthStatus — vault discovery", () => {
98
211
  seedVault(env.path, "default", { withDb: true });
99
212
  // garbage dir that happens to sit under data/
100
213
  mkdirSync(join(env.path, "data", "stray"), { recursive: true });
101
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
214
+ const status = readVaultAuthStatus({
215
+ vaultHome: env.path,
216
+ countTokens: () => 0,
217
+ probeHubDbHasUserPassword: hubDbUnreachable,
218
+ });
102
219
  expect(status.vaultNames).toEqual(["default"]);
103
220
  } finally {
104
221
  env.cleanup();
@@ -115,6 +232,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
115
232
  const status = readVaultAuthStatus({
116
233
  vaultHome: env.path,
117
234
  countTokens: (dbPath) => (dbPath.includes("/default/") ? 2 : 3),
235
+ probeHubDbHasUserPassword: hubDbUnreachable,
118
236
  });
119
237
  expect(status.tokenCount).toBe(5);
120
238
  expect(new Set(status.vaultNames)).toEqual(new Set(["default", "work"]));
@@ -135,6 +253,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
135
253
  if (dbPath.includes("/default/")) throw new Error("should not open missing DB");
136
254
  return 4;
137
255
  },
256
+ probeHubDbHasUserPassword: hubDbUnreachable,
138
257
  });
139
258
  expect(status.tokenCount).toBe(4);
140
259
  } finally {
@@ -153,6 +272,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
153
272
  if (dbPath.includes("/work/")) throw new Error("locked");
154
273
  return 2;
155
274
  },
275
+ probeHubDbHasUserPassword: hubDbUnreachable,
156
276
  });
157
277
  // Even though "default" succeeded with 2, we return null — callers
158
278
  // shouldn't see a misleading partial count.
@@ -162,3 +282,143 @@ describe("readVaultAuthStatus — token count resilience", () => {
162
282
  }
163
283
  });
164
284
  });
285
+
286
+ describe("readVaultAuthStatus — defaultProbeHubDbHasUserPassword end-to-end", () => {
287
+ // These tests exercise the real `bun:sqlite` probe (no injected fake)
288
+ // to catch breakage in the on-disk read path: schema drift, opening
289
+ // semantics, undefined-returns-on-failure.
290
+
291
+ test("hub.db missing → probe returns undefined → falls back to YAML cleanly", () => {
292
+ const env = makeVaultHome();
293
+ try {
294
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$legacyfromYAML"\n');
295
+ const status = readVaultAuthStatus({
296
+ vaultHome: env.path,
297
+ hubDbPath: join(env.path, "definitely-not-here.db"),
298
+ countTokens: () => 0,
299
+ });
300
+ expect(status.hasOwnerPassword).toBe(true);
301
+ } finally {
302
+ env.cleanup();
303
+ }
304
+ });
305
+
306
+ test("hub.db exists with users.password_hash set → hasOwnerPassword: true", () => {
307
+ const env = makeVaultHome();
308
+ try {
309
+ // Build a real hub.db with the canonical schema + an `unforced` user.
310
+ const { Database } = require("bun:sqlite");
311
+ const dbPath = join(env.path, "hub.db");
312
+ const db = new Database(dbPath);
313
+ db.exec(`
314
+ CREATE TABLE users (
315
+ id TEXT PRIMARY KEY,
316
+ username TEXT UNIQUE NOT NULL,
317
+ password_hash TEXT NOT NULL,
318
+ created_at TEXT NOT NULL,
319
+ updated_at TEXT NOT NULL,
320
+ password_changed INTEGER NOT NULL DEFAULT 0
321
+ );
322
+ INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed)
323
+ VALUES ('u1', 'unforced', '$argon2id$v=19$realhashhere', '2026-05-26T00:00:00Z', '2026-05-26T00:00:00Z', 1);
324
+ `);
325
+ db.close();
326
+ const status = readVaultAuthStatus({
327
+ vaultHome: env.path,
328
+ hubDbPath: dbPath,
329
+ countTokens: () => 0,
330
+ });
331
+ expect(status.hasOwnerPassword).toBe(true);
332
+ } finally {
333
+ env.cleanup();
334
+ }
335
+ });
336
+
337
+ test("hub.db exists but users table empty → falls back to YAML", () => {
338
+ const env = makeVaultHome();
339
+ try {
340
+ const { Database } = require("bun:sqlite");
341
+ const dbPath = join(env.path, "hub.db");
342
+ const db = new Database(dbPath);
343
+ db.exec(`
344
+ CREATE TABLE users (
345
+ id TEXT PRIMARY KEY,
346
+ username TEXT UNIQUE NOT NULL,
347
+ password_hash TEXT NOT NULL,
348
+ created_at TEXT NOT NULL,
349
+ updated_at TEXT NOT NULL,
350
+ password_changed INTEGER NOT NULL DEFAULT 0
351
+ );
352
+ `);
353
+ db.close();
354
+ // YAML provides the password — should still be true.
355
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$fallbackhash"\n');
356
+ const status = readVaultAuthStatus({
357
+ vaultHome: env.path,
358
+ hubDbPath: dbPath,
359
+ countTokens: () => 0,
360
+ });
361
+ expect(status.hasOwnerPassword).toBe(true);
362
+ } finally {
363
+ env.cleanup();
364
+ }
365
+ });
366
+
367
+ test("hub.db exists but schema is missing the users table → probe returns undefined, YAML fallback", () => {
368
+ const env = makeVaultHome();
369
+ try {
370
+ // A hub.db that hasn't run migration v2 yet (only signing_keys, v1).
371
+ const { Database } = require("bun:sqlite");
372
+ const dbPath = join(env.path, "hub.db");
373
+ const db = new Database(dbPath);
374
+ db.exec(`
375
+ CREATE TABLE signing_keys (kid TEXT PRIMARY KEY);
376
+ `);
377
+ db.close();
378
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$onlyinYAML"\n');
379
+ const status = readVaultAuthStatus({
380
+ vaultHome: env.path,
381
+ hubDbPath: dbPath,
382
+ countTokens: () => 0,
383
+ });
384
+ // SELECT against nonexistent `users` throws → probe returns undefined
385
+ // → YAML wins → password reported as set.
386
+ expect(status.hasOwnerPassword).toBe(true);
387
+ } finally {
388
+ env.cleanup();
389
+ }
390
+ });
391
+
392
+ test("hub.db users table has a row with empty password_hash → treated as no password", () => {
393
+ const env = makeVaultHome();
394
+ try {
395
+ const { Database } = require("bun:sqlite");
396
+ const dbPath = join(env.path, "hub.db");
397
+ const db = new Database(dbPath);
398
+ // NOT NULL on password_hash, but allow empty string — schema check
399
+ // is just for "non-empty hash exists."
400
+ db.exec(`
401
+ CREATE TABLE users (
402
+ id TEXT PRIMARY KEY,
403
+ username TEXT UNIQUE NOT NULL,
404
+ password_hash TEXT NOT NULL,
405
+ created_at TEXT NOT NULL,
406
+ updated_at TEXT NOT NULL,
407
+ password_changed INTEGER NOT NULL DEFAULT 0
408
+ );
409
+ INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed)
410
+ VALUES ('u1', 'placeholder', '', '2026-05-26T00:00:00Z', '2026-05-26T00:00:00Z', 0);
411
+ `);
412
+ db.close();
413
+ const status = readVaultAuthStatus({
414
+ vaultHome: env.path,
415
+ hubDbPath: dbPath,
416
+ countTokens: () => 0,
417
+ });
418
+ // No YAML, no non-empty hub.db password → wide open.
419
+ expect(status.hasOwnerPassword).toBe(false);
420
+ } finally {
421
+ env.cleanup();
422
+ }
423
+ });
424
+ });
@@ -0,0 +1,126 @@
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 {
14
+ clearVaultHubOrigin,
15
+ isLoopbackOrigin,
16
+ persistVaultHubOrigin,
17
+ } from "../vault-hub-origin-env.ts";
18
+
19
+ let dir: string;
20
+
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), "pcli-vhoe-"));
23
+ });
24
+ afterEach(() => {
25
+ rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ function vaultEnv(): string {
29
+ return join(dir, "vault", ".env");
30
+ }
31
+
32
+ describe("isLoopbackOrigin", () => {
33
+ test("flags 127.0.0.1 / localhost / [::1] / 0.0.0.0", () => {
34
+ expect(isLoopbackOrigin("http://127.0.0.1:1939")).toBe(true);
35
+ expect(isLoopbackOrigin("http://localhost:1939")).toBe(true);
36
+ expect(isLoopbackOrigin("http://[::1]:1939")).toBe(true);
37
+ // 0.0.0.0 is a bind-all wildcard, not a reachable origin.
38
+ expect(isLoopbackOrigin("http://0.0.0.0:1939")).toBe(true);
39
+ });
40
+
41
+ test("does not flag a public FQDN", () => {
42
+ expect(isLoopbackOrigin("https://parachute-aaron.tailc75afc.ts.net")).toBe(false);
43
+ expect(isLoopbackOrigin("https://hub.example.com")).toBe(false);
44
+ });
45
+
46
+ test("non-URL strings are treated as non-loopback (don't block persistence)", () => {
47
+ expect(isLoopbackOrigin("not a url")).toBe(false);
48
+ });
49
+ });
50
+
51
+ describe("persistVaultHubOrigin", () => {
52
+ test("writes a non-loopback public origin into vault/.env", () => {
53
+ const wrote = persistVaultHubOrigin(dir, "https://parachute-aaron.tailc75afc.ts.net", () => {});
54
+ expect(wrote).toBe(true);
55
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
56
+ "https://parachute-aaron.tailc75afc.ts.net",
57
+ );
58
+ });
59
+
60
+ test("refuses to persist a loopback origin (would shadow a later exposure)", () => {
61
+ const wrote = persistVaultHubOrigin(dir, "http://127.0.0.1:1939", () => {});
62
+ expect(wrote).toBe(false);
63
+ expect(existsSync(vaultEnv())).toBe(false);
64
+ });
65
+
66
+ test("refuses to persist a 0.0.0.0 origin (--hub-origin flows straight through)", () => {
67
+ // `--hub-origin http://0.0.0.0:1939` bypasses deriveHubOrigin and reaches
68
+ // here verbatim; baking a bind-all wildcard into vault/.env would advertise
69
+ // a non-functional issuer and recreate the iss-mismatch class.
70
+ const wrote = persistVaultHubOrigin(dir, "http://0.0.0.0:1939", () => {});
71
+ expect(wrote).toBe(false);
72
+ expect(existsSync(vaultEnv())).toBe(false);
73
+ });
74
+
75
+ test("is idempotent — no rewrite when the value is already current", () => {
76
+ const log: string[] = [];
77
+ expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(true);
78
+ expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(false);
79
+ // Only the first call logged.
80
+ expect(log).toHaveLength(1);
81
+ expect(log[0]).toMatch(/persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/);
82
+ });
83
+
84
+ test("updates a stale origin in-place and preserves sibling keys", () => {
85
+ writeFileSync(
86
+ mkVaultDir(),
87
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://old.example.com\nSCRIBE_URL=http://127.0.0.1:1943\n",
88
+ );
89
+ const wrote = persistVaultHubOrigin(dir, "https://new.example.com", () => {});
90
+ expect(wrote).toBe(true);
91
+ const values = readEnvFileValues(vaultEnv());
92
+ expect(values.PARACHUTE_HUB_ORIGIN).toBe("https://new.example.com");
93
+ // Sibling keys untouched.
94
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
95
+ expect(values.SCRIBE_URL).toBe("http://127.0.0.1:1943");
96
+ });
97
+ });
98
+
99
+ describe("clearVaultHubOrigin", () => {
100
+ test("removes a persisted origin and leaves sibling keys", () => {
101
+ writeFileSync(
102
+ mkVaultDir(),
103
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://hub.example.com\n",
104
+ );
105
+ const wrote = clearVaultHubOrigin(dir, () => {});
106
+ expect(wrote).toBe(true);
107
+ const values = readEnvFileValues(vaultEnv());
108
+ expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
109
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
110
+ });
111
+
112
+ test("no-op when no origin is present", () => {
113
+ writeFileSync(mkVaultDir(), "SCRIBE_AUTH_TOKEN=secret\n");
114
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
115
+ });
116
+
117
+ test("no-op when vault/.env doesn't exist", () => {
118
+ expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
119
+ });
120
+ });
121
+
122
+ /** Create `<dir>/vault/` and return the `.env` path so writeFileSync lands. */
123
+ function mkVaultDir(): string {
124
+ mkdirSync(join(dir, "vault"), { recursive: true });
125
+ return vaultEnv();
126
+ }
@@ -426,10 +426,10 @@ describe("buildWellKnown", () => {
426
426
  // joined onto the canonical origin into a deep-linkable `url`.
427
427
  describe("uis hierarchical sub-units (hub#313)", () => {
428
428
  const app: ServiceEntry = {
429
- name: "parachute-app",
429
+ name: "parachute-surface",
430
430
  port: 1946,
431
- paths: ["/app"],
432
- health: "/app/healthz",
431
+ paths: ["/surface"],
432
+ health: "/surface/healthz",
433
433
  version: "0.1.0",
434
434
  };
435
435
 
@@ -455,7 +455,7 @@ describe("buildWellKnown", () => {
455
455
  services: [withUis],
456
456
  canonicalOrigin: "https://x.example",
457
457
  });
458
- const appSvc = doc.services.find((s) => s.name === "parachute-app");
458
+ const appSvc = doc.services.find((s) => s.name === "parachute-surface");
459
459
  expect(appSvc?.uis).toEqual([
460
460
  {
461
461
  name: "gitcoin-brain",
@@ -500,7 +500,7 @@ describe("buildWellKnown", () => {
500
500
  services: [empty],
501
501
  canonicalOrigin: "https://x.example",
502
502
  });
503
- const svc = doc.services.find((s) => s.name === "parachute-app");
503
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
504
504
  expect(svc).not.toHaveProperty("uis");
505
505
  });
506
506
 
@@ -519,7 +519,7 @@ describe("buildWellKnown", () => {
519
519
  services: [withIcon],
520
520
  canonicalOrigin: "https://x.example",
521
521
  });
522
- const svc = doc.services.find((s) => s.name === "parachute-app");
522
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
523
523
  expect(svc?.uis?.[0]?.iconUrl).toBe("https://x.example/app/slug/icon.svg");
524
524
  });
525
525
 
@@ -538,7 +538,7 @@ describe("buildWellKnown", () => {
538
538
  services: [withIcon],
539
539
  canonicalOrigin: "https://x.example",
540
540
  });
541
- const svc = doc.services.find((s) => s.name === "parachute-app");
541
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
542
542
  expect(svc?.uis?.[0]?.iconUrl).toBe("https://cdn.example.com/icon.svg");
543
543
  });
544
544
 
@@ -562,7 +562,7 @@ describe("buildWellKnown", () => {
562
562
  services: [mixed],
563
563
  canonicalOrigin: "https://x.example",
564
564
  });
565
- const svc = doc.services.find((s) => s.name === "parachute-app");
565
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
566
566
  const full = svc?.uis?.find((u) => u.name === "full");
567
567
  const minimal = svc?.uis?.find((u) => u.name === "minimal");
568
568
  expect(full?.tagline).toBe("Has it all");
@@ -593,7 +593,7 @@ describe("buildWellKnown", () => {
593
593
  services: [app1, app2],
594
594
  canonicalOrigin: "https://x.example",
595
595
  });
596
- const svc1 = doc.services.find((s) => s.name === "parachute-app");
596
+ const svc1 = doc.services.find((s) => s.name === "parachute-surface");
597
597
  const svc2 = doc.services.find((s) => s.name === "parachute-app-2");
598
598
  expect(svc1?.uis?.map((u) => u.name)).toEqual(["a"]);
599
599
  expect(svc2?.uis?.map((u) => u.name)).toEqual(["b"]);