@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.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- 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__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
- package/web/ui/dist/index.html +1 -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,
|
|
@@ -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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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":
|
package/src/bun-link.ts
ADDED
|
@@ -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
|
+
}
|