@openparachute/hub 0.5.13 → 0.5.14-rc.2
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.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- 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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
*
|
|
110
|
-
* (
|
|
111
|
-
*
|
|
112
|
-
* `
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|