@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
package/src/supervisor.ts CHANGED
@@ -80,6 +80,17 @@ export interface SupervisorOpts {
80
80
  * Default 500 — gives sockets time to release on EADDRINUSE.
81
81
  */
82
82
  readonly restartDelayMs?: number;
83
+ /**
84
+ * Max time to wait for a child to exit after SIGTERM before
85
+ * escalating to SIGKILL, in ms. Default 5000 — long enough for a
86
+ * well-behaved module to flush its log buffer + drop its listeners,
87
+ * short enough that a wedged child doesn't keep `stop()` (and the
88
+ * container shutdown path that calls it) hanging indefinitely.
89
+ *
90
+ * Tests pass a short timeout (1–10ms) to exercise the SIGKILL
91
+ * escalation path without real waiting.
92
+ */
93
+ readonly killTimeoutMs?: number;
83
94
  /**
84
95
  * Where prefixed child output goes. Default `process.stdout.write`.
85
96
  * Tests inject a collector so they can assert on the multiplexed
@@ -123,6 +134,7 @@ export interface SupervisedProc {
123
134
  const DEFAULT_MAX_RESTARTS = 3;
124
135
  const DEFAULT_RESTART_WINDOW_MS = 60_000;
125
136
  const DEFAULT_RESTART_DELAY_MS = 500;
137
+ const DEFAULT_KILL_TIMEOUT_MS = 5_000;
126
138
 
127
139
  /**
128
140
  * Per-module supervisor. Owns the spawn → watch → restart loop.
@@ -143,6 +155,7 @@ export class Supervisor {
143
155
  maxRestarts: opts.maxRestarts ?? DEFAULT_MAX_RESTARTS,
144
156
  restartWindowMs: opts.restartWindowMs ?? DEFAULT_RESTART_WINDOW_MS,
145
157
  restartDelayMs: opts.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS,
158
+ killTimeoutMs: opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS,
146
159
  output: opts.output ?? ((line) => process.stdout.write(line)),
147
160
  spawnFn: opts.spawnFn ?? defaultSpawnFn,
148
161
  now: opts.now ?? Date.now,
@@ -177,20 +190,65 @@ export class Supervisor {
177
190
  }
178
191
 
179
192
  /**
180
- * Stop a supervised module. Sends SIGTERM, marks the state
181
- * `stopped`, and detaches the exit watcher so a normal termination
182
- * isn't seen as a crash. Idempotent on already-stopped modules.
193
+ * Stop a supervised module. Sends SIGTERM, awaits the child's exit
194
+ * (so the log-pump drains the final flush before our stdout closes),
195
+ * and escalates to SIGKILL if the child doesn't exit within
196
+ * `killTimeoutMs`. Marks the state `stopped` and detaches the exit
197
+ * watcher so a normal termination isn't seen as a crash. Idempotent
198
+ * on already-stopped modules.
199
+ *
200
+ * The await matters in two places:
201
+ * - Container shutdown (hub PID 1 receiving SIGTERM from Render):
202
+ * without it, children's final log lines never make it through
203
+ * hub's stdout pipe before the platform reaps the pod.
204
+ * - `restart()`: a fresh spawn that races a still-listening prior
205
+ * PID will fail with EADDRINUSE.
206
+ *
207
+ * The SIGKILL escalation handles a wedged module (e.g. a broken
208
+ * native binding ignoring SIGTERM). Without it, `stop()` would hang
209
+ * forever and a re-deploy would leak the orphaned child until the
210
+ * container itself was recycled.
183
211
  */
184
212
  async stop(short: string): Promise<ModuleState | undefined> {
185
213
  const entry = this.modules.get(short);
186
214
  if (!entry) return undefined;
187
215
  entry.stopRequested = true;
188
- if (entry.proc) {
216
+ const proc = entry.proc;
217
+ if (proc) {
189
218
  try {
190
- entry.proc.kill("SIGTERM");
219
+ proc.kill("SIGTERM");
191
220
  } catch {
192
221
  // Process may already be dead — fall through.
193
222
  }
223
+ // Race the child's exit against the kill timeout. If the timer
224
+ // wins, escalate to SIGKILL. Either way we end up awaiting the
225
+ // exit promise so the log pump drains.
226
+ let timer: ReturnType<typeof setTimeout> | undefined;
227
+ const timeout = new Promise<"timeout">((resolve) => {
228
+ timer = setTimeout(() => resolve("timeout"), this.opts.killTimeoutMs);
229
+ });
230
+ try {
231
+ const winner = await Promise.race([proc.exited.then(() => "exited" as const), timeout]);
232
+ if (winner === "timeout") {
233
+ this.opts.output(
234
+ `[supervisor] ${entry.req.short} did not exit ${this.opts.killTimeoutMs}ms after SIGTERM — escalating to SIGKILL.\n`,
235
+ );
236
+ try {
237
+ proc.kill("SIGKILL");
238
+ } catch {
239
+ // Process may already be dead between the timeout firing
240
+ // and us reaching kill() — fall through to the await.
241
+ }
242
+ try {
243
+ await proc.exited;
244
+ // SIGKILL cannot be caught; OS reaps the child promptly.
245
+ } catch {
246
+ // exited rejection is non-fatal — we're stopping anyway.
247
+ }
248
+ }
249
+ } finally {
250
+ clearTimeout(timer!);
251
+ }
194
252
  }
195
253
  entry.state = { ...entry.state, status: "stopped" };
196
254
  return entry.state;
@@ -217,16 +275,10 @@ export class Supervisor {
217
275
  if (!entry) return undefined;
218
276
  const req = entry.req;
219
277
  entry.state = { ...entry.state, status: "restarting" };
278
+ // stop() now awaits the prior process's exit (with SIGKILL
279
+ // escalation) before returning, so the fresh spawn below doesn't
280
+ // race on EADDRINUSE — no separate await needed here.
220
281
  await this.stop(short);
221
- // Wait for the prior process to actually exit so the new spawn
222
- // doesn't race on EADDRINUSE.
223
- if (entry.proc) {
224
- try {
225
- await entry.proc.exited;
226
- } catch {
227
- // exited promise rejection is non-fatal — we're stopping anyway.
228
- }
229
- }
230
282
  // Drop the entry so `start` treats this as a clean spawn.
231
283
  this.modules.delete(short);
232
284
  return this.start(req);
package/src/users.ts CHANGED
@@ -25,6 +25,25 @@ export interface User {
25
25
  passwordHash: string;
26
26
  createdAt: string;
27
27
  updatedAt: string;
28
+ /**
29
+ * Whether the user has changed their password since account creation.
30
+ * `false` means the user signed up with an admin-typed default password
31
+ * and the force-change-password flow at sign-in time should redirect
32
+ * them to `/account/change-password`. The wizard's first admin and env-
33
+ * seeded admins land as `true` (they chose their own password). Stored
34
+ * as `users.password_changed INTEGER 0|1` (added in migration v8).
35
+ */
36
+ passwordChanged: boolean;
37
+ /**
38
+ * The vault instance name this user is pinned to (Phase 1 multi-user is
39
+ * single-vault-per-user). `null` means "no per-vault restriction" — the
40
+ * default for admin accounts, where the OAuth issuer mints tokens for
41
+ * any requested vault. Non-null pins the issuer to narrow scopes to
42
+ * `vault:<assigned_vault>:<verb>`. No FK; vault names resolve through
43
+ * `services.json` at mint time. Stored as `users.assigned_vault TEXT`
44
+ * (added in migration v8).
45
+ */
46
+ assignedVault: string | null;
28
47
  }
29
48
 
30
49
  export class SingleUserModeError extends Error {
@@ -56,6 +75,8 @@ interface Row {
56
75
  password_hash: string;
57
76
  created_at: string;
58
77
  updated_at: string;
78
+ password_changed: number;
79
+ assigned_vault: string | null;
59
80
  }
60
81
 
61
82
  function rowToUser(r: Row): User {
@@ -65,6 +86,8 @@ function rowToUser(r: Row): User {
65
86
  passwordHash: r.password_hash,
66
87
  createdAt: r.created_at,
67
88
  updatedAt: r.updated_at,
89
+ passwordChanged: r.password_changed === 1,
90
+ assignedVault: r.assigned_vault,
68
91
  };
69
92
  }
70
93
 
@@ -72,6 +95,23 @@ export interface CreateUserOpts {
72
95
  /** Allow creating an additional user when one already exists. Off by default. */
73
96
  allowMulti?: boolean;
74
97
  now?: () => Date;
98
+ /**
99
+ * Whether the new user has already chosen their password. Default `false`
100
+ * — the admin-creates-user path (PR 2) lands new accounts with the bit
101
+ * unset so the user is force-redirected to change it on first sign-in
102
+ * (PR 3). The wizard's first-admin path and env-seeded admin path pass
103
+ * `true` (they chose their own password through the wizard form / env
104
+ * vars; no force-change needed).
105
+ */
106
+ passwordChanged?: boolean;
107
+ /**
108
+ * Vault instance name to pin the user to (Phase 1 single-vault). `null`
109
+ * (default) means "no restriction" — admin posture. The OAuth issuer
110
+ * (PR 4) reads this at mint time to narrow scopes. No validation here:
111
+ * the API endpoint (PR 2) is responsible for checking against
112
+ * `services.json` before passing through.
113
+ */
114
+ assignedVault?: string | null;
75
115
  }
76
116
 
77
117
  export async function createUser(
@@ -87,10 +127,14 @@ export async function createUser(
87
127
  const id = randomUUID();
88
128
  const passwordHash = await argonHash(password);
89
129
  const stamp = (opts.now?.() ?? new Date()).toISOString();
130
+ const passwordChanged = opts.passwordChanged === true ? 1 : 0;
131
+ const assignedVault = opts.assignedVault ?? null;
90
132
  try {
91
133
  db.prepare(
92
- "INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
93
- ).run(id, username, passwordHash, stamp, stamp);
134
+ `INSERT INTO users
135
+ (id, username, password_hash, created_at, updated_at, password_changed, assigned_vault)
136
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
137
+ ).run(id, username, passwordHash, stamp, stamp, passwordChanged, assignedVault);
94
138
  } catch (err) {
95
139
  const msg = err instanceof Error ? err.message : String(err);
96
140
  if (msg.includes("UNIQUE") && msg.includes("users.username")) {
@@ -98,7 +142,15 @@ export async function createUser(
98
142
  }
99
143
  throw err;
100
144
  }
101
- return { id, username, passwordHash, createdAt: stamp, updatedAt: stamp };
145
+ return {
146
+ id,
147
+ username,
148
+ passwordHash,
149
+ createdAt: stamp,
150
+ updatedAt: stamp,
151
+ passwordChanged: passwordChanged === 1,
152
+ assignedVault,
153
+ };
102
154
  }
103
155
 
104
156
  export function getUserByUsername(db: Database, username: string): User | null {
@@ -106,6 +158,22 @@ export function getUserByUsername(db: Database, username: string): User | null {
106
158
  return row ? rowToUser(row) : null;
107
159
  }
108
160
 
161
+ /**
162
+ * Case-insensitive username lookup. Username validation already pins
163
+ * the canonical form to lowercase (`[a-z0-9_-]`), so the only way a
164
+ * mixed-case lookup ever fires is a defense-in-depth check at the
165
+ * admin-create-user boundary — a future loosening of the validator
166
+ * (or a hand-edited row) wouldn't accidentally allow `Bob` to land
167
+ * alongside an existing `bob`. SQLite's `COLLATE NOCASE` does the work
168
+ * with no schema change.
169
+ */
170
+ export function getUserByUsernameCI(db: Database, username: string): User | null {
171
+ const row = db
172
+ .query<Row, [string]>("SELECT * FROM users WHERE username = ? COLLATE NOCASE")
173
+ .get(username);
174
+ return row ? rowToUser(row) : null;
175
+ }
176
+
109
177
  export function getUserById(db: Database, id: string): User | null {
110
178
  const row = db.query<Row, [string]>("SELECT * FROM users WHERE id = ?").get(id);
111
179
  return row ? rowToUser(row) : null;
@@ -142,3 +210,142 @@ export async function setPassword(
142
210
  .run(passwordHash, stamp, userId);
143
211
  if (result.changes === 0) throw new UserNotFoundError(userId);
144
212
  }
213
+
214
+ /**
215
+ * Hard-delete a user row and clean up FK-dependent rows.
216
+ *
217
+ * Schema reality at v8:
218
+ * - `tokens.user_id` is nullable (made nullable in migration v6). The
219
+ * plan from the design doc is "tokens stay with `revoked_at` set so
220
+ * the audit trail of 'this user existed and held these tokens'
221
+ * survives." But the FK is RESTRICT-on-delete, so we need to null
222
+ * out `tokens.user_id` after revoking to actually delete the
223
+ * parent users row. The audit trail survives via the `subject`
224
+ * column we backfill from the username plus the existing
225
+ * `created_at`, `scopes`, `client_id`, `revoked_at` fields.
226
+ * - `sessions.user_id` and `grants.user_id` are NOT NULL with a
227
+ * non-cascading FK. Both are deleted before the users row drops.
228
+ *
229
+ * Returns false when no user matches the id (idempotent — the API
230
+ * layer translates that to 404). Returns true on a successful delete.
231
+ *
232
+ * Caller is responsible for the first-admin-undeletable check; this
233
+ * helper enforces no policy beyond the schema hygiene.
234
+ */
235
+ export function deleteUser(db: Database, userId: string): boolean {
236
+ const row = db.query<Row, [string]>("SELECT * FROM users WHERE id = ?").get(userId);
237
+ if (!row) return false;
238
+ const now = new Date().toISOString();
239
+ db.transaction(() => {
240
+ // 1. Revoke + retain tokens for audit. Mark every un-revoked token
241
+ // revoked, then null out user_id on every token (revoked or
242
+ // not) so the FK doesn't block the users delete. Backfill
243
+ // `subject` with the username so the audit trail isn't anchored
244
+ // to a primary key that just vanished.
245
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(
246
+ now,
247
+ userId,
248
+ );
249
+ db.prepare(
250
+ "UPDATE tokens SET subject = COALESCE(subject, ?), user_id = NULL WHERE user_id = ?",
251
+ ).run(row.username, userId);
252
+ // 2. Drop sessions + grants. Both have non-cascading FKs on user_id;
253
+ // leaving rows behind would RESTRICT the users delete below.
254
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
255
+ db.prepare("DELETE FROM grants WHERE user_id = ?").run(userId);
256
+ // 3. Drop the user row itself.
257
+ db.prepare("DELETE FROM users WHERE id = ?").run(userId);
258
+ })();
259
+ return true;
260
+ }
261
+
262
+ /**
263
+ * Username validation (multi-user Phase 1, design 2026-05-20-multi-user-phase-1.md §4).
264
+ *
265
+ * Rules — settled with Aaron pre-PR-1:
266
+ * * Charset: `[a-z0-9_-]` (lowercase letters, digits, underscore, hyphen).
267
+ * Lowercase-only sidesteps "Bob vs bob" case-folding bugs across every
268
+ * downstream surface (URLs, log lines, the admin SPA's row keys).
269
+ * * Length: 2-32 chars inclusive. Hard floor on 1-char names (no `a`,
270
+ * `b`, …) because those are too easy to typo into someone else's
271
+ * account; hard ceiling on 32 because URL paths and log lines stay
272
+ * scannable. (Same shape vault-side scope verbs use.)
273
+ * * Reserved list (case-insensitive): admin, root, system, setup,
274
+ * parachute, hub. Keeps URL-shaped surfaces safe (Phase 2 may add
275
+ * `/users/<username>` paths; reserving the namespace now is cheap).
276
+ * Regex already pins lowercase, but the case-folded check is defense
277
+ * in depth: if a future loosening lets capitals through, the reserved
278
+ * check still triggers on `Admin`, `ROOT`, etc.
279
+ *
280
+ * Discriminated-union return: callers branch on `valid` rather than
281
+ * throwing. PR 2's `POST /api/users` returns a 400 with the `reason`
282
+ * surfaced in the response body.
283
+ */
284
+ export const USERNAME_RESERVED = ["admin", "root", "system", "setup", "parachute", "hub"] as const;
285
+
286
+ const USERNAME_REGEX = /^[a-z0-9_-]+$/;
287
+ const USERNAME_MIN_LEN = 2;
288
+ const USERNAME_MAX_LEN = 32;
289
+
290
+ export type ValidateUsernameResult =
291
+ | { valid: true; name: string }
292
+ | { valid: false; reason: "format" | "length" | "reserved" };
293
+
294
+ export function validateUsername(name: string): ValidateUsernameResult {
295
+ // Length check first — a 0-char string fails the regex on emptiness but
296
+ // "length" is the more honest diagnostic.
297
+ if (name.length < USERNAME_MIN_LEN || name.length > USERNAME_MAX_LEN) {
298
+ return { valid: false, reason: "length" };
299
+ }
300
+ // The regex deliberately allows leading/trailing `_` and `-` (so
301
+ // `_-_`, `--alice`, `-foo`, `bar_` all pass the format gate). Stricter
302
+ // rules can land later if real-world users hit confusion. Vault's
303
+ // parallel username validator has the same shape — cross-repo parity
304
+ // matters more than aesthetic edge-case rejection here.
305
+ if (!USERNAME_REGEX.test(name)) {
306
+ return { valid: false, reason: "format" };
307
+ }
308
+ // Reserved-words check is case-insensitive even though the regex already
309
+ // pins lowercase — see comment above.
310
+ const lower = name.toLowerCase();
311
+ if (USERNAME_RESERVED.some((r) => r === lower)) {
312
+ return { valid: false, reason: "reserved" };
313
+ }
314
+ return { valid: true, name };
315
+ }
316
+
317
+ /**
318
+ * Password validation (multi-user Phase 1, design §5).
319
+ *
320
+ * Single rule: minimum 12 characters. No complexity classes — modern
321
+ * guidance (NIST 800-63B) prefers passphrase length over forced-symbol
322
+ * mixes, and Aaron settled on 12 as the floor pre-PR-1. No max length
323
+ * (argon2id absorbs whatever the user submits).
324
+ *
325
+ * Same discriminated-union shape as `validateUsername` — PR 2's create-
326
+ * user / reset-password endpoints (and PR 3's `/account/change-password`
327
+ * form) wire the `reason` into the response.
328
+ */
329
+ export const PASSWORD_MIN_LEN = 12;
330
+
331
+ /**
332
+ * Upper bound for incoming password bodies. Not enforced inside
333
+ * `validatePassword` itself — the validator's contract is "length floor,
334
+ * no complexity rules" and adding a ceiling would muddy it. Exposed as
335
+ * a constant so PR 2's `POST /api/users` (and PR 3's change-password
336
+ * form) can cap incoming bodies before argon2id touches them. Defense
337
+ * against a CPU-DoS shape where an unauthenticated POST submits a
338
+ * megabyte password and forces a long argon2id hash. 256 chars is
339
+ * comfortably above any human-chosen passphrase (Diceware 8-word
340
+ * passphrases run ~55 chars).
341
+ */
342
+ export const PASSWORD_MAX_LEN = 256;
343
+
344
+ export type ValidatePasswordResult = { valid: true } | { valid: false; reason: "too_short" };
345
+
346
+ export function validatePassword(password: string): ValidatePasswordResult {
347
+ if (password.length < PASSWORD_MIN_LEN) {
348
+ return { valid: false, reason: "too_short" };
349
+ }
350
+ return { valid: true };
351
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Vault-name validation, mirrored from `@openparachute/vault`'s
3
+ * `src/vault-name.ts`.
4
+ *
5
+ * The vault package owns the canonical validator (used by `init`, the
6
+ * `--vault-name` flag, and the `PARACHUTE_VAULT_NAME` env var on
7
+ * first-boot). Hub doesn't depend on vault at runtime, so we keep a
8
+ * byte-identical contract here and pin parity with a test that exercises
9
+ * the same rule set:
10
+ *
11
+ * * lowercase alphanumeric + hyphens or underscores
12
+ * * 2–32 chars
13
+ * * `list` is reserved
14
+ *
15
+ * If vault's validator changes (e.g. additional reserved name, length
16
+ * relaxation), the two must move in lockstep — hub passing the typed
17
+ * name through `PARACHUTE_VAULT_NAME` only works as long as vault accepts
18
+ * what hub validates. Cross-repo drift here would silently fall back to
19
+ * `default` at vault first-boot (vault's `resolveFirstBootVaultName`
20
+ * downgrades env-invalid values).
21
+ *
22
+ * Out of scope: collision against existing vaults on the same hub — the
23
+ * wizard only ever creates the first vault, so name reuse can't happen.
24
+ * Subsequent vaults go through the admin SPA, which talks to vault's own
25
+ * `/vault/list` endpoint.
26
+ */
27
+
28
+ const VAULT_NAME_RE = /^[a-z0-9_-]+$/;
29
+ const VAULT_NAME_MIN_LEN = 2;
30
+ const VAULT_NAME_MAX_LEN = 32;
31
+
32
+ const RESERVED_NAMES = new Set([
33
+ // Mirrors vault's reservation. Collides with the legacy `/vaults/list`
34
+ // discovery endpoint; the routes have moved under `/vault/<name>/` but
35
+ // vault's `cmdCreate` still rejects "list" and cross-repo consistency
36
+ // is cheap.
37
+ "list",
38
+ ]);
39
+
40
+ export type VaultNameValidation = { ok: true; name: string } | { ok: false; error: string };
41
+
42
+ /**
43
+ * Validate a vault name against vault's strict contract. Trims
44
+ * surrounding whitespace before checking. Returns the trimmed name on
45
+ * success so callers don't double-trim.
46
+ */
47
+ export function validateVaultName(raw: string): VaultNameValidation {
48
+ const name = raw.trim();
49
+ if (!name) {
50
+ return { ok: false, error: "vault name cannot be empty." };
51
+ }
52
+ if (name.length < VAULT_NAME_MIN_LEN || name.length > VAULT_NAME_MAX_LEN) {
53
+ return {
54
+ ok: false,
55
+ error: `vault names must be ${VAULT_NAME_MIN_LEN}–${VAULT_NAME_MAX_LEN} characters long.`,
56
+ };
57
+ }
58
+ if (!VAULT_NAME_RE.test(name)) {
59
+ return {
60
+ ok: false,
61
+ error: "vault names must be lowercase alphanumeric with hyphens or underscores.",
62
+ };
63
+ }
64
+ if (RESERVED_NAMES.has(name)) {
65
+ return { ok: false, error: `"${name}" is a reserved vault name.` };
66
+ }
67
+ return { ok: true, name };
68
+ }
69
+
70
+ /** The default vault name when the operator leaves the field blank. */
71
+ export const DEFAULT_VAULT_NAME = "default";
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Vault-name list — the single source of truth for "which vault instances are
3
+ * registered on this hub right now."
4
+ *
5
+ * Multi-user Phase 1, PR 4 of 5 (design
6
+ * [`parachute.computer/design/2026-05-20-multi-user-phase-1.md`](https://parachute.computer/design/2026-05-20-multi-user-phase-1/)).
7
+ * Consolidates the two pre-PR-4 copies that read services.json and emitted
8
+ * vault names — one was private inside `oauth-handlers.ts` (used by the
9
+ * consent vault picker + post-consent narrowing), the other was private
10
+ * inside `api-users.ts` (used by `GET /api/users/vaults` for the admin SPA's
11
+ * assigned-vault dropdown + `POST /api/users` validation). PR 4 wires a third
12
+ * caller — server-side defense in `handleConsentSubmit` refusing mints
13
+ * whose picked vault disagrees with the user's `assigned_vault` — so the
14
+ * two private copies became three, and a duplicated read-and-derive helper
15
+ * for "what vaults exist" is exactly the shape that needs a single owner.
16
+ *
17
+ * Lives next to `well-known.ts` (which already owns `isVaultEntry` +
18
+ * `vaultInstanceNameFor`) rather than inside it: well-known's role is the
19
+ * `/.well-known/parachute.json` document shape, and a free-floating list
20
+ * helper would muddy that file's surface. Standalone module keeps the
21
+ * focused-purpose contract.
22
+ *
23
+ * Walks both manifest shapes: single-entry-multi-path (`parachute-vault`
24
+ * with `paths: ["/vault/work", "/vault/personal"]`) and per-vault entries
25
+ * (`parachute-vault-work`) by delegating each (name, path) pair to
26
+ * `vaultInstanceNameFor`. Entries with no paths still resolve to a name via
27
+ * the helper's manifest-suffix fallback (hub#143).
28
+ */
29
+ import { type ServicesManifest, readManifest } from "./services-manifest.ts";
30
+ import { isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
31
+
32
+ /**
33
+ * Emit each vault instance's name from an in-memory manifest. Sorted output
34
+ * keeps callers (consent picker dropdown, admin SPA dropdown, server-side
35
+ * defense lookup) deterministic without each having to wrap in their own
36
+ * `.sort()`.
37
+ */
38
+ export function listVaultNames(manifest: ServicesManifest): string[] {
39
+ const names = new Set<string>();
40
+ for (const svc of manifest.services) {
41
+ if (!isVaultEntry(svc)) continue;
42
+ const paths = svc.paths.length > 0 ? svc.paths : [undefined];
43
+ for (const path of paths) {
44
+ names.add(vaultInstanceNameFor(svc.name, path));
45
+ }
46
+ }
47
+ return Array.from(names).sort();
48
+ }
49
+
50
+ /**
51
+ * Read-from-disk convenience for callers that already have a manifest path
52
+ * (e.g. `/api/users/vaults` reading the live `services.json`). Equivalent to
53
+ * `listVaultNames(readManifest(manifestPath))`.
54
+ */
55
+ export function listVaultNamesFromPath(manifestPath: string): string[] {
56
+ return listVaultNames(readManifest(manifestPath));
57
+ }