@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.
- package/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +139 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-server.test.ts +29 -4
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +1059 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +1500 -13
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +30 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +54 -0
- package/src/hub-server.ts +162 -18
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +34 -9
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +256 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +1100 -56
- package/src/supervisor.ts +66 -14
- package/src/users.ts +210 -3
- package/src/vault-name.ts +71 -0
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/src/api-users.ts
ADDED
|
@@ -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
|
+
}
|
package/src/commands/auth.ts
CHANGED
|
@@ -433,7 +433,15 @@ async function runSetPassword(args: readonly string[], deps: AuthDeps): Promise<
|
|
|
433
433
|
}
|
|
434
434
|
|
|
435
435
|
try {
|
|
436
|
-
|
|
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
|
|
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|