@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.
Files changed (47) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +163 -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-ops.test.ts +97 -0
  8. package/src/__tests__/api-modules.test.ts +32 -32
  9. package/src/__tests__/api-users.test.ts +383 -11
  10. package/src/__tests__/chrome-strip.test.ts +15 -15
  11. package/src/__tests__/hub-db.test.ts +194 -29
  12. package/src/__tests__/hub-server.test.ts +23 -23
  13. package/src/__tests__/notes-redirect.test.ts +20 -20
  14. package/src/__tests__/oauth-handlers.test.ts +722 -28
  15. package/src/__tests__/serve.test.ts +9 -9
  16. package/src/__tests__/services-manifest.test.ts +40 -40
  17. package/src/__tests__/setup-wizard.test.ts +493 -25
  18. package/src/__tests__/setup.test.ts +1 -1
  19. package/src/__tests__/status.test.ts +39 -0
  20. package/src/__tests__/users.test.ts +396 -9
  21. package/src/__tests__/well-known.test.ts +9 -9
  22. package/src/account-home-ui.ts +434 -0
  23. package/src/admin-handlers.ts +49 -17
  24. package/src/admin-host-admin-token.ts +25 -0
  25. package/src/admin-vault-admin-token.ts +17 -0
  26. package/src/api-account.ts +72 -6
  27. package/src/api-modules-ops.ts +52 -16
  28. package/src/api-modules.ts +3 -3
  29. package/src/api-users.ts +468 -55
  30. package/src/bun-link.ts +55 -0
  31. package/src/chrome-strip.ts +6 -6
  32. package/src/commands/install.ts +8 -21
  33. package/src/commands/status.ts +10 -1
  34. package/src/help.ts +2 -2
  35. package/src/hub-db.ts +42 -0
  36. package/src/hub-server.ts +69 -10
  37. package/src/hub-settings.ts +2 -2
  38. package/src/hub.ts +6 -6
  39. package/src/notes-redirect.ts +5 -5
  40. package/src/oauth-handlers.ts +278 -173
  41. package/src/oauth-ui.ts +18 -2
  42. package/src/service-spec.ts +39 -18
  43. package/src/setup-wizard.ts +489 -42
  44. package/src/users.ts +307 -29
  45. package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
  46. package/web/ui/dist/index.html +1 -1
  47. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/src/api-users.ts CHANGED
@@ -1,26 +1,34 @@
1
1
  /**
2
2
  * `/api/users*` — admin endpoints for managing hub user accounts.
3
3
  *
4
- * Multi-user Phase 1, PR 2 of 5. Design:
4
+ * Multi-user Phase 2 PR 2 (per-user multi-vault membership). Design:
5
5
  * [`parachute.computer/design/2026-05-20-multi-user-phase-1.md`](https://parachute.computer/design/2026-05-20-multi-user-phase-1/).
6
- * Tracker: hub#252. Builds on PR 1 (hub#279) which shipped migration v8 +
7
- * the `validateUsername` / `validatePassword` validators this layer wires
8
- * through.
6
+ * Tracker: hub#252. Builds on PR 1 (hub#279) which shipped migration v8
7
+ * + the `validateUsername` / `validatePassword` validators, and Phase 2
8
+ * PR 1 (admin password reset). PR 2 lifts the single `assigned_vault`
9
+ * column into the `user_vaults` many-to-many table (migration v10) so a
10
+ * user can have access to multiple vaults.
9
11
  *
10
12
  * Surfaces:
11
13
  *
12
- * GET /api/users list users (host:admin)
13
- * POST /api/users create user (host:admin)
14
- * DELETE /api/users/:id hard-delete user (host:admin)
15
- * GET /api/users/vaults vault-name list for the assigned-vault
16
- * dropdown (host:admin)
14
+ * GET /api/users list users (host:admin)
15
+ * POST /api/users create user (host:admin)
16
+ * DELETE /api/users/:id hard-delete user (host:admin)
17
+ * POST /api/users/:id/reset-password admin password reset (host:admin)
18
+ * PATCH /api/users/:id/vaults edit a user's vault list (host:admin)
19
+ * GET /api/users/vaults vault-name list for the
20
+ * assigned-vault dropdown
21
+ * (host:admin)
17
22
  *
18
23
  * Wire shape is snake_case (matches `/api/grants`, `/api/auth/tokens`).
19
24
  * Responses never include `password_hash` — hashes never leave the DB.
20
25
  *
21
- * Phase 1 deliberately ships only list / create / delete. Editing a user
22
- * (reassign vault, reset password) is Phase 2 work Phase 1's admin
23
- * recovery shape is "delete + re-create" per the design doc's §6.
26
+ * Phase 1 shipped list / create / delete. Phase 2 PR 1 adds admin
27
+ * password reset (this file's `handleResetUserPassword`)the highest-
28
+ * pain operator UX gap from Phase 1 was "friend forgot their password
29
+ * → operator has to delete+recreate," which is destructive-feeling
30
+ * even though vaults are independent of accounts. Reassign-vault and
31
+ * other edits land in later Phase 2 PRs.
24
32
  *
25
33
  * Auth: every endpoint requires a bearer token carrying the
26
34
  * `parachute:host:admin` scope. Same gate as `/api/grants`, `/vaults`,
@@ -45,9 +53,13 @@ import {
45
53
  UsernameTakenError,
46
54
  createUser,
47
55
  deleteUser,
56
+ getFirstAdminId,
48
57
  getUserById,
49
58
  getUserByUsernameCI,
59
+ isFirstAdmin,
50
60
  listUsers,
61
+ resetUserPassword,
62
+ setUserVaults,
51
63
  validatePassword,
52
64
  validateUsername,
53
65
  } from "./users.ts";
@@ -62,16 +74,21 @@ export interface ApiUsersDeps {
62
74
  }
63
75
 
64
76
  /**
65
- * Wire shape for a user row. Mirrors the DB columns but renames for
66
- * snake_case-on-the-wire camelCase-in-TS: `password_changed`,
67
- * `assigned_vault`, `created_at`. **`password_hash` is never present**
77
+ * Wire shape for a user row. Mirrors the schema but renames for snake_
78
+ * case-on-the-wire / camelCase-in-TS: `password_changed`,
79
+ * `assigned_vaults`, `created_at`. **`password_hash` is never present**
68
80
  * — it's the one column that must not leak.
81
+ *
82
+ * `assigned_vaults` replaces the Phase 1 `assigned_vault: string | null`
83
+ * shape (multi-user Phase 2 PR 2). Empty array = "no vault narrowing"
84
+ * for admin posture; a non-empty array lists every vault the user has
85
+ * access to.
69
86
  */
70
87
  export interface UserWireShape {
71
88
  id: string;
72
89
  username: string;
73
90
  password_changed: boolean;
74
- assigned_vault: string | null;
91
+ assigned_vaults: string[];
75
92
  created_at: string;
76
93
  }
77
94
 
@@ -80,7 +97,7 @@ function toWire(u: User): UserWireShape {
80
97
  id: u.id,
81
98
  username: u.username,
82
99
  password_changed: u.passwordChanged,
83
- assigned_vault: u.assignedVault,
100
+ assigned_vaults: [...u.assignedVaults],
84
101
  created_at: u.createdAt,
85
102
  };
86
103
  }
@@ -112,7 +129,7 @@ export async function handleListUsers(req: Request, deps: ApiUsersDeps): Promise
112
129
  interface CreateUserBody {
113
130
  username: string;
114
131
  password: string;
115
- assignedVault: string | null;
132
+ assignedVaults: string[];
116
133
  }
117
134
 
118
135
  interface ParseOk {
@@ -190,27 +207,50 @@ async function parseCreateBody(req: Request): Promise<ParseOk | ParseErr> {
190
207
  description: `password length must be ≤ ${PASSWORD_MAX_LEN} characters`,
191
208
  };
192
209
  }
193
- // `assigned_vault` is optional — omitted (undefined) or explicit null
194
- // both mean "no restriction (admin-level access)." Empty string is
195
- // rejected as a confused client send (would otherwise persist as ""
196
- // and never resolve in services.json).
197
- let assignedVault: string | null = null;
198
- if (Object.hasOwn(obj, "assignedVault")) {
199
- const v = obj.assignedVault;
200
- if (v === null) {
201
- assignedVault = null;
202
- } else if (typeof v === "string" && v.length > 0) {
203
- assignedVault = v;
204
- } else if (typeof v !== "undefined") {
205
- return {
206
- ok: false,
207
- status: 400,
208
- error: "invalid_request",
209
- description: '"assignedVault" must be a non-empty string or null',
210
- };
210
+ // `assigned_vaults` is optional — omitted, explicit null, or empty
211
+ // array all mean "no vault narrowing." Multi-user Phase 2 PR 2: the
212
+ // wire shape moved from `assigned_vault: string | null` (single name)
213
+ // to `assigned_vaults: string[]` (array). We accept both camelCase
214
+ // `assignedVaults` (current SPA send) and snake_case `assigned_vaults`
215
+ // (defensive — matches the response wire shape). Empty strings in the
216
+ // array are rejected; the validation against services.json runs in
217
+ // the handler.
218
+ let assignedVaults: string[] = [];
219
+ const rawVaults =
220
+ Object.hasOwn(obj, "assignedVaults") && obj.assignedVaults !== undefined
221
+ ? obj.assignedVaults
222
+ : Object.hasOwn(obj, "assigned_vaults") && obj.assigned_vaults !== undefined
223
+ ? obj.assigned_vaults
224
+ : undefined;
225
+ if (rawVaults === null || rawVaults === undefined) {
226
+ assignedVaults = [];
227
+ } else if (Array.isArray(rawVaults)) {
228
+ const result: string[] = [];
229
+ const seen = new Set<string>();
230
+ for (const v of rawVaults) {
231
+ if (typeof v !== "string" || v.length === 0) {
232
+ return {
233
+ ok: false,
234
+ status: 400,
235
+ error: "invalid_request",
236
+ description: '"assigned_vaults" must be an array of non-empty strings',
237
+ };
238
+ }
239
+ if (!seen.has(v)) {
240
+ seen.add(v);
241
+ result.push(v);
242
+ }
211
243
  }
244
+ assignedVaults = result;
245
+ } else {
246
+ return {
247
+ ok: false,
248
+ status: 400,
249
+ error: "invalid_request",
250
+ description: '"assigned_vaults" must be an array of strings (or omitted / null for none)',
251
+ };
212
252
  }
213
- return { ok: true, body: { username, password, assignedVault } };
253
+ return { ok: true, body: { username, password, assignedVaults } };
214
254
  }
215
255
 
216
256
  /** POST /api/users — create user. */
@@ -227,7 +267,7 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
227
267
  if (!parsed.ok) {
228
268
  return jsonError(parsed.status, parsed.error, parsed.description);
229
269
  }
230
- const { username, password, assignedVault } = parsed.body;
270
+ const { username, password, assignedVaults } = parsed.body;
231
271
 
232
272
  // PR 1's username validator — charset + length + reserved-word check.
233
273
  const u = validateUsername(username);
@@ -252,34 +292,34 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
252
292
  return jsonError(409, "username_taken", `username "${username}" is already in use`);
253
293
  }
254
294
 
255
- // Validate `assigned_vault` against the live services.json vault list.
256
- // A stale name (vault since removed) is rejected at create time per
257
- // design §security/`assigned_vault validation`. NULL means "no
258
- // restriction" and skips the check.
259
- if (assignedVault !== null) {
295
+ // Validate every `assigned_vaults` entry against the live services.json
296
+ // vault list. A stale name (vault since removed) is rejected at create
297
+ // time. Empty list = "no narrowing" and skips the manifest read.
298
+ if (assignedVaults.length > 0) {
260
299
  const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
261
300
  const known = new Set(listVaultNamesFromPath(manifestPath));
262
- if (!known.has(assignedVault)) {
301
+ const unknown = assignedVaults.filter((v) => !known.has(v));
302
+ if (unknown.length > 0) {
263
303
  return jsonError(
264
304
  400,
265
305
  "assigned_vault_not_found",
266
- `assigned_vault "${assignedVault}" is not registered in services.json`,
306
+ `assigned vault(s) ${unknown.map((n) => `"${n}"`).join(", ")} not registered in services.json`,
267
307
  );
268
308
  }
269
309
  }
270
310
 
271
311
  // Persist. The admin-created path lands `passwordChanged: false` so the
272
312
  // user gets force-redirected through `/account/change-password` on
273
- // first sign-in (PR 3). The wizard's first-admin path and the env-
274
- // seed path both set `passwordChanged: true` explicitly — neither of
275
- // those touches this endpoint. `allowMulti: true` because Phase 1 is
276
- // the whole point — `createUser`'s single-user guard would otherwise
277
- // 500 here once the first admin exists.
313
+ // first sign-in. The wizard's first-admin path and the env-seed path
314
+ // both set `passwordChanged: true` explicitly — neither touches this
315
+ // endpoint. `allowMulti: true` because multi-user is the whole point —
316
+ // `createUser`'s single-user guard would otherwise 500 once the first
317
+ // admin exists.
278
318
  try {
279
319
  const created = await createUser(deps.db, username, password, {
280
320
  allowMulti: true,
281
321
  passwordChanged: false,
282
- assignedVault,
322
+ assignedVaults,
283
323
  });
284
324
  return new Response(JSON.stringify({ user: toWire(created) }), {
285
325
  status: 201,
@@ -324,10 +364,13 @@ export async function handleDeleteUser(
324
364
  // by a state conflict — RFC 7231 §6.5.3 fits cleaner than §6.5.8.
325
365
  // Either is defensible; the wire `error` string is the part the SPA
326
366
  // matches on for the "first admin can't be deleted" surface).
327
- const firstAdminRow = deps.db
328
- .query<{ id: string }, []>("SELECT id FROM users ORDER BY created_at ASC LIMIT 1")
329
- .get();
330
- if (firstAdminRow && firstAdminRow.id === userId) {
367
+ //
368
+ // The `getFirstAdminId` helper (users.ts) is the single source of
369
+ // truth for "who is the admin" — same SELECT also gates the SPA
370
+ // bearer-mint endpoint (admin-host-admin-token.ts) and drives the
371
+ // non-admin login-redirect default.
372
+ const firstAdminId = getFirstAdminId(deps.db);
373
+ if (firstAdminId && firstAdminId === userId) {
331
374
  return jsonError(
332
375
  403,
333
376
  "first_admin_undeletable",
@@ -381,6 +424,376 @@ export async function handleListVaults(req: Request, deps: ApiUsersDeps): Promis
381
424
  });
382
425
  }
383
426
 
427
+ // ---------------------------------------------------------------------------
428
+ // PATCH /api/users/:id/vaults — replace a user's vault assignments
429
+ // ---------------------------------------------------------------------------
430
+
431
+ interface UpdateVaultsBody {
432
+ assigned_vaults: string[];
433
+ }
434
+
435
+ async function parseUpdateVaultsBody(
436
+ req: Request,
437
+ ): Promise<{ ok: true; body: UpdateVaultsBody } | ParseErr> {
438
+ const ctype = req.headers.get("content-type") ?? "";
439
+ if (!ctype.toLowerCase().includes("application/json")) {
440
+ return {
441
+ ok: false,
442
+ status: 400,
443
+ error: "invalid_request",
444
+ description: "Content-Type must be application/json",
445
+ };
446
+ }
447
+ let raw: unknown;
448
+ try {
449
+ raw = await req.json();
450
+ } catch (err) {
451
+ const msg = err instanceof Error ? err.message : String(err);
452
+ return {
453
+ ok: false,
454
+ status: 400,
455
+ error: "invalid_request",
456
+ description: `invalid JSON body: ${msg}`,
457
+ };
458
+ }
459
+ if (!raw || typeof raw !== "object") {
460
+ return {
461
+ ok: false,
462
+ status: 400,
463
+ error: "invalid_request",
464
+ description: "request body must be a JSON object",
465
+ };
466
+ }
467
+ const obj = raw as Record<string, unknown>;
468
+ // Accept both `assigned_vaults` (snake_case, primary) and
469
+ // `assignedVaults` (camelCase, defensive) — same shape as parseCreateBody.
470
+ const rawVaults =
471
+ Object.hasOwn(obj, "assigned_vaults") && obj.assigned_vaults !== undefined
472
+ ? obj.assigned_vaults
473
+ : Object.hasOwn(obj, "assignedVaults") && obj.assignedVaults !== undefined
474
+ ? obj.assignedVaults
475
+ : undefined;
476
+ if (rawVaults === undefined) {
477
+ return {
478
+ ok: false,
479
+ status: 400,
480
+ error: "invalid_request",
481
+ description: '"assigned_vaults" is required (array of strings; pass [] to clear)',
482
+ };
483
+ }
484
+ if (!Array.isArray(rawVaults)) {
485
+ return {
486
+ ok: false,
487
+ status: 400,
488
+ error: "invalid_request",
489
+ description: '"assigned_vaults" must be an array of strings',
490
+ };
491
+ }
492
+ const seen = new Set<string>();
493
+ const list: string[] = [];
494
+ for (const v of rawVaults) {
495
+ if (typeof v !== "string" || v.length === 0) {
496
+ return {
497
+ ok: false,
498
+ status: 400,
499
+ error: "invalid_request",
500
+ description: '"assigned_vaults" entries must be non-empty strings',
501
+ };
502
+ }
503
+ if (!seen.has(v)) {
504
+ seen.add(v);
505
+ list.push(v);
506
+ }
507
+ }
508
+ return { ok: true, body: { assigned_vaults: list } };
509
+ }
510
+
511
+ /**
512
+ * PATCH /api/users/:id/vaults — replace the user's vault assignments
513
+ * atomically (multi-user Phase 2 PR 2).
514
+ *
515
+ * Body: `{ "assigned_vaults": ["maya", "family"] }`. Pass `[]` to clear
516
+ * every assignment (the user retains their account but loses every per-
517
+ * vault grant — no narrowing for admins; "no access" for non-admins).
518
+ *
519
+ * Order of checks (mirrors `handleResetUserPassword`):
520
+ *
521
+ * 1. Method gate (405 on non-PATCH).
522
+ * 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
523
+ * 3. Parse body (400 on shape).
524
+ * 4. Target user exists (404 `not_found`).
525
+ * 5. Target is NOT the first admin (403 `cannot_edit_first_admin_vaults`)
526
+ * — admin posture is unrestricted by design (`isFirstAdmin`); the
527
+ * first admin's "vault membership" is implicit and shouldn't be
528
+ * mutated. Mirrors the first-admin-undeletable rail.
529
+ * 6. Every requested vault name is registered in services.json
530
+ * (400 `assigned_vault_not_found`).
531
+ * 7. `setUserVaults` — atomic DELETE+INSERT inside one transaction.
532
+ *
533
+ * Response on success: `200 { ok: true, user: <wire shape> }` with the
534
+ * updated `assigned_vaults` reflected.
535
+ */
536
+ export async function handleUpdateUserVaults(
537
+ req: Request,
538
+ userId: string,
539
+ deps: ApiUsersDeps,
540
+ ): Promise<Response> {
541
+ if (req.method !== "PATCH") {
542
+ return jsonError(405, "method_not_allowed", "use PATCH");
543
+ }
544
+ try {
545
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
546
+ } catch (err) {
547
+ return adminAuthErrorResponse(err as AdminAuthError);
548
+ }
549
+ const parsed = await parseUpdateVaultsBody(req);
550
+ if (!parsed.ok) {
551
+ return jsonError(parsed.status, parsed.error, parsed.description);
552
+ }
553
+ const target = getUserById(deps.db, userId);
554
+ if (!target) {
555
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
556
+ }
557
+ // First-admin protection — admin posture is "unrestricted" by design
558
+ // (`isFirstAdmin` short-circuits `vaultScopeForUser` to `[]`). Pinning
559
+ // the first admin to a vault list would muddy that semantic. The SPA
560
+ // disables this row's button as a UX hint; the server check is
561
+ // authoritative.
562
+ if (isFirstAdmin(deps.db, userId)) {
563
+ return jsonError(
564
+ 403,
565
+ "cannot_edit_first_admin_vaults",
566
+ "the first admin's vault membership is unrestricted by design — no vault list to edit",
567
+ );
568
+ }
569
+ // Validate every vault name against the live services.json list.
570
+ const assignedVaults = parsed.body.assigned_vaults;
571
+ if (assignedVaults.length > 0) {
572
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
573
+ const known = new Set(listVaultNamesFromPath(manifestPath));
574
+ const unknown = assignedVaults.filter((v) => !known.has(v));
575
+ if (unknown.length > 0) {
576
+ return jsonError(
577
+ 400,
578
+ "assigned_vault_not_found",
579
+ `assigned vault(s) ${unknown.map((n) => `"${n}"`).join(", ")} not registered in services.json`,
580
+ );
581
+ }
582
+ }
583
+ const ok = setUserVaults(deps.db, userId, assignedVaults);
584
+ if (!ok) {
585
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
586
+ }
587
+ console.log(
588
+ `user vaults updated: id=${userId} username=${target.username} vaults=${assignedVaults.join(",")}`,
589
+ );
590
+ const fresh = getUserById(deps.db, userId);
591
+ return new Response(JSON.stringify({ ok: true, user: fresh ? toWire(fresh) : null }), {
592
+ status: 200,
593
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
594
+ });
595
+ }
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // POST /api/users/:id/reset-password — admin-initiated password reset
599
+ // ---------------------------------------------------------------------------
600
+
601
+ interface ResetPasswordBody {
602
+ new_password: string;
603
+ }
604
+
605
+ async function parseResetPasswordBody(
606
+ req: Request,
607
+ ): Promise<{ ok: true; body: ResetPasswordBody } | ParseErr> {
608
+ const ctype = req.headers.get("content-type") ?? "";
609
+ if (!ctype.toLowerCase().includes("application/json")) {
610
+ return {
611
+ ok: false,
612
+ status: 400,
613
+ error: "invalid_request",
614
+ description: "Content-Type must be application/json",
615
+ };
616
+ }
617
+ let raw: unknown;
618
+ try {
619
+ raw = await req.json();
620
+ } catch (err) {
621
+ const msg = err instanceof Error ? err.message : String(err);
622
+ return {
623
+ ok: false,
624
+ status: 400,
625
+ error: "invalid_request",
626
+ description: `invalid JSON body: ${msg}`,
627
+ };
628
+ }
629
+ if (!raw || typeof raw !== "object") {
630
+ return {
631
+ ok: false,
632
+ status: 400,
633
+ error: "invalid_request",
634
+ description: "request body must be a JSON object",
635
+ };
636
+ }
637
+ const obj = raw as Record<string, unknown>;
638
+ const newPassword = obj.new_password;
639
+ if (typeof newPassword !== "string" || newPassword.length === 0) {
640
+ return {
641
+ ok: false,
642
+ status: 400,
643
+ error: "invalid_request",
644
+ description: '"new_password" must be a non-empty string',
645
+ };
646
+ }
647
+ // Same CPU-DoS cap as `parseCreateBody` — bound the payload BEFORE
648
+ // argon2id touches it. 413 (request entity too large) is the canonical
649
+ // RFC 7231 status for "body fits, but a specific field exceeds policy."
650
+ if (newPassword.length > PASSWORD_MAX_LEN) {
651
+ return {
652
+ ok: false,
653
+ status: 413,
654
+ error: "password_too_long",
655
+ description: `password length must be ≤ ${PASSWORD_MAX_LEN} characters`,
656
+ };
657
+ }
658
+ return { ok: true, body: { new_password: newPassword } };
659
+ }
660
+
661
+ /**
662
+ * Resource-server revocation-cache TTL surfaced in the reset-password
663
+ * response (smoke 2026-05-27, finding 3). Mirrors
664
+ * `REVOCATION_CACHE_TTL_MS = 60_000` in
665
+ * `packages/scope-guard/src/revocation-cache.ts`. Duplicated as a
666
+ * constant here (not imported) because hub never imports scope-guard
667
+ * — hub is the issuer + revocation-list publisher; scope-guard runs
668
+ * at resource servers (vault, scribe, etc.) on the validation side.
669
+ * Crossing that dependency boundary just to share a constant would
670
+ * invert the architecture. If the TTL ever changes, update both
671
+ * places (the scope-guard CHANGELOG entry pins the wire contract;
672
+ * this constant is the operator-facing surface).
673
+ */
674
+ export const REVOCATION_LAG_SECONDS = 60;
675
+
676
+ /**
677
+ * POST /api/users/:id/reset-password — admin sets a new temp password
678
+ * for a non-admin user. The user is force-redirected through
679
+ * `/account/change-password` on next sign-in (same rail as admin-created
680
+ * users), so the admin's chosen value is genuinely a "temporary one-
681
+ * time handoff" string rather than a long-lived password.
682
+ *
683
+ * Order of checks (mirrors `handleDeleteUser` for the first-admin gate
684
+ * and `handleCreateUser` for the parse / validate pipeline):
685
+ *
686
+ * 1. Method gate (405 on non-POST).
687
+ * 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
688
+ * 3. Parse + cap body (400 on shape, 413 on > PASSWORD_MAX_LEN).
689
+ * 4. Target user exists (404 `not_found`).
690
+ * 5. Target is NOT the first admin (403 `cannot_reset_first_admin`).
691
+ * Admin self-service uses `/account/change-password`; admin-reset
692
+ * is for friends only. Mirrors the first-admin-undeletable rail.
693
+ * 6. `validatePassword(new_password)` (400 `invalid_password`).
694
+ * 7. `resetUserPassword` — rotates hash, flips `password_changed=0`,
695
+ * revokes the user's still-active tokens, all in one tx.
696
+ *
697
+ * Response on success: `200 { ok: true, user: <wire shape>,
698
+ * revocation_lag_seconds: 60 }`. We deliberately don't echo the
699
+ * password — the admin already typed it and will hand it to the
700
+ * friend out-of-band (Signal, in-person — same as the create-user
701
+ * default-password flow).
702
+ *
703
+ * **Revocation propagation lag** (smoke 2026-05-27, finding 3):
704
+ * `resetUserPassword` marks tokens revoked in hub's DB immediately
705
+ * AND hub's `/.well-known/parachute-revocation.json` reflects the
706
+ * new revocation on the next fetch. BUT resource servers (vault,
707
+ * scribe, etc.) cache the revocation list via scope-guard's
708
+ * `REVOCATION_CACHE_TTL_MS = 60_000` — they may continue accepting
709
+ * the revoked token for up to 60 seconds after this call returns.
710
+ *
711
+ * - Friend-forgot-pw recovery path: fine. No adversary; the user
712
+ * re-authenticates and the lag is invisible.
713
+ * - Stolen-device / "kill the friend's tokens NOW" path: a
714
+ * meaningful exposure window. Operator should also restart the
715
+ * affected resource servers (`parachute restart vault`, etc.) to
716
+ * flush the cache immediately.
717
+ *
718
+ * The `revocation_lag_seconds` field in the response surfaces this
719
+ * to API clients (admin SPA's reset-password success banner) so
720
+ * the lag isn't a silent gotcha. The TTL is deliberate (network-
721
+ * cost tradeoff per the scope-guard CHANGELOG); changing it is a
722
+ * separate design question (cf. smoke 2026-05-27 Bug 3 mitigation
723
+ * option 2: inline cache-bust trigger).
724
+ */
725
+ export async function handleResetUserPassword(
726
+ req: Request,
727
+ userId: string,
728
+ deps: ApiUsersDeps,
729
+ ): Promise<Response> {
730
+ if (req.method !== "POST") {
731
+ return jsonError(405, "method_not_allowed", "use POST");
732
+ }
733
+ try {
734
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
735
+ } catch (err) {
736
+ return adminAuthErrorResponse(err as AdminAuthError);
737
+ }
738
+ const parsed = await parseResetPasswordBody(req);
739
+ if (!parsed.ok) {
740
+ return jsonError(parsed.status, parsed.error, parsed.description);
741
+ }
742
+ const target = getUserById(deps.db, userId);
743
+ if (!target) {
744
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
745
+ }
746
+ // First-admin protection. The earliest-created row is the wizard or
747
+ // env-seeded admin by construction (Phase 1 has no role model). Reset
748
+ // by admin would be a self-action — the admin should use the normal
749
+ // `/account/change-password` rotate flow instead, which requires
750
+ // knowing the current password (genuine credential rotation, not a
751
+ // recovery reset). Pairs with the first-admin-undeletable rail above.
752
+ if (isFirstAdmin(deps.db, userId)) {
753
+ return jsonError(
754
+ 403,
755
+ "cannot_reset_first_admin",
756
+ "the first admin must use /account/change-password directly — admin password reset is for friend accounts",
757
+ );
758
+ }
759
+ const validity = validatePassword(parsed.body.new_password);
760
+ if (!validity.valid) {
761
+ return jsonError(
762
+ 400,
763
+ "invalid_password",
764
+ "password must be at least 12 characters (passphrase-friendly; no complexity rules)",
765
+ );
766
+ }
767
+ // `resetUserPassword` is idempotent on a missing row — returns false
768
+ // when the target vanished between `getUserById` and this call. Same
769
+ // race-tolerant 404 as `handleDeleteUser` for that path.
770
+ const ok = await resetUserPassword(deps.db, userId, parsed.body.new_password);
771
+ if (!ok) {
772
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
773
+ }
774
+ console.log(`password reset by admin: id=${userId} username=${target.username}`);
775
+ // Re-read so the response carries the updated `password_changed=false`
776
+ // + bumped `updated_at`. Cheap (single SELECT). Saves the SPA a refetch
777
+ // to see the row's "pending first login" badge come back.
778
+ const fresh = getUserById(deps.db, userId);
779
+ // `revocation_lag_seconds`: smoke 2026-05-27 finding 3. Resource
780
+ // servers cache the revocation list for up to 60s; surface that so
781
+ // the SPA's success banner can warn operators in the
782
+ // stolen-device-recovery threat model. See REVOCATION_LAG_SECONDS
783
+ // doc + handler docstring above.
784
+ return new Response(
785
+ JSON.stringify({
786
+ ok: true,
787
+ user: fresh ? toWire(fresh) : null,
788
+ revocation_lag_seconds: REVOCATION_LAG_SECONDS,
789
+ }),
790
+ {
791
+ status: 200,
792
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
793
+ },
794
+ );
795
+ }
796
+
384
797
  function describeUsernameReason(reason: "format" | "length" | "reserved"): string {
385
798
  switch (reason) {
386
799
  case "length":
@@ -0,0 +1,55 @@
1
+ /**
2
+ * bun-link detection — shared helper used by both the CLI install path
3
+ * (`commands/install.ts`) and the API/wizard install path (`api-modules-ops.ts`).
4
+ *
5
+ * "Linked" means a global symlink shape under `~/.bun/install/global/node_modules/<pkg>`
6
+ * created by `bun link` (from a local checkout). When the package is already linked,
7
+ * `bun add -g <pkg>` is at best a wasted npm round-trip (~3s) and at worst a hard
8
+ * failure when the global bun.lock has unrelated noise — neither outcome is desirable
9
+ * given the linked checkout already provides the binary on PATH.
10
+ *
11
+ * Both install paths gate the `bun add -g` call on `isLinked(pkg) === false`.
12
+ * Centralizing the detection here keeps the CLI and wizard in lockstep — diverging
13
+ * (as the wizard did pre-hub#433) is the bug class this module exists to prevent.
14
+ */
15
+
16
+ import { lstatSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ /**
21
+ * The set of bun global-prefix locations to probe for a `<pkg>` symlink.
22
+ * Honors `BUN_INSTALL` (the canonical override) before falling back to the
23
+ * default `~/.bun` layout. Order matters — env-set prefix wins on a custom
24
+ * bun layout (containers, CI).
25
+ */
26
+ export function bunGlobalPrefixes(): string[] {
27
+ const prefixes: string[] = [];
28
+ const fromEnv = process.env.BUN_INSTALL;
29
+ if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
30
+ prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
31
+ return prefixes;
32
+ }
33
+
34
+ /**
35
+ * True iff `<pkg>` resolves to a symlink under any bun global prefix —
36
+ * i.e. the package was installed via `bun link` from a local checkout
37
+ * rather than `bun add -g` from npm. Used to short-circuit `bun add -g`
38
+ * in both the CLI and the wizard install paths.
39
+ *
40
+ * Scoped packages (`@openparachute/vault`) are split on `/` so the probe
41
+ * lands at `<prefix>/@openparachute/vault`. Non-symlink resolutions
42
+ * (real dir from `bun add -g`) return false — we only want to skip the
43
+ * `bun add -g` when the symlink-shape is in place.
44
+ */
45
+ export function isLinked(pkg: string): boolean {
46
+ for (const prefix of bunGlobalPrefixes()) {
47
+ const path = join(prefix, ...pkg.split("/"));
48
+ try {
49
+ if (lstatSync(path).isSymbolicLink()) return true;
50
+ } catch {
51
+ // Not present at this prefix; try the next.
52
+ }
53
+ }
54
+ return false;
55
+ }