@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
package/src/users.ts CHANGED
@@ -35,15 +35,20 @@ export interface User {
35
35
  */
36
36
  passwordChanged: boolean;
37
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).
38
+ * The vault instance names this user has access to (multi-user Phase 2
39
+ * PR 2 — many-to-many via the `user_vaults` table; design
40
+ * 2026-05-20-multi-user-phase-1.md §Phase 2). Empty `[]` means "no per-
41
+ * vault restriction" for admin accounts (where `isFirstAdmin` is true
42
+ * and the OAuth issuer mints tokens for any requested vault). Empty
43
+ * `[]` for a non-admin means "no access" — distinct semantics that the
44
+ * consent picker enforces. A non-empty array lists every vault the
45
+ * user is assigned to; the OAuth issuer narrows tokens to
46
+ * `vault:<name>:<verb>` for any name in the list. No FK; vault names
47
+ * resolve through `services.json` at mint time. Replaces the v8 single
48
+ * `assigned_vault` column (dropped in migration v10). Sorted in
49
+ * `created_at ASC` insert-order for deterministic iteration.
45
50
  */
46
- assignedVault: string | null;
51
+ assignedVaults: string[];
47
52
  }
48
53
 
49
54
  export class SingleUserModeError extends Error {
@@ -76,10 +81,44 @@ interface Row {
76
81
  created_at: string;
77
82
  updated_at: string;
78
83
  password_changed: number;
79
- assigned_vault: string | null;
80
84
  }
81
85
 
82
- function rowToUser(r: Row): User {
86
+ /**
87
+ * Read every (user_id → vault_name list) tuple in one shot. Cheaper than
88
+ * issuing one SELECT per row when callers (listUsers, etc.) hydrate
89
+ * several rows. Returns a Map keyed by user_id with the vault names
90
+ * sorted by `created_at ASC` for stable iteration. Users with no
91
+ * `user_vaults` rows are absent from the map; rowToUser substitutes
92
+ * an empty array.
93
+ */
94
+ function loadVaultMap(db: Database, userIds?: readonly string[]): Map<string, string[]> {
95
+ const map = new Map<string, string[]>();
96
+ let rows: { user_id: string; vault_name: string }[];
97
+ if (userIds && userIds.length > 0) {
98
+ const placeholders = userIds.map(() => "?").join(",");
99
+ rows = db
100
+ .query<{ user_id: string; vault_name: string }, string[]>(
101
+ `SELECT user_id, vault_name FROM user_vaults
102
+ WHERE user_id IN (${placeholders})
103
+ ORDER BY user_id ASC, created_at ASC, vault_name ASC`,
104
+ )
105
+ .all(...userIds);
106
+ } else {
107
+ rows = db
108
+ .query<{ user_id: string; vault_name: string }, []>(
109
+ "SELECT user_id, vault_name FROM user_vaults ORDER BY user_id ASC, created_at ASC, vault_name ASC",
110
+ )
111
+ .all();
112
+ }
113
+ for (const r of rows) {
114
+ const list = map.get(r.user_id);
115
+ if (list) list.push(r.vault_name);
116
+ else map.set(r.user_id, [r.vault_name]);
117
+ }
118
+ return map;
119
+ }
120
+
121
+ function rowToUser(r: Row, assignedVaults: string[]): User {
83
122
  return {
84
123
  id: r.id,
85
124
  username: r.username,
@@ -87,10 +126,24 @@ function rowToUser(r: Row): User {
87
126
  createdAt: r.created_at,
88
127
  updatedAt: r.updated_at,
89
128
  passwordChanged: r.password_changed === 1,
90
- assignedVault: r.assigned_vault,
129
+ assignedVaults,
91
130
  };
92
131
  }
93
132
 
133
+ /**
134
+ * Hydrate a single user's `assignedVaults` list directly. Single
135
+ * SELECT against `user_vaults` ordered by insertion time. Used by the
136
+ * single-row helpers (`getUserById`, `getUserByUsername`, etc.).
137
+ */
138
+ function readVaultsForUser(db: Database, userId: string): string[] {
139
+ return db
140
+ .query<{ vault_name: string }, [string]>(
141
+ "SELECT vault_name FROM user_vaults WHERE user_id = ? ORDER BY created_at ASC, vault_name ASC",
142
+ )
143
+ .all(userId)
144
+ .map((r) => r.vault_name);
145
+ }
146
+
94
147
  export interface CreateUserOpts {
95
148
  /** Allow creating an additional user when one already exists. Off by default. */
96
149
  allowMulti?: boolean;
@@ -105,13 +158,15 @@ export interface CreateUserOpts {
105
158
  */
106
159
  passwordChanged?: boolean;
107
160
  /**
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.
161
+ * Vault instance names this user should be granted access to (multi-
162
+ * user Phase 2 PR 2 many-to-many via `user_vaults`). Default `[]`
163
+ * (no entries) means "no restriction" for admins / "no access" for
164
+ * non-admins. Each name is inserted into `user_vaults` within the same
165
+ * transaction as the `users` row so creation is atomic. No validation
166
+ * here: the API endpoint (`api-users.ts`) is responsible for checking
167
+ * each name against `services.json` before passing through.
113
168
  */
114
- assignedVault?: string | null;
169
+ assignedVaults?: string[];
115
170
  }
116
171
 
117
172
  export async function createUser(
@@ -128,13 +183,35 @@ export async function createUser(
128
183
  const passwordHash = await argonHash(password);
129
184
  const stamp = (opts.now?.() ?? new Date()).toISOString();
130
185
  const passwordChanged = opts.passwordChanged === true ? 1 : 0;
131
- const assignedVault = opts.assignedVault ?? null;
186
+ // De-dupe + preserve insert order so the returned array matches what
187
+ // `getUserById` would load right after (which sorts by created_at +
188
+ // vault_name). Empty array is "no vaults" — admin posture or a non-
189
+ // admin who'll have vaults added later via `setUserVaults`.
190
+ const assignedVaults: string[] = [];
191
+ const seen = new Set<string>();
192
+ for (const v of opts.assignedVaults ?? []) {
193
+ if (!seen.has(v)) {
194
+ seen.add(v);
195
+ assignedVaults.push(v);
196
+ }
197
+ }
132
198
  try {
133
- db.prepare(
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);
199
+ db.transaction(() => {
200
+ db.prepare(
201
+ `INSERT INTO users
202
+ (id, username, password_hash, created_at, updated_at, password_changed)
203
+ VALUES (?, ?, ?, ?, ?, ?)`,
204
+ ).run(id, username, passwordHash, stamp, stamp, passwordChanged);
205
+ if (assignedVaults.length > 0) {
206
+ const insertVault = db.prepare(
207
+ `INSERT INTO user_vaults (user_id, vault_name, role, created_at)
208
+ VALUES (?, ?, 'write', ?)`,
209
+ );
210
+ for (const vaultName of assignedVaults) {
211
+ insertVault.run(id, vaultName, stamp);
212
+ }
213
+ }
214
+ })();
138
215
  } catch (err) {
139
216
  const msg = err instanceof Error ? err.message : String(err);
140
217
  if (msg.includes("UNIQUE") && msg.includes("users.username")) {
@@ -149,13 +226,13 @@ export async function createUser(
149
226
  createdAt: stamp,
150
227
  updatedAt: stamp,
151
228
  passwordChanged: passwordChanged === 1,
152
- assignedVault,
229
+ assignedVaults,
153
230
  };
154
231
  }
155
232
 
156
233
  export function getUserByUsername(db: Database, username: string): User | null {
157
234
  const row = db.query<Row, [string]>("SELECT * FROM users WHERE username = ?").get(username);
158
- return row ? rowToUser(row) : null;
235
+ return row ? rowToUser(row, readVaultsForUser(db, row.id)) : null;
159
236
  }
160
237
 
161
238
  /**
@@ -171,27 +248,135 @@ export function getUserByUsernameCI(db: Database, username: string): User | null
171
248
  const row = db
172
249
  .query<Row, [string]>("SELECT * FROM users WHERE username = ? COLLATE NOCASE")
173
250
  .get(username);
174
- return row ? rowToUser(row) : null;
251
+ return row ? rowToUser(row, readVaultsForUser(db, row.id)) : null;
175
252
  }
176
253
 
177
254
  export function getUserById(db: Database, id: string): User | null {
178
255
  const row = db.query<Row, [string]>("SELECT * FROM users WHERE id = ?").get(id);
179
- return row ? rowToUser(row) : null;
256
+ return row ? rowToUser(row, readVaultsForUser(db, row.id)) : null;
180
257
  }
181
258
 
182
259
  export function listUsers(db: Database): User[] {
183
260
  const rows = db.query<Row, []>("SELECT * FROM users ORDER BY created_at ASC").all();
184
- return rows.map(rowToUser);
261
+ if (rows.length === 0) return [];
262
+ // One JOIN-ish read for everyone — single SELECT against user_vaults
263
+ // beats N+1 single-user reads.
264
+ const vaultMap = loadVaultMap(
265
+ db,
266
+ rows.map((r) => r.id),
267
+ );
268
+ return rows.map((r) => rowToUser(r, vaultMap.get(r.id) ?? []));
185
269
  }
186
270
 
187
271
  export function userCount(db: Database): number {
188
272
  return (db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM users").get() ?? { n: 0 }).n;
189
273
  }
190
274
 
275
+ /**
276
+ * Single source of truth for "who is *the* admin in Phase 1." The
277
+ * earliest-created user row is the wizard or env-seeded admin by
278
+ * construction — Phase 1 has no role model, so the first row is the
279
+ * hub administrator. Used by:
280
+ *
281
+ * - `api-users.ts` for the first-admin-undeletable rail (the only
282
+ * user who can't be deleted, since deleting them would self-lock
283
+ * the hub).
284
+ * - `admin-host-admin-token.ts` to gate the SPA-bearer mint endpoint
285
+ * to the admin only — any signed-in non-admin friend hitting it
286
+ * would otherwise get a JWT carrying `parachute:host:admin` +
287
+ * `parachute:host:auth`, a full-admin privesc (multi-user Phase 1
288
+ * friend-account follow-up).
289
+ * - `admin-handlers.ts` for the login-redirect default — non-admin
290
+ * users targeting `/admin/*` get redirected to `/account/` instead
291
+ * of a 403 wall.
292
+ *
293
+ * Returns `null` only when the users table is empty (pre-wizard state).
294
+ */
295
+ export function getFirstAdminId(db: Database): string | null {
296
+ const row = db
297
+ .query<{ id: string }, []>("SELECT id FROM users ORDER BY created_at ASC LIMIT 1")
298
+ .get();
299
+ return row?.id ?? null;
300
+ }
301
+
302
+ /**
303
+ * Convenience predicate over `getFirstAdminId`. Caller sites read
304
+ * cleaner as `isFirstAdmin(db, userId)` than `getFirstAdminId(db) === userId`.
305
+ */
306
+ export function isFirstAdmin(db: Database, userId: string): boolean {
307
+ return getFirstAdminId(db) === userId;
308
+ }
309
+
191
310
  export async function verifyPassword(user: User, password: string): Promise<boolean> {
192
311
  return argonVerify(user.passwordHash, password);
193
312
  }
194
313
 
314
+ /**
315
+ * Replace a user's vault assignments atomically (multi-user Phase 2 PR 2).
316
+ *
317
+ * Two writes inside one transaction:
318
+ * 1. DELETE every existing `user_vaults` row for `userId`.
319
+ * 2. INSERT one row per name in `vaultNames`.
320
+ *
321
+ * Returns `false` when the user doesn't exist (idempotent — the API layer
322
+ * translates that to 404); `true` when the assignments were updated.
323
+ * Passing an empty array clears every existing assignment (non-admin
324
+ * non-empty array = "no vault access"). Duplicates are silently
325
+ * collapsed (de-duped at the array level before INSERT). No vault-name
326
+ * validation here — `api-users.ts` is responsible for checking each
327
+ * name against `services.json`. No FK on `vault_name` (matches the
328
+ * pre-existing schema contract — vault names resolve through
329
+ * `services.json`, not a DB row).
330
+ *
331
+ * Caller responsibilities:
332
+ * - First-admin protection — admin "membership" is unrestricted by
333
+ * design (see `isFirstAdmin`); `api-users.ts` refuses to call this
334
+ * for the first admin's row.
335
+ * - Vault-name validation against the live services manifest.
336
+ */
337
+ export function setUserVaults(
338
+ db: Database,
339
+ userId: string,
340
+ vaultNames: readonly string[],
341
+ now: () => Date = () => new Date(),
342
+ ): boolean {
343
+ const exists = db
344
+ .query<{ id: string }, [string]>("SELECT id FROM users WHERE id = ?")
345
+ .get(userId);
346
+ if (!exists) return false;
347
+ // De-dupe before INSERT — duplicate names from a misbehaving client
348
+ // would trip the (user_id, vault_name) PRIMARY KEY constraint and
349
+ // abort the whole transaction. Silently collapse the dupes; the
350
+ // operator's intent is "this user has access to these vaults"
351
+ // regardless of how many times the same name appears.
352
+ const seen = new Set<string>();
353
+ const uniques: string[] = [];
354
+ for (const v of vaultNames) {
355
+ if (!seen.has(v)) {
356
+ seen.add(v);
357
+ uniques.push(v);
358
+ }
359
+ }
360
+ const stamp = now().toISOString();
361
+ db.transaction(() => {
362
+ db.prepare("DELETE FROM user_vaults WHERE user_id = ?").run(userId);
363
+ if (uniques.length > 0) {
364
+ const insertVault = db.prepare(
365
+ `INSERT INTO user_vaults (user_id, vault_name, role, created_at)
366
+ VALUES (?, ?, 'write', ?)`,
367
+ );
368
+ for (const vaultName of uniques) {
369
+ insertVault.run(userId, vaultName, stamp);
370
+ }
371
+ }
372
+ // Bump the user's updated_at so downstream observers (SPA row,
373
+ // /account/) reflect the change without us having to bake a
374
+ // separate "vault assignments changed" timestamp.
375
+ db.prepare("UPDATE users SET updated_at = ? WHERE id = ?").run(stamp, userId);
376
+ })();
377
+ return true;
378
+ }
379
+
195
380
  /**
196
381
  * Updates the password for an existing user. Throws `UserNotFoundError` if
197
382
  * the id has no row. Single-user-mode flows look up by username first and
@@ -211,10 +396,100 @@ export async function setPassword(
211
396
  if (result.changes === 0) throw new UserNotFoundError(userId);
212
397
  }
213
398
 
399
+ /**
400
+ * Reset a user's password to an admin-chosen value (multi-user Phase 2
401
+ * PR 1, hub#252 follow-up). Used by the `POST /api/users/:id/reset-password`
402
+ * admin endpoint when a friend forgets their password — the operator's
403
+ * only Phase-1 recovery was delete+recreate, which is destructive-feeling
404
+ * even though it's safe (vaults are independent of accounts).
405
+ *
406
+ * Three writes inside one transaction:
407
+ *
408
+ * 1. Rotate `password_hash` to the new argon2id hash and flip
409
+ * `password_changed` back to 0 so the user is force-redirected
410
+ * through `/account/change-password` on next sign-in (same posture
411
+ * as the admin-created-user default — the operator hands the temp
412
+ * password out-of-band, the user picks their own immediately).
413
+ * 2. Revoke every still-active token row owned by the user
414
+ * (`tokens.revoked_at = now WHERE user_id = ? AND revoked_at IS NULL`).
415
+ * The reset is a "the old password leaked" recovery shape — leaving
416
+ * pre-reset tokens valid for an attacker who knew the old password
417
+ * would defeat the purpose. We keep the rows (don't NULL `user_id`
418
+ * like `deleteUser` does) because the audit trail naturally re-
419
+ * anchors to the still-existing user row.
420
+ * 3. Bump `updated_at` so the SPA's row reflects the rotation.
421
+ *
422
+ * Hash OUTSIDE the transaction — argon2id is async and `db.transaction()`
423
+ * on bun:sqlite is sync; doing it inside silently breaks atomicity (same
424
+ * constraint api-account.ts:399 documents for the change-password POST).
425
+ *
426
+ * **Revocation propagation lag (smoke 2026-05-27, finding 3)**: this
427
+ * function marks tokens revoked in hub's DB immediately. Hub's
428
+ * `/.well-known/parachute-revocation.json` reflects the new revocation
429
+ * on the next fetch. BUT resource servers (vault, scribe, etc.) consult
430
+ * the revocation list via scope-guard's `REVOCATION_CACHE_TTL_MS = 60_000`
431
+ * cache — so they may continue accepting the revoked token for up to
432
+ * 60 seconds after this call returns. For the "friend forgot pw"
433
+ * recovery path this is fine (no adversary). For the "stolen device,
434
+ * kill the friend's tokens NOW" path it's a meaningful exposure
435
+ * window — operators in that scenario should also restart the
436
+ * affected resource servers to flush their cache. See
437
+ * `REVOCATION_LAG_SECONDS` for the value surfaced to API callers.
438
+ *
439
+ * Caller responsibilities (not enforced here):
440
+ * - Validate `newPassword` first (`validatePassword`) — this helper
441
+ * trusts the input and runs argon2id over whatever it gets.
442
+ * - First-admin protection — admin password reset is restricted to
443
+ * non-first-admin users per design §7. The first admin uses the
444
+ * normal `/account/change-password` flow for themselves.
445
+ *
446
+ * Returns true on success, false if the user doesn't exist (idempotent —
447
+ * the API layer translates that to 404).
448
+ */
449
+ export async function resetUserPassword(
450
+ db: Database,
451
+ userId: string,
452
+ newPassword: string,
453
+ now: () => Date = () => new Date(),
454
+ ): Promise<boolean> {
455
+ // Existence pre-check OUTSIDE the tx. The argon2id hash below is the
456
+ // expensive step; hashing for a non-existent user is wasted CPU and
457
+ // also leaks "was this id valid" timing. Cheap SELECT first.
458
+ const exists = db
459
+ .query<{ id: string }, [string]>("SELECT id FROM users WHERE id = ?")
460
+ .get(userId);
461
+ if (!exists) return false;
462
+ // Hash outside the tx — see note above.
463
+ const passwordHash = await argonHash(newPassword);
464
+ const stamp = now().toISOString();
465
+ // Track whether the tx actually applied the update — `result.changes === 0`
466
+ // means the row vanished between the pre-check and the tx body (concurrent
467
+ // delete race). The outer caller needs to know so its 200/{ok,user} response
468
+ // isn't a lie when the user is gone. Reviewer fold on hub#427.
469
+ let updated = false;
470
+ db.transaction(() => {
471
+ const result = db
472
+ .prepare(
473
+ "UPDATE users SET password_hash = ?, password_changed = 0, updated_at = ? WHERE id = ?",
474
+ )
475
+ .run(passwordHash, stamp, userId);
476
+ if (result.changes === 0) return;
477
+ updated = true;
478
+ // Revoke still-active tokens. Audit trail stays on the user row —
479
+ // we don't null `user_id` because the parent users row sticks
480
+ // around (unlike `deleteUser` where the parent vanishes).
481
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(
482
+ stamp,
483
+ userId,
484
+ );
485
+ })();
486
+ return updated;
487
+ }
488
+
214
489
  /**
215
490
  * Hard-delete a user row and clean up FK-dependent rows.
216
491
  *
217
- * Schema reality at v8:
492
+ * Schema reality at v10:
218
493
  * - `tokens.user_id` is nullable (made nullable in migration v6). The
219
494
  * plan from the design doc is "tokens stay with `revoked_at` set so
220
495
  * the audit trail of 'this user existed and held these tokens'
@@ -225,6 +500,9 @@ export async function setPassword(
225
500
  * `created_at`, `scopes`, `client_id`, `revoked_at` fields.
226
501
  * - `sessions.user_id` and `grants.user_id` are NOT NULL with a
227
502
  * non-cascading FK. Both are deleted before the users row drops.
503
+ * - `user_vaults.user_id` has `ON DELETE CASCADE` (migration v10), so
504
+ * vault assignments are dropped automatically when the parent row
505
+ * goes. No explicit cleanup needed.
228
506
  *
229
507
  * Returns false when no user matches the id (idempotent — the API
230
508
  * layer translates that to 404). Returns true on a successful delete.