@openparachute/hub 0.5.10-rc.6 → 0.5.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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +139 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-server.test.ts +29 -4
  10. package/src/__tests__/hub-settings.test.ts +377 -0
  11. package/src/__tests__/hub.test.ts +17 -0
  12. package/src/__tests__/jwt-sign.test.ts +59 -0
  13. package/src/__tests__/oauth-handlers.test.ts +1059 -10
  14. package/src/__tests__/oauth-ui.test.ts +210 -0
  15. package/src/__tests__/scope-explanations.test.ts +23 -0
  16. package/src/__tests__/serve.test.ts +8 -1
  17. package/src/__tests__/setup-wizard.test.ts +1500 -13
  18. package/src/__tests__/supervisor.test.ts +76 -2
  19. package/src/__tests__/users.test.ts +196 -0
  20. package/src/__tests__/vault-name.test.ts +79 -0
  21. package/src/__tests__/vault-names.test.ts +172 -0
  22. package/src/account-change-password-ui.ts +379 -0
  23. package/src/admin-handlers.ts +68 -2
  24. package/src/admin-host-admin-token.ts +5 -0
  25. package/src/admin-vault-admin-token.ts +7 -0
  26. package/src/api-account.ts +443 -0
  27. package/src/api-mint-token.ts +6 -0
  28. package/src/api-modules-ops.ts +30 -6
  29. package/src/api-modules.ts +101 -0
  30. package/src/api-users.ts +393 -0
  31. package/src/commands/auth.ts +10 -1
  32. package/src/commands/serve.ts +5 -1
  33. package/src/cors.ts +263 -0
  34. package/src/hub-db.ts +54 -0
  35. package/src/hub-server.ts +162 -18
  36. package/src/hub-settings.ts +259 -0
  37. package/src/hub.ts +34 -9
  38. package/src/jwt-sign.ts +17 -1
  39. package/src/oauth-handlers.ts +256 -29
  40. package/src/oauth-ui.ts +451 -38
  41. package/src/operator-token.ts +4 -0
  42. package/src/scope-explanations.ts +26 -1
  43. package/src/setup-wizard.ts +1100 -56
  44. package/src/supervisor.ts +66 -14
  45. package/src/users.ts +210 -3
  46. package/src/vault-name.ts +71 -0
  47. package/src/vault-names.ts +57 -0
  48. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  49. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  50. package/web/ui/dist/index.html +2 -2
  51. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
@@ -0,0 +1,393 @@
1
+ /**
2
+ * `/api/users*` — admin endpoints for managing hub user accounts.
3
+ *
4
+ * Multi-user Phase 1, PR 2 of 5. Design:
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.
9
+ *
10
+ * Surfaces:
11
+ *
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)
17
+ *
18
+ * Wire shape is snake_case (matches `/api/grants`, `/api/auth/tokens`).
19
+ * Responses never include `password_hash` — hashes never leave the DB.
20
+ *
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.
24
+ *
25
+ * Auth: every endpoint requires a bearer token carrying the
26
+ * `parachute:host:admin` scope. Same gate as `/api/grants`, `/vaults`,
27
+ * and the destructive `/api/modules/:short/*` actions. The SPA mints
28
+ * one via `/admin/host-admin-token` from the session cookie; the SPA's
29
+ * `lib/auth.ts` caches it in module-scoped memory (never `localStorage`).
30
+ *
31
+ * First-admin-undeletable: enforced server-side via
32
+ * `SELECT id FROM users ORDER BY created_at ASC LIMIT 1`. Per design §7
33
+ * the safety rail is absolute — Phase 1 has no role model, so the
34
+ * first-created admin is *the* admin by construction. A malicious or
35
+ * buggy SPA bypassing the row-level disabled-button can't get past
36
+ * the API check.
37
+ */
38
+ import type { Database } from "bun:sqlite";
39
+ import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
40
+ import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
41
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
42
+ import {
43
+ PASSWORD_MAX_LEN,
44
+ type User,
45
+ UsernameTakenError,
46
+ createUser,
47
+ deleteUser,
48
+ getUserById,
49
+ getUserByUsernameCI,
50
+ listUsers,
51
+ validatePassword,
52
+ validateUsername,
53
+ } from "./users.ts";
54
+ import { listVaultNamesFromPath } from "./vault-names.ts";
55
+
56
+ export interface ApiUsersDeps {
57
+ db: Database;
58
+ /** Hub origin — JWT `iss` validation. */
59
+ issuer: string;
60
+ /** Override services.json path. Defaults to `~/.parachute/services.json`. */
61
+ manifestPath?: string;
62
+ }
63
+
64
+ /**
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**
68
+ * — it's the one column that must not leak.
69
+ */
70
+ export interface UserWireShape {
71
+ id: string;
72
+ username: string;
73
+ password_changed: boolean;
74
+ assigned_vault: string | null;
75
+ created_at: string;
76
+ }
77
+
78
+ function toWire(u: User): UserWireShape {
79
+ return {
80
+ id: u.id,
81
+ username: u.username,
82
+ password_changed: u.passwordChanged,
83
+ assigned_vault: u.assignedVault,
84
+ created_at: u.createdAt,
85
+ };
86
+ }
87
+
88
+ function jsonError(status: number, error: string, description: string): Response {
89
+ return new Response(JSON.stringify({ error, error_description: description }), {
90
+ status,
91
+ headers: { "content-type": "application/json" },
92
+ });
93
+ }
94
+
95
+ /** GET /api/users — list users, ordered by `created_at ASC`. */
96
+ export async function handleListUsers(req: Request, deps: ApiUsersDeps): Promise<Response> {
97
+ if (req.method !== "GET") {
98
+ return jsonError(405, "method_not_allowed", "use GET");
99
+ }
100
+ try {
101
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
102
+ } catch (err) {
103
+ return adminAuthErrorResponse(err as AdminAuthError);
104
+ }
105
+ const users = listUsers(deps.db).map(toWire);
106
+ return new Response(JSON.stringify({ users }), {
107
+ status: 200,
108
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
109
+ });
110
+ }
111
+
112
+ interface CreateUserBody {
113
+ username: string;
114
+ password: string;
115
+ assignedVault: string | null;
116
+ }
117
+
118
+ interface ParseOk {
119
+ ok: true;
120
+ body: CreateUserBody;
121
+ }
122
+ interface ParseErr {
123
+ ok: false;
124
+ status: number;
125
+ error: string;
126
+ description: string;
127
+ }
128
+
129
+ async function parseCreateBody(req: Request): Promise<ParseOk | ParseErr> {
130
+ const ctype = req.headers.get("content-type") ?? "";
131
+ if (!ctype.toLowerCase().includes("application/json")) {
132
+ return {
133
+ ok: false,
134
+ status: 400,
135
+ error: "invalid_request",
136
+ description: "Content-Type must be application/json",
137
+ };
138
+ }
139
+ let raw: unknown;
140
+ try {
141
+ raw = await req.json();
142
+ } catch (err) {
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ return {
145
+ ok: false,
146
+ status: 400,
147
+ error: "invalid_request",
148
+ description: `invalid JSON body: ${msg}`,
149
+ };
150
+ }
151
+ if (!raw || typeof raw !== "object") {
152
+ return {
153
+ ok: false,
154
+ status: 400,
155
+ error: "invalid_request",
156
+ description: "request body must be a JSON object",
157
+ };
158
+ }
159
+ const obj = raw as Record<string, unknown>;
160
+ const username = obj.username;
161
+ if (typeof username !== "string" || username.length === 0) {
162
+ return {
163
+ ok: false,
164
+ status: 400,
165
+ error: "invalid_request",
166
+ description: '"username" must be a non-empty string',
167
+ };
168
+ }
169
+ const password = obj.password;
170
+ if (typeof password !== "string" || password.length === 0) {
171
+ return {
172
+ ok: false,
173
+ status: 400,
174
+ error: "invalid_request",
175
+ description: '"password" must be a non-empty string',
176
+ };
177
+ }
178
+ // Cap incoming password length BEFORE any validator or argon2id touches
179
+ // it. Argon2id over an arbitrarily-large body is a CPU-DoS shape: a
180
+ // 1MB password would burn ~seconds of single-thread time. The
181
+ // `PASSWORD_MAX_LEN` const from PR 1 (256 chars) is comfortably above
182
+ // any human passphrase (Diceware 8-word is ~55 chars). 413 is the
183
+ // canonical RFC 7231 status for "request entity too large" — the
184
+ // body itself is in-bounds but a specific field exceeds policy.
185
+ if (password.length > PASSWORD_MAX_LEN) {
186
+ return {
187
+ ok: false,
188
+ status: 413,
189
+ error: "password_too_long",
190
+ description: `password length must be ≤ ${PASSWORD_MAX_LEN} characters`,
191
+ };
192
+ }
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
+ };
211
+ }
212
+ }
213
+ return { ok: true, body: { username, password, assignedVault } };
214
+ }
215
+
216
+ /** POST /api/users — create user. */
217
+ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promise<Response> {
218
+ if (req.method !== "POST") {
219
+ return jsonError(405, "method_not_allowed", "use POST");
220
+ }
221
+ try {
222
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
223
+ } catch (err) {
224
+ return adminAuthErrorResponse(err as AdminAuthError);
225
+ }
226
+ const parsed = await parseCreateBody(req);
227
+ if (!parsed.ok) {
228
+ return jsonError(parsed.status, parsed.error, parsed.description);
229
+ }
230
+ const { username, password, assignedVault } = parsed.body;
231
+
232
+ // PR 1's username validator — charset + length + reserved-word check.
233
+ const u = validateUsername(username);
234
+ if (!u.valid) {
235
+ const description = describeUsernameReason(u.reason);
236
+ return jsonError(400, "invalid_username", description);
237
+ }
238
+ // PR 1's password validator — 12-char floor only.
239
+ const p = validatePassword(password);
240
+ if (!p.valid) {
241
+ return jsonError(
242
+ 400,
243
+ "invalid_password",
244
+ "password must be at least 12 characters (passphrase-friendly; no complexity rules)",
245
+ );
246
+ }
247
+
248
+ // Case-insensitive uniqueness check — the validator pins lowercase
249
+ // for new inputs, but a legacy mixed-case row in the DB shouldn't be
250
+ // shadowed by an accidental same-letters-different-case new user.
251
+ if (getUserByUsernameCI(deps.db, username) !== null) {
252
+ return jsonError(409, "username_taken", `username "${username}" is already in use`);
253
+ }
254
+
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) {
260
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
261
+ const known = new Set(listVaultNamesFromPath(manifestPath));
262
+ if (!known.has(assignedVault)) {
263
+ return jsonError(
264
+ 400,
265
+ "assigned_vault_not_found",
266
+ `assigned_vault "${assignedVault}" is not registered in services.json`,
267
+ );
268
+ }
269
+ }
270
+
271
+ // Persist. The admin-created path lands `passwordChanged: false` so the
272
+ // 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.
278
+ try {
279
+ const created = await createUser(deps.db, username, password, {
280
+ allowMulti: true,
281
+ passwordChanged: false,
282
+ assignedVault,
283
+ });
284
+ return new Response(JSON.stringify({ user: toWire(created) }), {
285
+ status: 201,
286
+ headers: { "content-type": "application/json" },
287
+ });
288
+ } catch (err) {
289
+ // Race: another POST landed between our CI check and createUser's
290
+ // INSERT and snagged the username. Surface as the same 409.
291
+ if (err instanceof UsernameTakenError) {
292
+ return jsonError(409, "username_taken", err.message);
293
+ }
294
+ const msg = err instanceof Error ? err.message : String(err);
295
+ return jsonError(500, "server_error", `failed to create user: ${msg}`);
296
+ }
297
+ }
298
+
299
+ /** DELETE /api/users/:id — hard-delete + token revocation + session/grant cleanup. */
300
+ export async function handleDeleteUser(
301
+ req: Request,
302
+ userId: string,
303
+ deps: ApiUsersDeps,
304
+ ): Promise<Response> {
305
+ if (req.method !== "DELETE") {
306
+ return jsonError(405, "method_not_allowed", "use DELETE");
307
+ }
308
+ try {
309
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
310
+ } catch (err) {
311
+ return adminAuthErrorResponse(err as AdminAuthError);
312
+ }
313
+ const target = getUserById(deps.db, userId);
314
+ if (!target) {
315
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
316
+ }
317
+
318
+ // First-admin-undeletable. The earliest-created row is the wizard or
319
+ // env-seeded admin by construction — Phase 1 has no role model, so
320
+ // the first admin is *the* admin. Deleting them would self-lock the
321
+ // hub. Per design §7 the API returns 403 with `first_admin_undeletable`
322
+ // (the design doc says 409; aligning to 403 here because the resource
323
+ // exists and the request is forbidden by policy rather than blocked
324
+ // by a state conflict — RFC 7231 §6.5.3 fits cleaner than §6.5.8.
325
+ // Either is defensible; the wire `error` string is the part the SPA
326
+ // 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) {
331
+ return jsonError(
332
+ 403,
333
+ "first_admin_undeletable",
334
+ "the first-created admin cannot be deleted (would self-lock the hub)",
335
+ );
336
+ }
337
+
338
+ // `deleteUser` (users.ts) atomically revokes the user's tokens
339
+ // (`tokens.revoked_at = now`, then NULLs `user_id` so the FK doesn't
340
+ // block the parent delete; backfills `subject` with the username so
341
+ // the audit trail isn't anchored to a vanished primary key), drops
342
+ // their sessions + grants (both have non-cascading FKs on user_id),
343
+ // and finally deletes the users row. Idempotent — false return path
344
+ // happens only if the row vanished between `getUserById` and this
345
+ // call, which we treat as a race-tolerant 204.
346
+ const removed = deleteUser(deps.db, userId);
347
+ if (!removed) {
348
+ // Race: row deleted by a concurrent request. Operator's intent
349
+ // (no such user) is already satisfied — same shape as the grant-
350
+ // revoke race in `admin-grants.ts`.
351
+ return new Response(null, { status: 204 });
352
+ }
353
+ console.log(`user deleted: id=${userId} username=${target.username}`);
354
+ return new Response(null, { status: 204 });
355
+ }
356
+
357
+ /**
358
+ * GET /api/users/vaults — vault-name list for the assigned-vault
359
+ * dropdown. Same `parachute:host:admin` scope gate as the other
360
+ * `/api/users*` endpoints. Returns `{ vaults: string[] }` (sorted) so
361
+ * the SPA can populate the dropdown without a second roundtrip.
362
+ *
363
+ * This is the canonical surface for "which vaults could a user be
364
+ * pinned to?" — PR 4's OAuth issuer reads through the same
365
+ * services.json source.
366
+ */
367
+ export async function handleListVaults(req: Request, deps: ApiUsersDeps): Promise<Response> {
368
+ if (req.method !== "GET") {
369
+ return jsonError(405, "method_not_allowed", "use GET");
370
+ }
371
+ try {
372
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
373
+ } catch (err) {
374
+ return adminAuthErrorResponse(err as AdminAuthError);
375
+ }
376
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
377
+ const vaults = listVaultNamesFromPath(manifestPath);
378
+ return new Response(JSON.stringify({ vaults }), {
379
+ status: 200,
380
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
381
+ });
382
+ }
383
+
384
+ function describeUsernameReason(reason: "format" | "length" | "reserved"): string {
385
+ switch (reason) {
386
+ case "length":
387
+ return "username must be 2-32 characters long";
388
+ case "format":
389
+ return "username must contain only lowercase letters, digits, hyphens, and underscores ([a-z0-9_-])";
390
+ case "reserved":
391
+ return "username is reserved (admin, root, system, setup, parachute, hub)";
392
+ }
393
+ }
@@ -433,7 +433,15 @@ async function runSetPassword(args: readonly string[], deps: AuthDeps): Promise<
433
433
  }
434
434
 
435
435
  try {
436
- const u = await createUser(db, targetUsername, password, { allowMulti: flags.allowMulti });
436
+ // `passwordChanged: true` matches the wizard + env-seed paths: the
437
+ // CLI user typed their password at the prompt above (or supplied
438
+ // it via --password), so they don't need PR 3's force-change-on-
439
+ // first-sign-in redirect. Same reasoning as
440
+ // `seedInitialAdminIfNeeded` and `handleSetupAccountPost`.
441
+ const u = await createUser(db, targetUsername, password, {
442
+ allowMulti: flags.allowMulti,
443
+ passwordChanged: true,
444
+ });
437
445
  console.log(`Created hub user "${u.username}" (id=${u.id}).`);
438
446
  const issued = await issueOperatorToken(db, u.id, {
439
447
  dir: deps.configDir,
@@ -971,6 +979,7 @@ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<nu
971
979
  clientId: OPERATOR_TOKEN_CLIENT_ID,
972
980
  issuer,
973
981
  ttlSeconds,
982
+ vaultScope: [], // CLI-mint tokens are operator-scoped; no per-user vault pin
974
983
  ...(permissionsClaim !== undefined ? { extraClaims: { permissions: permissionsClaim } } : {}),
975
984
  });
976
985
 
@@ -102,7 +102,11 @@ export async function seedInitialAdminIfNeeded(
102
102
  const username = env.PARACHUTE_INITIAL_ADMIN_USERNAME?.trim();
103
103
  const password = env.PARACHUTE_INITIAL_ADMIN_PASSWORD;
104
104
  if (!username || !password) return "needs-setup";
105
- await createUser(db, username, password);
105
+ // Env-seeded admins chose their password via the env var; skip the
106
+ // multi-user-Phase-1 force-change-password redirect by landing
107
+ // `password_changed=true`. Same treatment as the wizard's first admin.
108
+ // `assignedVault` stays null — admin posture (no per-vault restriction).
109
+ await createUser(db, username, password, { passwordChanged: true });
106
110
  log(`parachute serve: seeded initial admin "${username}" from PARACHUTE_INITIAL_ADMIN_*`);
107
111
  return "seeded";
108
112
  }