@openparachute/hub 0.5.10-rc.6 → 0.5.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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +139 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-server.test.ts +29 -4
  10. package/src/__tests__/hub-settings.test.ts +377 -0
  11. package/src/__tests__/hub.test.ts +17 -0
  12. package/src/__tests__/jwt-sign.test.ts +59 -0
  13. package/src/__tests__/oauth-handlers.test.ts +1059 -10
  14. package/src/__tests__/oauth-ui.test.ts +210 -0
  15. package/src/__tests__/scope-explanations.test.ts +23 -0
  16. package/src/__tests__/serve.test.ts +8 -1
  17. package/src/__tests__/setup-wizard.test.ts +1500 -13
  18. package/src/__tests__/supervisor.test.ts +76 -2
  19. package/src/__tests__/users.test.ts +196 -0
  20. package/src/__tests__/vault-name.test.ts +79 -0
  21. package/src/__tests__/vault-names.test.ts +172 -0
  22. package/src/account-change-password-ui.ts +379 -0
  23. package/src/admin-handlers.ts +68 -2
  24. package/src/admin-host-admin-token.ts +5 -0
  25. package/src/admin-vault-admin-token.ts +7 -0
  26. package/src/api-account.ts +443 -0
  27. package/src/api-mint-token.ts +6 -0
  28. package/src/api-modules-ops.ts +30 -6
  29. package/src/api-modules.ts +101 -0
  30. package/src/api-users.ts +393 -0
  31. package/src/commands/auth.ts +10 -1
  32. package/src/commands/serve.ts +5 -1
  33. package/src/cors.ts +263 -0
  34. package/src/hub-db.ts +54 -0
  35. package/src/hub-server.ts +162 -18
  36. package/src/hub-settings.ts +259 -0
  37. package/src/hub.ts +34 -9
  38. package/src/jwt-sign.ts +17 -1
  39. package/src/oauth-handlers.ts +256 -29
  40. package/src/oauth-ui.ts +451 -38
  41. package/src/operator-token.ts +4 -0
  42. package/src/scope-explanations.ts +26 -1
  43. package/src/setup-wizard.ts +1100 -56
  44. package/src/supervisor.ts +66 -14
  45. package/src/users.ts +210 -3
  46. package/src/vault-name.ts +71 -0
  47. package/src/vault-names.ts +57 -0
  48. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  49. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  50. package/web/ui/dist/index.html +2 -2
  51. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
@@ -248,18 +248,92 @@ describe("Supervisor.stop", () => {
248
248
  });
249
249
  await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
250
250
 
251
- await sup.stop("vault");
251
+ // stop() now awaits proc.exited (with SIGKILL escalation on
252
+ // timeout) — kick it off, observe the SIGTERM landed, then
253
+ // resolve exited so the await completes.
254
+ const stopPromise = sup.stop("vault");
252
255
  expect(proc.killed).toBe(true);
253
256
  expect(proc.killSignal).toBe("SIGTERM");
254
257
 
255
258
  proc.closeStreams();
256
259
  proc.resolveExit(0);
257
- await tick();
260
+ await stopPromise;
258
261
 
259
262
  // No second spawn — stop is an intentional exit.
260
263
  expect(spawner.calls).toHaveLength(1);
261
264
  expect(sup.get("vault")?.status).toBe("stopped");
262
265
  });
266
+
267
+ test("escalates to SIGKILL when child ignores SIGTERM past killTimeoutMs", async () => {
268
+ // Child that refuses to exit on SIGTERM. The fake records every
269
+ // signal it receives; the supervisor should send SIGTERM,
270
+ // observe no exit, then send SIGKILL after the timeout.
271
+ const proc = makeFakeProc(101);
272
+ const signals: (NodeJS.Signals | number | undefined)[] = [];
273
+ proc.kill = (signal) => {
274
+ signals.push(signal);
275
+ // Only SIGKILL actually terminates this fake child — SIGTERM
276
+ // gets logged and ignored, simulating the wedged-module shape.
277
+ if (signal === "SIGKILL") proc.resolveExit(null);
278
+ };
279
+ const spawner = makeQueueSpawner();
280
+ spawner.enqueue(proc);
281
+ const outputs: string[] = [];
282
+ const sup = new Supervisor({
283
+ spawnFn: spawner.spawn,
284
+ restartDelayMs: 0,
285
+ sleep: () => Promise.resolve(),
286
+ killTimeoutMs: 5, // Short timeout so the test doesn't pause for 5s.
287
+ output: (line) => outputs.push(line),
288
+ });
289
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
290
+
291
+ proc.closeStreams();
292
+ await sup.stop("vault");
293
+
294
+ // SIGTERM first, then SIGKILL after the timeout.
295
+ expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
296
+ expect(outputs.some((l) => l.includes("escalating to SIGKILL"))).toBe(true);
297
+ expect(sup.get("vault")?.status).toBe("stopped");
298
+ });
299
+
300
+ test("stop awaits child exit before returning (no SIGKILL needed)", async () => {
301
+ // Well-behaved child: exits ~10ms after SIGTERM. stop() should
302
+ // return only after the exit promise resolves, not immediately
303
+ // post-SIGTERM. This is the log-flush guarantee that motivated
304
+ // the await in the first place (hub#263).
305
+ const proc = makeFakeProc(101);
306
+ proc.kill = (signal) => {
307
+ signals.push(signal);
308
+ // Simulate the child taking a few ms to flush + exit.
309
+ setTimeout(() => proc.resolveExit(0), 5);
310
+ };
311
+ const signals: (NodeJS.Signals | number | undefined)[] = [];
312
+ const spawner = makeQueueSpawner();
313
+ spawner.enqueue(proc);
314
+ const sup = new Supervisor({
315
+ spawnFn: spawner.spawn,
316
+ restartDelayMs: 0,
317
+ sleep: () => Promise.resolve(),
318
+ killTimeoutMs: 1000, // Plenty of headroom for the 5ms simulated exit.
319
+ });
320
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
321
+
322
+ proc.closeStreams();
323
+ let exitObservedBeforeReturn = false;
324
+ void proc.exited.then(() => {
325
+ exitObservedBeforeReturn = true;
326
+ });
327
+ await sup.stop("vault");
328
+
329
+ // The exited-resolver awaited the same promise stop() did; if
330
+ // stop returned without awaiting, this flag could still be false.
331
+ // (Both promise chains fire from the same resolveExit call.
332
+ // Microtask ordering guarantees they both run before await returns.)
333
+ expect(exitObservedBeforeReturn).toBe(true);
334
+ expect(signals).toEqual(["SIGTERM"]);
335
+ expect(sup.get("vault")?.status).toBe("stopped");
336
+ });
263
337
  });
264
338
 
265
339
  describe("Supervisor.restart", () => {
@@ -4,15 +4,21 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { hubDbPath, openHubDb } from "../hub-db.ts";
6
6
  import {
7
+ PASSWORD_MIN_LEN,
7
8
  SingleUserModeError,
9
+ USERNAME_RESERVED,
8
10
  UserNotFoundError,
9
11
  UsernameTakenError,
10
12
  createUser,
13
+ deleteUser,
11
14
  getUserById,
12
15
  getUserByUsername,
16
+ getUserByUsernameCI,
13
17
  listUsers,
14
18
  setPassword,
15
19
  userCount,
20
+ validatePassword,
21
+ validateUsername,
16
22
  verifyPassword,
17
23
  } from "../users.ts";
18
24
 
@@ -29,6 +35,11 @@ function makeDb() {
29
35
  }
30
36
 
31
37
  describe("createUser", () => {
38
+ // Note: createUser doesn't call validatePassword directly (PR 2 wires
39
+ // it at the endpoint layer). These short passwords ("hunter2", "pw1",
40
+ // etc.) are deliberate for testing the argon2id round-trip + the
41
+ // INSERT shape in isolation; PR 2's endpoint tests will use full-
42
+ // length (12+ char) passwords against the validator-gated path.
32
43
  test("creates a user and stores an argon2id hash", async () => {
33
44
  const { db, cleanup } = makeDb();
34
45
  try {
@@ -39,6 +50,50 @@ describe("createUser", () => {
39
50
  expect(u.passwordHash.startsWith("$argon2id$")).toBe(true);
40
51
  expect(u.createdAt).toBe(u.updatedAt);
41
52
  expect(userCount(db)).toBe(1);
53
+ // Default multi-user-Phase-1 shape: unchanged password, no vault pin.
54
+ expect(u.passwordChanged).toBe(false);
55
+ expect(u.assignedVault).toBeNull();
56
+ } finally {
57
+ cleanup();
58
+ }
59
+ });
60
+
61
+ test("passwordChanged opt-in lands the bit set (wizard / env-seed path)", async () => {
62
+ const { db, cleanup } = makeDb();
63
+ try {
64
+ const u = await createUser(db, "owner", "hunter2", { passwordChanged: true });
65
+ expect(u.passwordChanged).toBe(true);
66
+ // Round-trip through getUserById so we know it's persisted, not
67
+ // just returned by the in-memory createUser result.
68
+ const fresh = getUserById(db, u.id);
69
+ expect(fresh?.passwordChanged).toBe(true);
70
+ } finally {
71
+ cleanup();
72
+ }
73
+ });
74
+
75
+ test("assignedVault opt-in persists the column (admin-creates-user path)", async () => {
76
+ const { db, cleanup } = makeDb();
77
+ try {
78
+ const u = await createUser(db, "alice", "pw1", {
79
+ allowMulti: true,
80
+ assignedVault: "alice",
81
+ });
82
+ expect(u.assignedVault).toBe("alice");
83
+ const fresh = getUserById(db, u.id);
84
+ expect(fresh?.assignedVault).toBe("alice");
85
+ } finally {
86
+ cleanup();
87
+ }
88
+ });
89
+
90
+ test("assignedVault explicit null is treated the same as omitted", async () => {
91
+ const { db, cleanup } = makeDb();
92
+ try {
93
+ const u = await createUser(db, "admin1", "pw1", { assignedVault: null });
94
+ expect(u.assignedVault).toBeNull();
95
+ const fresh = getUserById(db, u.id);
96
+ expect(fresh?.assignedVault).toBeNull();
42
97
  } finally {
43
98
  cleanup();
44
99
  }
@@ -152,3 +207,144 @@ describe("listUsers / getUserByUsername", () => {
152
207
  }
153
208
  });
154
209
  });
210
+
211
+ describe("getUserByUsernameCI", () => {
212
+ test("matches exact lowercase username", async () => {
213
+ const { db, cleanup } = makeDb();
214
+ try {
215
+ await createUser(db, "alice", "alice-strong-passphrase");
216
+ expect(getUserByUsernameCI(db, "alice")?.username).toBe("alice");
217
+ } finally {
218
+ cleanup();
219
+ }
220
+ });
221
+
222
+ test("matches case-insensitively (defense in depth for legacy mixed-case rows)", async () => {
223
+ const { db, cleanup } = makeDb();
224
+ try {
225
+ await createUser(db, "alice", "alice-strong-passphrase");
226
+ expect(getUserByUsernameCI(db, "Alice")?.username).toBe("alice");
227
+ expect(getUserByUsernameCI(db, "ALICE")?.username).toBe("alice");
228
+ } finally {
229
+ cleanup();
230
+ }
231
+ });
232
+
233
+ test("returns null when no row matches", async () => {
234
+ const { db, cleanup } = makeDb();
235
+ try {
236
+ expect(getUserByUsernameCI(db, "ghost")).toBeNull();
237
+ } finally {
238
+ cleanup();
239
+ }
240
+ });
241
+ });
242
+
243
+ describe("deleteUser", () => {
244
+ test("returns false when user does not exist", () => {
245
+ const { db, cleanup } = makeDb();
246
+ try {
247
+ expect(deleteUser(db, "no-such-id")).toBe(false);
248
+ } finally {
249
+ cleanup();
250
+ }
251
+ });
252
+
253
+ test("returns true and drops the row", async () => {
254
+ const { db, cleanup } = makeDb();
255
+ try {
256
+ const u = await createUser(db, "alice", "alice-strong-passphrase");
257
+ expect(deleteUser(db, u.id)).toBe(true);
258
+ expect(getUserById(db, u.id)).toBeNull();
259
+ expect(userCount(db)).toBe(0);
260
+ } finally {
261
+ cleanup();
262
+ }
263
+ });
264
+ });
265
+
266
+ describe("validateUsername", () => {
267
+ test("happy path — typical names", () => {
268
+ for (const name of ["alice", "bob_42", "user-1", "ab", "a-b-c", "x_y_z"]) {
269
+ const r = validateUsername(name);
270
+ expect(r.valid).toBe(true);
271
+ if (r.valid) expect(r.name).toBe(name);
272
+ }
273
+ });
274
+
275
+ test("length boundaries — 2 and 32 OK, 1 and 33 rejected", () => {
276
+ expect(validateUsername("ab").valid).toBe(true);
277
+ expect(validateUsername("a".repeat(32)).valid).toBe(true);
278
+ const tooShort = validateUsername("a");
279
+ expect(tooShort.valid).toBe(false);
280
+ if (!tooShort.valid) expect(tooShort.reason).toBe("length");
281
+ const tooLong = validateUsername("a".repeat(33));
282
+ expect(tooLong.valid).toBe(false);
283
+ if (!tooLong.valid) expect(tooLong.reason).toBe("length");
284
+ // Empty string is a length failure (not a format failure).
285
+ const empty = validateUsername("");
286
+ expect(empty.valid).toBe(false);
287
+ if (!empty.valid) expect(empty.reason).toBe("length");
288
+ });
289
+
290
+ test("format failures — uppercase, spaces, symbols rejected", () => {
291
+ for (const name of ["Alice", "bob smith", "user@domain", "name!", "user.name", "naïve"]) {
292
+ const r = validateUsername(name);
293
+ expect(r.valid).toBe(false);
294
+ if (!r.valid) expect(r.reason).toBe("format");
295
+ }
296
+ });
297
+
298
+ test("reserved words rejected — case-insensitive", () => {
299
+ for (const reserved of USERNAME_RESERVED) {
300
+ const r = validateUsername(reserved);
301
+ expect(r.valid).toBe(false);
302
+ if (!r.valid) expect(r.reason).toBe("reserved");
303
+ }
304
+ // Case variants — the regex pins lowercase so uppercase fails as
305
+ // "format" first; the case-insensitive reserved check is defense in
306
+ // depth. But within the lowercase-allowed set, mixed-case-spelled
307
+ // reserved words are blocked by the format gate (e.g. "Admin"
308
+ // fails format, not reserved). The regex catches case variants
309
+ // before reserved-check ever runs — that's correct order.
310
+ const mixed = validateUsername("Admin");
311
+ expect(mixed.valid).toBe(false);
312
+ if (!mixed.valid) expect(mixed.reason).toBe("format");
313
+ });
314
+
315
+ test("hyphens and underscores allowed; numbers allowed", () => {
316
+ expect(validateUsername("user_1").valid).toBe(true);
317
+ expect(validateUsername("user-2").valid).toBe(true);
318
+ expect(validateUsername("123").valid).toBe(true);
319
+ expect(validateUsername("_-_").valid).toBe(true);
320
+ });
321
+ });
322
+
323
+ describe("validatePassword", () => {
324
+ test("happy path — 12+ chars accepted", () => {
325
+ expect(validatePassword("twelvechars1").valid).toBe(true);
326
+ expect(validatePassword("a much longer passphrase here").valid).toBe(true);
327
+ });
328
+
329
+ test("boundary — exactly 12 chars accepted, 11 rejected", () => {
330
+ expect(PASSWORD_MIN_LEN).toBe(12);
331
+ expect(validatePassword("a".repeat(12)).valid).toBe(true);
332
+ const r = validatePassword("a".repeat(11));
333
+ expect(r.valid).toBe(false);
334
+ if (!r.valid) expect(r.reason).toBe("too_short");
335
+ });
336
+
337
+ test("empty string rejected as too_short", () => {
338
+ const r = validatePassword("");
339
+ expect(r.valid).toBe(false);
340
+ if (!r.valid) expect(r.reason).toBe("too_short");
341
+ });
342
+
343
+ test("no complexity rules — long but all-same-char accepted", () => {
344
+ // Phase 1 takes NIST 800-63B's lead: length over forced classes.
345
+ // Aaron settled on 12-min, no complexity. If we later want to nudge
346
+ // toward passphrases we'll layer it as a separate signal, not a
347
+ // hard gate.
348
+ expect(validatePassword("aaaaaaaaaaaa").valid).toBe(true);
349
+ });
350
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Vault-name validator tests (hub#267). Mirrors the vault repo's
3
+ * `vault-name.test.ts` — hub keeps its own copy because it doesn't
4
+ * depend on @openparachute/vault at runtime. The two must stay in
5
+ * lockstep so the typed name hub validates is the one vault accepts.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { DEFAULT_VAULT_NAME, validateVaultName } from "../vault-name.ts";
10
+
11
+ describe("validateVaultName", () => {
12
+ test("accepts lowercase alphanumeric + hyphens/underscores", () => {
13
+ const cases = ["aaron", "my-vault", "smoke_2026", "abc", "vault123", "a-b_c-d"];
14
+ for (const name of cases) {
15
+ const result = validateVaultName(name);
16
+ expect(result.ok).toBe(true);
17
+ if (result.ok) expect(result.name).toBe(name);
18
+ }
19
+ });
20
+
21
+ test("trims surrounding whitespace", () => {
22
+ const result = validateVaultName(" aaron ");
23
+ expect(result.ok).toBe(true);
24
+ if (result.ok) expect(result.name).toBe("aaron");
25
+ });
26
+
27
+ test("rejects uppercase letters", () => {
28
+ const result = validateVaultName("Aaron");
29
+ expect(result.ok).toBe(false);
30
+ if (!result.ok) expect(result.error).toContain("lowercase alphanumeric");
31
+ });
32
+
33
+ test("rejects spaces", () => {
34
+ const result = validateVaultName("my vault");
35
+ expect(result.ok).toBe(false);
36
+ });
37
+
38
+ test("rejects special characters", () => {
39
+ for (const name of ["my!vault", "vault.dot", "vault/slash", "vault@home"]) {
40
+ const result = validateVaultName(name);
41
+ expect(result.ok).toBe(false);
42
+ }
43
+ });
44
+
45
+ test("rejects too-short names (< 2 chars)", () => {
46
+ const result = validateVaultName("a");
47
+ expect(result.ok).toBe(false);
48
+ if (!result.ok) expect(result.error).toContain("2");
49
+ });
50
+
51
+ test("rejects too-long names (> 32 chars)", () => {
52
+ const result = validateVaultName("a".repeat(33));
53
+ expect(result.ok).toBe(false);
54
+ if (!result.ok) expect(result.error).toContain("32");
55
+ });
56
+
57
+ test("accepts boundary lengths (2 and 32)", () => {
58
+ expect(validateVaultName("ab").ok).toBe(true);
59
+ expect(validateVaultName("a".repeat(32)).ok).toBe(true);
60
+ });
61
+
62
+ test("rejects empty / whitespace-only names", () => {
63
+ expect(validateVaultName("").ok).toBe(false);
64
+ expect(validateVaultName(" ").ok).toBe(false);
65
+ });
66
+
67
+ test("rejects the reserved name 'list' (matches vault's reservation)", () => {
68
+ const result = validateVaultName("list");
69
+ expect(result.ok).toBe(false);
70
+ if (!result.ok) expect(result.error).toContain("reserved");
71
+ });
72
+
73
+ test("DEFAULT_VAULT_NAME is 'default'", () => {
74
+ expect(DEFAULT_VAULT_NAME).toBe("default");
75
+ // And it passes the validator (sanity check — vault uses this as
76
+ // the canonical fallback).
77
+ expect(validateVaultName(DEFAULT_VAULT_NAME).ok).toBe(true);
78
+ });
79
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Tests for the shared `vault-names.ts` helper (multi-user Phase 1, PR 4).
3
+ *
4
+ * The shared helper lifted the two pre-PR-4 private copies (`oauth-handlers.ts`
5
+ * + `api-users.ts`) into one place. These tests pin the canonical behavior so
6
+ * both callers — the OAuth consent picker + the admin SPA's assigned-vault
7
+ * dropdown + the server-side defense in `handleConsentSubmit` — see the same
8
+ * name set.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import type { ServicesManifest } from "../services-manifest.ts";
15
+ import { listVaultNames, listVaultNamesFromPath } from "../vault-names.ts";
16
+
17
+ describe("listVaultNames", () => {
18
+ test("returns empty list when no vault services are registered", () => {
19
+ const manifest: ServicesManifest = {
20
+ services: [
21
+ { name: "parachute-notes", port: 1942, paths: ["/notes"], health: "/h", version: "0.1.0" },
22
+ {
23
+ name: "parachute-scribe",
24
+ port: 1943,
25
+ paths: ["/scribe"],
26
+ health: "/h",
27
+ version: "0.1.0",
28
+ },
29
+ ],
30
+ };
31
+ expect(listVaultNames(manifest)).toEqual([]);
32
+ });
33
+
34
+ test("single-entry-multi-path: emits one name per `/vault/<name>` path", () => {
35
+ const manifest: ServicesManifest = {
36
+ services: [
37
+ {
38
+ name: "parachute-vault",
39
+ port: 1940,
40
+ paths: ["/vault/work", "/vault/personal"],
41
+ health: "/h",
42
+ version: "0.1.0",
43
+ },
44
+ ],
45
+ };
46
+ expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
47
+ });
48
+
49
+ test("per-vault entries: emits one name per `parachute-vault-<name>` entry", () => {
50
+ const manifest: ServicesManifest = {
51
+ services: [
52
+ {
53
+ name: "parachute-vault-work",
54
+ port: 1940,
55
+ paths: ["/vault/work"],
56
+ health: "/h",
57
+ version: "0.1.0",
58
+ },
59
+ {
60
+ name: "parachute-vault-personal",
61
+ port: 1941,
62
+ paths: ["/vault/personal"],
63
+ health: "/h",
64
+ version: "0.1.0",
65
+ },
66
+ ],
67
+ };
68
+ expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
69
+ });
70
+
71
+ test("entry with no paths falls back to the manifest-suffix name (hub#143)", () => {
72
+ const manifest: ServicesManifest = {
73
+ services: [
74
+ {
75
+ name: "parachute-vault-archived",
76
+ port: 1940,
77
+ paths: [],
78
+ health: "/h",
79
+ version: "0.1.0",
80
+ },
81
+ ],
82
+ };
83
+ expect(listVaultNames(manifest)).toEqual(["archived"]);
84
+ });
85
+
86
+ test("deduplicates collisions across single-entry + per-vault shapes", () => {
87
+ const manifest: ServicesManifest = {
88
+ services: [
89
+ {
90
+ name: "parachute-vault",
91
+ port: 1940,
92
+ paths: ["/vault/work"],
93
+ health: "/h",
94
+ version: "0.1.0",
95
+ },
96
+ {
97
+ name: "parachute-vault-work",
98
+ port: 1941,
99
+ paths: ["/vault/work"],
100
+ health: "/h",
101
+ version: "0.1.0",
102
+ },
103
+ ],
104
+ };
105
+ expect(listVaultNames(manifest)).toEqual(["work"]);
106
+ });
107
+
108
+ test("output is sorted ascending — deterministic order for both callers", () => {
109
+ const manifest: ServicesManifest = {
110
+ services: [
111
+ {
112
+ name: "parachute-vault",
113
+ port: 1940,
114
+ paths: ["/vault/zeta", "/vault/alpha", "/vault/middle"],
115
+ health: "/h",
116
+ version: "0.1.0",
117
+ },
118
+ ],
119
+ };
120
+ expect(listVaultNames(manifest)).toEqual(["alpha", "middle", "zeta"]);
121
+ });
122
+ });
123
+
124
+ describe("listVaultNamesFromPath", () => {
125
+ test("reads from a services.json file and emits the same names", () => {
126
+ const dir = mkdtempSync(join(tmpdir(), "phub-vault-names-"));
127
+ const path = join(dir, "services.json");
128
+ writeFileSync(
129
+ path,
130
+ JSON.stringify({
131
+ services: [
132
+ {
133
+ name: "parachute-vault",
134
+ port: 1940,
135
+ paths: ["/vault/work"],
136
+ health: "/h",
137
+ version: "0.1.0",
138
+ },
139
+ ],
140
+ }),
141
+ );
142
+ try {
143
+ expect(listVaultNamesFromPath(path)).toEqual(["work"]);
144
+ } finally {
145
+ rmSync(dir, { recursive: true, force: true });
146
+ }
147
+ });
148
+
149
+ test("api-users.ts and oauth-handlers.ts read the same source through listVaultNamesFromPath / listVaultNames", () => {
150
+ // Cross-caller parity: the helper is the single source. Both code paths
151
+ // should see byte-identical output against the same services.json.
152
+ const dir = mkdtempSync(join(tmpdir(), "phub-vault-names-parity-"));
153
+ const path = join(dir, "services.json");
154
+ const manifest: ServicesManifest = {
155
+ services: [
156
+ {
157
+ name: "parachute-vault",
158
+ port: 1940,
159
+ paths: ["/vault/work", "/vault/personal"],
160
+ health: "/h",
161
+ version: "0.1.0",
162
+ },
163
+ ],
164
+ };
165
+ writeFileSync(path, JSON.stringify(manifest));
166
+ try {
167
+ expect(listVaultNamesFromPath(path)).toEqual(listVaultNames(manifest));
168
+ } finally {
169
+ rmSync(dir, { recursive: true, force: true });
170
+ }
171
+ });
172
+ });