@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.
Files changed (37) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +140 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules.test.ts +32 -32
  8. package/src/__tests__/api-users.test.ts +192 -2
  9. package/src/__tests__/chrome-strip.test.ts +15 -15
  10. package/src/__tests__/hub-server.test.ts +23 -23
  11. package/src/__tests__/notes-redirect.test.ts +20 -20
  12. package/src/__tests__/services-manifest.test.ts +40 -40
  13. package/src/__tests__/setup-wizard.test.ts +157 -19
  14. package/src/__tests__/setup.test.ts +1 -1
  15. package/src/__tests__/status.test.ts +39 -0
  16. package/src/__tests__/users.test.ts +261 -0
  17. package/src/__tests__/well-known.test.ts +9 -9
  18. package/src/account-home-ui.ts +404 -0
  19. package/src/admin-handlers.ts +49 -17
  20. package/src/admin-host-admin-token.ts +25 -0
  21. package/src/admin-vault-admin-token.ts +17 -0
  22. package/src/api-account.ts +72 -6
  23. package/src/api-modules.ts +3 -3
  24. package/src/api-users.ts +173 -12
  25. package/src/chrome-strip.ts +6 -6
  26. package/src/commands/status.ts +10 -1
  27. package/src/help.ts +2 -2
  28. package/src/hub-server.ts +50 -10
  29. package/src/hub-settings.ts +2 -2
  30. package/src/hub.ts +6 -6
  31. package/src/notes-redirect.ts +5 -5
  32. package/src/service-spec.ts +39 -18
  33. package/src/setup-wizard.ts +335 -28
  34. package/src/users.ts +112 -0
  35. package/web/ui/dist/assets/index-Qf56GsGm.js +61 -0
  36. package/web/ui/dist/index.html +1 -1
  37. 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
  *