@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/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,
@@ -296,7 +336,16 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
296
336
  }
297
337
  }
298
338
 
299
- /** DELETE /api/users/:id — hard-delete + token revocation + session/grant cleanup. */
339
+ /**
340
+ * DELETE /api/users/:id — hard-delete + token revocation + session/grant
341
+ * cleanup.
342
+ *
343
+ * Success returns `200 { ok: true, revocation_lag_seconds: 60 }` (was a bare
344
+ * 204 pre-consistency-fix) so the SPA can warn that the deleted user's
345
+ * tokens linger ~60s on resource-server revocation caches — same surface
346
+ * the reset-password path carries. The race-tolerant "row already gone"
347
+ * path stays a bodyless 204 (nothing was revoked here, no lag to report).
348
+ */
300
349
  export async function handleDeleteUser(
301
350
  req: Request,
302
351
  userId: string,
@@ -324,10 +373,13 @@ export async function handleDeleteUser(
324
373
  // by a state conflict — RFC 7231 §6.5.3 fits cleaner than §6.5.8.
325
374
  // Either is defensible; the wire `error` string is the part the SPA
326
375
  // 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) {
376
+ //
377
+ // The `getFirstAdminId` helper (users.ts) is the single source of
378
+ // truth for "who is the admin" — same SELECT also gates the SPA
379
+ // bearer-mint endpoint (admin-host-admin-token.ts) and drives the
380
+ // non-admin login-redirect default.
381
+ const firstAdminId = getFirstAdminId(deps.db);
382
+ if (firstAdminId && firstAdminId === userId) {
331
383
  return jsonError(
332
384
  403,
333
385
  "first_admin_undeletable",
@@ -347,11 +399,28 @@ export async function handleDeleteUser(
347
399
  if (!removed) {
348
400
  // Race: row deleted by a concurrent request. Operator's intent
349
401
  // (no such user) is already satisfied — same shape as the grant-
350
- // revoke race in `admin-grants.ts`.
402
+ // revoke race in `admin-grants.ts`. No tokens were revoked by THIS
403
+ // call, so there's no revocation lag to warn about; keep the bodyless
404
+ // 204 for the race path.
351
405
  return new Response(null, { status: 204 });
352
406
  }
353
407
  console.log(`user deleted: id=${userId} username=${target.username}`);
354
- return new Response(null, { status: 204 });
408
+ // `revocation_lag_seconds`: same consistency fix the reset-password path
409
+ // got (smoke 2026-05-27 finding 3). Deleting a user revokes their tokens
410
+ // in hub's DB immediately, but resource servers (vault, scribe, …) cache
411
+ // the revocation list via scope-guard's `REVOCATION_CACHE_TTL_MS = 60_000`
412
+ // — a deleted user's tokens linger for up to ~60s on those caches. Surface
413
+ // that so the admin isn't surprised when a just-deleted user's client can
414
+ // still read for a minute (relevant in the stolen-device / compromise
415
+ // threat model). 200 + body instead of the old bare 204 so the SPA can
416
+ // render the warning banner.
417
+ return new Response(
418
+ JSON.stringify({ ok: true, revocation_lag_seconds: REVOCATION_LAG_SECONDS }),
419
+ {
420
+ status: 200,
421
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
422
+ },
423
+ );
355
424
  }
356
425
 
357
426
  /**
@@ -381,6 +450,376 @@ export async function handleListVaults(req: Request, deps: ApiUsersDeps): Promis
381
450
  });
382
451
  }
383
452
 
453
+ // ---------------------------------------------------------------------------
454
+ // PATCH /api/users/:id/vaults — replace a user's vault assignments
455
+ // ---------------------------------------------------------------------------
456
+
457
+ interface UpdateVaultsBody {
458
+ assigned_vaults: string[];
459
+ }
460
+
461
+ async function parseUpdateVaultsBody(
462
+ req: Request,
463
+ ): Promise<{ ok: true; body: UpdateVaultsBody } | ParseErr> {
464
+ const ctype = req.headers.get("content-type") ?? "";
465
+ if (!ctype.toLowerCase().includes("application/json")) {
466
+ return {
467
+ ok: false,
468
+ status: 400,
469
+ error: "invalid_request",
470
+ description: "Content-Type must be application/json",
471
+ };
472
+ }
473
+ let raw: unknown;
474
+ try {
475
+ raw = await req.json();
476
+ } catch (err) {
477
+ const msg = err instanceof Error ? err.message : String(err);
478
+ return {
479
+ ok: false,
480
+ status: 400,
481
+ error: "invalid_request",
482
+ description: `invalid JSON body: ${msg}`,
483
+ };
484
+ }
485
+ if (!raw || typeof raw !== "object") {
486
+ return {
487
+ ok: false,
488
+ status: 400,
489
+ error: "invalid_request",
490
+ description: "request body must be a JSON object",
491
+ };
492
+ }
493
+ const obj = raw as Record<string, unknown>;
494
+ // Accept both `assigned_vaults` (snake_case, primary) and
495
+ // `assignedVaults` (camelCase, defensive) — same shape as parseCreateBody.
496
+ const rawVaults =
497
+ Object.hasOwn(obj, "assigned_vaults") && obj.assigned_vaults !== undefined
498
+ ? obj.assigned_vaults
499
+ : Object.hasOwn(obj, "assignedVaults") && obj.assignedVaults !== undefined
500
+ ? obj.assignedVaults
501
+ : undefined;
502
+ if (rawVaults === undefined) {
503
+ return {
504
+ ok: false,
505
+ status: 400,
506
+ error: "invalid_request",
507
+ description: '"assigned_vaults" is required (array of strings; pass [] to clear)',
508
+ };
509
+ }
510
+ if (!Array.isArray(rawVaults)) {
511
+ return {
512
+ ok: false,
513
+ status: 400,
514
+ error: "invalid_request",
515
+ description: '"assigned_vaults" must be an array of strings',
516
+ };
517
+ }
518
+ const seen = new Set<string>();
519
+ const list: string[] = [];
520
+ for (const v of rawVaults) {
521
+ if (typeof v !== "string" || v.length === 0) {
522
+ return {
523
+ ok: false,
524
+ status: 400,
525
+ error: "invalid_request",
526
+ description: '"assigned_vaults" entries must be non-empty strings',
527
+ };
528
+ }
529
+ if (!seen.has(v)) {
530
+ seen.add(v);
531
+ list.push(v);
532
+ }
533
+ }
534
+ return { ok: true, body: { assigned_vaults: list } };
535
+ }
536
+
537
+ /**
538
+ * PATCH /api/users/:id/vaults — replace the user's vault assignments
539
+ * atomically (multi-user Phase 2 PR 2).
540
+ *
541
+ * Body: `{ "assigned_vaults": ["maya", "family"] }`. Pass `[]` to clear
542
+ * every assignment (the user retains their account but loses every per-
543
+ * vault grant — no narrowing for admins; "no access" for non-admins).
544
+ *
545
+ * Order of checks (mirrors `handleResetUserPassword`):
546
+ *
547
+ * 1. Method gate (405 on non-PATCH).
548
+ * 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
549
+ * 3. Parse body (400 on shape).
550
+ * 4. Target user exists (404 `not_found`).
551
+ * 5. Target is NOT the first admin (403 `cannot_edit_first_admin_vaults`)
552
+ * — admin posture is unrestricted by design (`isFirstAdmin`); the
553
+ * first admin's "vault membership" is implicit and shouldn't be
554
+ * mutated. Mirrors the first-admin-undeletable rail.
555
+ * 6. Every requested vault name is registered in services.json
556
+ * (400 `assigned_vault_not_found`).
557
+ * 7. `setUserVaults` — atomic DELETE+INSERT inside one transaction.
558
+ *
559
+ * Response on success: `200 { ok: true, user: <wire shape> }` with the
560
+ * updated `assigned_vaults` reflected.
561
+ */
562
+ export async function handleUpdateUserVaults(
563
+ req: Request,
564
+ userId: string,
565
+ deps: ApiUsersDeps,
566
+ ): Promise<Response> {
567
+ if (req.method !== "PATCH") {
568
+ return jsonError(405, "method_not_allowed", "use PATCH");
569
+ }
570
+ try {
571
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
572
+ } catch (err) {
573
+ return adminAuthErrorResponse(err as AdminAuthError);
574
+ }
575
+ const parsed = await parseUpdateVaultsBody(req);
576
+ if (!parsed.ok) {
577
+ return jsonError(parsed.status, parsed.error, parsed.description);
578
+ }
579
+ const target = getUserById(deps.db, userId);
580
+ if (!target) {
581
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
582
+ }
583
+ // First-admin protection — admin posture is "unrestricted" by design
584
+ // (`isFirstAdmin` short-circuits `vaultScopeForUser` to `[]`). Pinning
585
+ // the first admin to a vault list would muddy that semantic. The SPA
586
+ // disables this row's button as a UX hint; the server check is
587
+ // authoritative.
588
+ if (isFirstAdmin(deps.db, userId)) {
589
+ return jsonError(
590
+ 403,
591
+ "cannot_edit_first_admin_vaults",
592
+ "the first admin's vault membership is unrestricted by design — no vault list to edit",
593
+ );
594
+ }
595
+ // Validate every vault name against the live services.json list.
596
+ const assignedVaults = parsed.body.assigned_vaults;
597
+ if (assignedVaults.length > 0) {
598
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
599
+ const known = new Set(listVaultNamesFromPath(manifestPath));
600
+ const unknown = assignedVaults.filter((v) => !known.has(v));
601
+ if (unknown.length > 0) {
602
+ return jsonError(
603
+ 400,
604
+ "assigned_vault_not_found",
605
+ `assigned vault(s) ${unknown.map((n) => `"${n}"`).join(", ")} not registered in services.json`,
606
+ );
607
+ }
608
+ }
609
+ const ok = setUserVaults(deps.db, userId, assignedVaults);
610
+ if (!ok) {
611
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
612
+ }
613
+ console.log(
614
+ `user vaults updated: id=${userId} username=${target.username} vaults=${assignedVaults.join(",")}`,
615
+ );
616
+ const fresh = getUserById(deps.db, userId);
617
+ return new Response(JSON.stringify({ ok: true, user: fresh ? toWire(fresh) : null }), {
618
+ status: 200,
619
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
620
+ });
621
+ }
622
+
623
+ // ---------------------------------------------------------------------------
624
+ // POST /api/users/:id/reset-password — admin-initiated password reset
625
+ // ---------------------------------------------------------------------------
626
+
627
+ interface ResetPasswordBody {
628
+ new_password: string;
629
+ }
630
+
631
+ async function parseResetPasswordBody(
632
+ req: Request,
633
+ ): Promise<{ ok: true; body: ResetPasswordBody } | ParseErr> {
634
+ const ctype = req.headers.get("content-type") ?? "";
635
+ if (!ctype.toLowerCase().includes("application/json")) {
636
+ return {
637
+ ok: false,
638
+ status: 400,
639
+ error: "invalid_request",
640
+ description: "Content-Type must be application/json",
641
+ };
642
+ }
643
+ let raw: unknown;
644
+ try {
645
+ raw = await req.json();
646
+ } catch (err) {
647
+ const msg = err instanceof Error ? err.message : String(err);
648
+ return {
649
+ ok: false,
650
+ status: 400,
651
+ error: "invalid_request",
652
+ description: `invalid JSON body: ${msg}`,
653
+ };
654
+ }
655
+ if (!raw || typeof raw !== "object") {
656
+ return {
657
+ ok: false,
658
+ status: 400,
659
+ error: "invalid_request",
660
+ description: "request body must be a JSON object",
661
+ };
662
+ }
663
+ const obj = raw as Record<string, unknown>;
664
+ const newPassword = obj.new_password;
665
+ if (typeof newPassword !== "string" || newPassword.length === 0) {
666
+ return {
667
+ ok: false,
668
+ status: 400,
669
+ error: "invalid_request",
670
+ description: '"new_password" must be a non-empty string',
671
+ };
672
+ }
673
+ // Same CPU-DoS cap as `parseCreateBody` — bound the payload BEFORE
674
+ // argon2id touches it. 413 (request entity too large) is the canonical
675
+ // RFC 7231 status for "body fits, but a specific field exceeds policy."
676
+ if (newPassword.length > PASSWORD_MAX_LEN) {
677
+ return {
678
+ ok: false,
679
+ status: 413,
680
+ error: "password_too_long",
681
+ description: `password length must be ≤ ${PASSWORD_MAX_LEN} characters`,
682
+ };
683
+ }
684
+ return { ok: true, body: { new_password: newPassword } };
685
+ }
686
+
687
+ /**
688
+ * Resource-server revocation-cache TTL surfaced in the reset-password
689
+ * response (smoke 2026-05-27, finding 3). Mirrors
690
+ * `REVOCATION_CACHE_TTL_MS = 60_000` in
691
+ * `packages/scope-guard/src/revocation-cache.ts`. Duplicated as a
692
+ * constant here (not imported) because hub never imports scope-guard
693
+ * — hub is the issuer + revocation-list publisher; scope-guard runs
694
+ * at resource servers (vault, scribe, etc.) on the validation side.
695
+ * Crossing that dependency boundary just to share a constant would
696
+ * invert the architecture. If the TTL ever changes, update both
697
+ * places (the scope-guard CHANGELOG entry pins the wire contract;
698
+ * this constant is the operator-facing surface).
699
+ */
700
+ export const REVOCATION_LAG_SECONDS = 60;
701
+
702
+ /**
703
+ * POST /api/users/:id/reset-password — admin sets a new temp password
704
+ * for a non-admin user. The user is force-redirected through
705
+ * `/account/change-password` on next sign-in (same rail as admin-created
706
+ * users), so the admin's chosen value is genuinely a "temporary one-
707
+ * time handoff" string rather than a long-lived password.
708
+ *
709
+ * Order of checks (mirrors `handleDeleteUser` for the first-admin gate
710
+ * and `handleCreateUser` for the parse / validate pipeline):
711
+ *
712
+ * 1. Method gate (405 on non-POST).
713
+ * 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
714
+ * 3. Parse + cap body (400 on shape, 413 on > PASSWORD_MAX_LEN).
715
+ * 4. Target user exists (404 `not_found`).
716
+ * 5. Target is NOT the first admin (403 `cannot_reset_first_admin`).
717
+ * Admin self-service uses `/account/change-password`; admin-reset
718
+ * is for friends only. Mirrors the first-admin-undeletable rail.
719
+ * 6. `validatePassword(new_password)` (400 `invalid_password`).
720
+ * 7. `resetUserPassword` — rotates hash, flips `password_changed=0`,
721
+ * revokes the user's still-active tokens, all in one tx.
722
+ *
723
+ * Response on success: `200 { ok: true, user: <wire shape>,
724
+ * revocation_lag_seconds: 60 }`. We deliberately don't echo the
725
+ * password — the admin already typed it and will hand it to the
726
+ * friend out-of-band (Signal, in-person — same as the create-user
727
+ * default-password flow).
728
+ *
729
+ * **Revocation propagation lag** (smoke 2026-05-27, finding 3):
730
+ * `resetUserPassword` marks tokens revoked in hub's DB immediately
731
+ * AND hub's `/.well-known/parachute-revocation.json` reflects the
732
+ * new revocation on the next fetch. BUT resource servers (vault,
733
+ * scribe, etc.) cache the revocation list via scope-guard's
734
+ * `REVOCATION_CACHE_TTL_MS = 60_000` — they may continue accepting
735
+ * the revoked token for up to 60 seconds after this call returns.
736
+ *
737
+ * - Friend-forgot-pw recovery path: fine. No adversary; the user
738
+ * re-authenticates and the lag is invisible.
739
+ * - Stolen-device / "kill the friend's tokens NOW" path: a
740
+ * meaningful exposure window. Operator should also restart the
741
+ * affected resource servers (`parachute restart vault`, etc.) to
742
+ * flush the cache immediately.
743
+ *
744
+ * The `revocation_lag_seconds` field in the response surfaces this
745
+ * to API clients (admin SPA's reset-password success banner) so
746
+ * the lag isn't a silent gotcha. The TTL is deliberate (network-
747
+ * cost tradeoff per the scope-guard CHANGELOG); changing it is a
748
+ * separate design question (cf. smoke 2026-05-27 Bug 3 mitigation
749
+ * option 2: inline cache-bust trigger).
750
+ */
751
+ export async function handleResetUserPassword(
752
+ req: Request,
753
+ userId: string,
754
+ deps: ApiUsersDeps,
755
+ ): Promise<Response> {
756
+ if (req.method !== "POST") {
757
+ return jsonError(405, "method_not_allowed", "use POST");
758
+ }
759
+ try {
760
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
761
+ } catch (err) {
762
+ return adminAuthErrorResponse(err as AdminAuthError);
763
+ }
764
+ const parsed = await parseResetPasswordBody(req);
765
+ if (!parsed.ok) {
766
+ return jsonError(parsed.status, parsed.error, parsed.description);
767
+ }
768
+ const target = getUserById(deps.db, userId);
769
+ if (!target) {
770
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
771
+ }
772
+ // First-admin protection. The earliest-created row is the wizard or
773
+ // env-seeded admin by construction (Phase 1 has no role model). Reset
774
+ // by admin would be a self-action — the admin should use the normal
775
+ // `/account/change-password` rotate flow instead, which requires
776
+ // knowing the current password (genuine credential rotation, not a
777
+ // recovery reset). Pairs with the first-admin-undeletable rail above.
778
+ if (isFirstAdmin(deps.db, userId)) {
779
+ return jsonError(
780
+ 403,
781
+ "cannot_reset_first_admin",
782
+ "the first admin must use /account/change-password directly — admin password reset is for friend accounts",
783
+ );
784
+ }
785
+ const validity = validatePassword(parsed.body.new_password);
786
+ if (!validity.valid) {
787
+ return jsonError(
788
+ 400,
789
+ "invalid_password",
790
+ "password must be at least 12 characters (passphrase-friendly; no complexity rules)",
791
+ );
792
+ }
793
+ // `resetUserPassword` is idempotent on a missing row — returns false
794
+ // when the target vanished between `getUserById` and this call. Same
795
+ // race-tolerant 404 as `handleDeleteUser` for that path.
796
+ const ok = await resetUserPassword(deps.db, userId, parsed.body.new_password);
797
+ if (!ok) {
798
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
799
+ }
800
+ console.log(`password reset by admin: id=${userId} username=${target.username}`);
801
+ // Re-read so the response carries the updated `password_changed=false`
802
+ // + bumped `updated_at`. Cheap (single SELECT). Saves the SPA a refetch
803
+ // to see the row's "pending first login" badge come back.
804
+ const fresh = getUserById(deps.db, userId);
805
+ // `revocation_lag_seconds`: smoke 2026-05-27 finding 3. Resource
806
+ // servers cache the revocation list for up to 60s; surface that so
807
+ // the SPA's success banner can warn operators in the
808
+ // stolen-device-recovery threat model. See REVOCATION_LAG_SECONDS
809
+ // doc + handler docstring above.
810
+ return new Response(
811
+ JSON.stringify({
812
+ ok: true,
813
+ user: fresh ? toWire(fresh) : null,
814
+ revocation_lag_seconds: REVOCATION_LAG_SECONDS,
815
+ }),
816
+ {
817
+ status: 200,
818
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
819
+ },
820
+ );
821
+ }
822
+
384
823
  function describeUsernameReason(reason: "format" | "length" | "reserved"): string {
385
824
  switch (reason) {
386
825
  case "length":