@openparachute/hub 0.5.13 → 0.5.14-rc.1
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 +140 -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.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +192 -2
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +157 -19
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +261 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +404 -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.ts +3 -3
- package/src/api-users.ts +173 -12
- package/src/chrome-strip.ts +6 -6
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-server.ts +50 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +335 -28
- package/src/users.ts +112 -0
- package/web/ui/dist/assets/index-Qf56GsGm.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
|
@@ -188,6 +188,41 @@ export function userCount(db: Database): number {
|
|
|
188
188
|
return (db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM users").get() ?? { n: 0 }).n;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Single source of truth for "who is *the* admin in Phase 1." The
|
|
193
|
+
* earliest-created user row is the wizard or env-seeded admin by
|
|
194
|
+
* construction — Phase 1 has no role model, so the first row is the
|
|
195
|
+
* hub administrator. Used by:
|
|
196
|
+
*
|
|
197
|
+
* - `api-users.ts` for the first-admin-undeletable rail (the only
|
|
198
|
+
* user who can't be deleted, since deleting them would self-lock
|
|
199
|
+
* the hub).
|
|
200
|
+
* - `admin-host-admin-token.ts` to gate the SPA-bearer mint endpoint
|
|
201
|
+
* to the admin only — any signed-in non-admin friend hitting it
|
|
202
|
+
* would otherwise get a JWT carrying `parachute:host:admin` +
|
|
203
|
+
* `parachute:host:auth`, a full-admin privesc (multi-user Phase 1
|
|
204
|
+
* friend-account follow-up).
|
|
205
|
+
* - `admin-handlers.ts` for the login-redirect default — non-admin
|
|
206
|
+
* users targeting `/admin/*` get redirected to `/account/` instead
|
|
207
|
+
* of a 403 wall.
|
|
208
|
+
*
|
|
209
|
+
* Returns `null` only when the users table is empty (pre-wizard state).
|
|
210
|
+
*/
|
|
211
|
+
export function getFirstAdminId(db: Database): string | null {
|
|
212
|
+
const row = db
|
|
213
|
+
.query<{ id: string }, []>("SELECT id FROM users ORDER BY created_at ASC LIMIT 1")
|
|
214
|
+
.get();
|
|
215
|
+
return row?.id ?? null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Convenience predicate over `getFirstAdminId`. Caller sites read
|
|
220
|
+
* cleaner as `isFirstAdmin(db, userId)` than `getFirstAdminId(db) === userId`.
|
|
221
|
+
*/
|
|
222
|
+
export function isFirstAdmin(db: Database, userId: string): boolean {
|
|
223
|
+
return getFirstAdminId(db) === userId;
|
|
224
|
+
}
|
|
225
|
+
|
|
191
226
|
export async function verifyPassword(user: User, password: string): Promise<boolean> {
|
|
192
227
|
return argonVerify(user.passwordHash, password);
|
|
193
228
|
}
|
|
@@ -211,6 +246,83 @@ export async function setPassword(
|
|
|
211
246
|
if (result.changes === 0) throw new UserNotFoundError(userId);
|
|
212
247
|
}
|
|
213
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Reset a user's password to an admin-chosen value (multi-user Phase 2
|
|
251
|
+
* PR 1, hub#252 follow-up). Used by the `POST /api/users/:id/reset-password`
|
|
252
|
+
* admin endpoint when a friend forgets their password — the operator's
|
|
253
|
+
* only Phase-1 recovery was delete+recreate, which is destructive-feeling
|
|
254
|
+
* even though it's safe (vaults are independent of accounts).
|
|
255
|
+
*
|
|
256
|
+
* Three writes inside one transaction:
|
|
257
|
+
*
|
|
258
|
+
* 1. Rotate `password_hash` to the new argon2id hash and flip
|
|
259
|
+
* `password_changed` back to 0 so the user is force-redirected
|
|
260
|
+
* through `/account/change-password` on next sign-in (same posture
|
|
261
|
+
* as the admin-created-user default — the operator hands the temp
|
|
262
|
+
* password out-of-band, the user picks their own immediately).
|
|
263
|
+
* 2. Revoke every still-active token row owned by the user
|
|
264
|
+
* (`tokens.revoked_at = now WHERE user_id = ? AND revoked_at IS NULL`).
|
|
265
|
+
* The reset is a "the old password leaked" recovery shape — leaving
|
|
266
|
+
* pre-reset tokens valid for an attacker who knew the old password
|
|
267
|
+
* would defeat the purpose. We keep the rows (don't NULL `user_id`
|
|
268
|
+
* like `deleteUser` does) because the audit trail naturally re-
|
|
269
|
+
* anchors to the still-existing user row.
|
|
270
|
+
* 3. Bump `updated_at` so the SPA's row reflects the rotation.
|
|
271
|
+
*
|
|
272
|
+
* Hash OUTSIDE the transaction — argon2id is async and `db.transaction()`
|
|
273
|
+
* on bun:sqlite is sync; doing it inside silently breaks atomicity (same
|
|
274
|
+
* constraint api-account.ts:399 documents for the change-password POST).
|
|
275
|
+
*
|
|
276
|
+
* Caller responsibilities (not enforced here):
|
|
277
|
+
* - Validate `newPassword` first (`validatePassword`) — this helper
|
|
278
|
+
* trusts the input and runs argon2id over whatever it gets.
|
|
279
|
+
* - First-admin protection — admin password reset is restricted to
|
|
280
|
+
* non-first-admin users per design §7. The first admin uses the
|
|
281
|
+
* normal `/account/change-password` flow for themselves.
|
|
282
|
+
*
|
|
283
|
+
* Returns true on success, false if the user doesn't exist (idempotent —
|
|
284
|
+
* the API layer translates that to 404).
|
|
285
|
+
*/
|
|
286
|
+
export async function resetUserPassword(
|
|
287
|
+
db: Database,
|
|
288
|
+
userId: string,
|
|
289
|
+
newPassword: string,
|
|
290
|
+
now: () => Date = () => new Date(),
|
|
291
|
+
): Promise<boolean> {
|
|
292
|
+
// Existence pre-check OUTSIDE the tx. The argon2id hash below is the
|
|
293
|
+
// expensive step; hashing for a non-existent user is wasted CPU and
|
|
294
|
+
// also leaks "was this id valid" timing. Cheap SELECT first.
|
|
295
|
+
const exists = db
|
|
296
|
+
.query<{ id: string }, [string]>("SELECT id FROM users WHERE id = ?")
|
|
297
|
+
.get(userId);
|
|
298
|
+
if (!exists) return false;
|
|
299
|
+
// Hash outside the tx — see note above.
|
|
300
|
+
const passwordHash = await argonHash(newPassword);
|
|
301
|
+
const stamp = now().toISOString();
|
|
302
|
+
// Track whether the tx actually applied the update — `result.changes === 0`
|
|
303
|
+
// means the row vanished between the pre-check and the tx body (concurrent
|
|
304
|
+
// delete race). The outer caller needs to know so its 200/{ok,user} response
|
|
305
|
+
// isn't a lie when the user is gone. Reviewer fold on hub#427.
|
|
306
|
+
let updated = false;
|
|
307
|
+
db.transaction(() => {
|
|
308
|
+
const result = db
|
|
309
|
+
.prepare(
|
|
310
|
+
"UPDATE users SET password_hash = ?, password_changed = 0, updated_at = ? WHERE id = ?",
|
|
311
|
+
)
|
|
312
|
+
.run(passwordHash, stamp, userId);
|
|
313
|
+
if (result.changes === 0) return;
|
|
314
|
+
updated = true;
|
|
315
|
+
// Revoke still-active tokens. Audit trail stays on the user row —
|
|
316
|
+
// we don't null `user_id` because the parent users row sticks
|
|
317
|
+
// around (unlike `deleteUser` where the parent vanishes).
|
|
318
|
+
db.prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(
|
|
319
|
+
stamp,
|
|
320
|
+
userId,
|
|
321
|
+
);
|
|
322
|
+
})();
|
|
323
|
+
return updated;
|
|
324
|
+
}
|
|
325
|
+
|
|
214
326
|
/**
|
|
215
327
|
* Hard-delete a user row and clean up FK-dependent rows.
|
|
216
328
|
*
|