@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.
- package/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -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__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- 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__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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
|
|
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
|
|
8
|
-
*
|
|
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
|
|
13
|
-
* POST /api/users
|
|
14
|
-
* DELETE /api/users/:id
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
66
|
-
*
|
|
67
|
-
* `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// `
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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,
|
|
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,
|
|
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 `
|
|
256
|
-
// A stale name (vault since removed) is rejected at create
|
|
257
|
-
//
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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":
|