@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
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/account/*` — signed-in user self-service surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Multi-user Phase 1, PR 3 of 5 (force-change-password flow). 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 2 (hub#280) which shipped the admin
|
|
7
|
+
* `/api/users` surface for creating accounts that land with
|
|
8
|
+
* `password_changed: false`.
|
|
9
|
+
*
|
|
10
|
+
* This file handles the *user* side of the change-password flow:
|
|
11
|
+
*
|
|
12
|
+
* GET /account/change-password — server-rendered HTML form
|
|
13
|
+
* POST /account/change-password — verify current + set new + flip flag
|
|
14
|
+
*
|
|
15
|
+
* Auth posture: any user with a valid session cookie can reach both
|
|
16
|
+
* endpoints. The `/login` POST handler (separately) does the
|
|
17
|
+
* force-redirect when `password_changed === false` — that's the *only*
|
|
18
|
+
* surface that branches on the flag. The page itself is a regular
|
|
19
|
+
* signed-in surface (per design §sign-in flow change "Direct
|
|
20
|
+
* navigation"): a user with `password_changed: true` can still
|
|
21
|
+
* navigate here to rotate their password, and the POST works for any
|
|
22
|
+
* signed-in user against their own account.
|
|
23
|
+
*
|
|
24
|
+
* Force-change is **session-level, not token-level** (design
|
|
25
|
+
* §security/force-change-password as session-level). Tokens minted
|
|
26
|
+
* before the change stay valid until revoked; the redirect is the
|
|
27
|
+
* interactive sign-in boundary. PR 4's OAuth issuer doesn't read the
|
|
28
|
+
* flag at mint time.
|
|
29
|
+
*
|
|
30
|
+
* Wire shape: GET returns HTML (server-rendered). POST accepts
|
|
31
|
+
* `application/x-www-form-urlencoded` (matches the form submission;
|
|
32
|
+
* no fetch/JSON layer — keeps the page operational without JS, same
|
|
33
|
+
* posture as `/login` and `/admin/setup/account`). On success the
|
|
34
|
+
* POST returns 302 → `next` (or `/admin/vaults` if absent). On error
|
|
35
|
+
* the POST re-renders the form with an inline error banner — same
|
|
36
|
+
* pattern as `handleAdminLoginPost`.
|
|
37
|
+
*
|
|
38
|
+
* Other-session invalidation: skipped for Phase 1. Sessions are a
|
|
39
|
+
* single `id` column with a `user_id` FK; a one-liner
|
|
40
|
+
* `DELETE FROM sessions WHERE user_id = ? AND id != ?` would force
|
|
41
|
+
* re-auth on other devices, but it also breaks tabs open elsewhere
|
|
42
|
+
* without explicit user intent. Phase 2's self-service profile page
|
|
43
|
+
* adds a deliberate "sign out everywhere" action (design §2 "Phase
|
|
44
|
+
* 2"). Until then the user's existing other-device sessions stay
|
|
45
|
+
* valid through to natural 24h expiry — matches the design doc's
|
|
46
|
+
* trade-off discussion in §security/force-change-password.
|
|
47
|
+
*/
|
|
48
|
+
import type { Database } from "bun:sqlite";
|
|
49
|
+
import { hash as argonHash } from "@node-rs/argon2";
|
|
50
|
+
import { type ChangePasswordMode, renderChangePassword } from "./account-change-password-ui.ts";
|
|
51
|
+
import { renderAdminError } from "./admin-login-ui.ts";
|
|
52
|
+
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
53
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
54
|
+
import { findActiveSession } from "./sessions.ts";
|
|
55
|
+
import {
|
|
56
|
+
PASSWORD_MAX_LEN,
|
|
57
|
+
UserNotFoundError,
|
|
58
|
+
getUserById,
|
|
59
|
+
validatePassword,
|
|
60
|
+
verifyPassword,
|
|
61
|
+
} from "./users.ts";
|
|
62
|
+
|
|
63
|
+
export interface ApiAccountDeps {
|
|
64
|
+
db: Database;
|
|
65
|
+
/** Test seam — defaults to real clock. */
|
|
66
|
+
now?: () => Date;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where to land after a successful password change when no `next` param
|
|
71
|
+
* is present. Matches `POST_LOGIN_DEFAULT` in `admin-handlers.ts` — the
|
|
72
|
+
* admin SPA's vault list. Kept as a local const (not imported) so this
|
|
73
|
+
* file doesn't accidentally couple to admin-handlers' internals; if the
|
|
74
|
+
* default ever diverges the two should reconcile via a shared config.
|
|
75
|
+
*/
|
|
76
|
+
const POST_CHANGE_DEFAULT = "/admin/vaults";
|
|
77
|
+
|
|
78
|
+
function safeNext(raw: string | null | undefined): string {
|
|
79
|
+
if (!raw) return POST_CHANGE_DEFAULT;
|
|
80
|
+
// Only allow same-origin paths — never honor an absolute URL or scheme.
|
|
81
|
+
// Same shape as `safeNext` in admin-handlers.ts. The
|
|
82
|
+
// change-password GET should never redirect *back* to /login for a
|
|
83
|
+
// signed-in user, but if a malicious form somehow shipped an
|
|
84
|
+
// absolute URL we'd want it ignored here too.
|
|
85
|
+
if (!raw.startsWith("/") || raw.startsWith("//")) return POST_CHANGE_DEFAULT;
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|
|
90
|
+
return new Response(body, {
|
|
91
|
+
status,
|
|
92
|
+
headers: { "content-type": "text/html; charset=utf-8", ...extra },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function redirect(location: string, extra: Record<string, string> = {}): Response {
|
|
97
|
+
return new Response(null, { status: 302, headers: { location, ...extra } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute the change-password mode from the user's `passwordChanged`
|
|
102
|
+
* flag. Pure — both handlers below read the user, branch on this, and
|
|
103
|
+
* render. Keeping it a free function keeps the GET / POST handlers
|
|
104
|
+
* symmetric: the GET picks the mode for the initial render, the POST
|
|
105
|
+
* picks the same mode if it has to re-render with an error.
|
|
106
|
+
*/
|
|
107
|
+
function modeFor(passwordChanged: boolean): ChangePasswordMode {
|
|
108
|
+
return passwordChanged ? "rotate" : "first-time";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* GET /account/change-password — render the form.
|
|
113
|
+
*
|
|
114
|
+
* Auth: requires an active session. Without one, 302 to /login with
|
|
115
|
+
* `?next=/account/change-password` so the user lands back here after
|
|
116
|
+
* signing in. **Critically, this redirect fires regardless of the
|
|
117
|
+
* `password_changed` flag** — a session-less user has no flag to
|
|
118
|
+
* branch on, and they can't reach the change-password page until
|
|
119
|
+
* they've signed in once.
|
|
120
|
+
*
|
|
121
|
+
* The page renders for *any* signed-in user — including users whose
|
|
122
|
+
* `password_changed` is already `true`. That's the direct-navigation
|
|
123
|
+
* path: a user manually visits to rotate their password (design §sign-
|
|
124
|
+
* in flow change "Direct navigation").
|
|
125
|
+
*/
|
|
126
|
+
export function handleAccountChangePasswordGet(req: Request, deps: ApiAccountDeps): Response {
|
|
127
|
+
const session = findActiveSession(deps.db, req);
|
|
128
|
+
if (!session) {
|
|
129
|
+
// Echo `next` so post-login lands back here. Same safe-next discipline
|
|
130
|
+
// as `/login` — strip any unsafe path before re-emitting.
|
|
131
|
+
const url = new URL(req.url);
|
|
132
|
+
const requestedNext = url.searchParams.get("next");
|
|
133
|
+
const safeNextValue = safeNext(requestedNext);
|
|
134
|
+
const querySuffix =
|
|
135
|
+
safeNextValue !== POST_CHANGE_DEFAULT ? `?next=${encodeURIComponent(safeNextValue)}` : "";
|
|
136
|
+
const nextParam = encodeURIComponent(`/account/change-password${querySuffix}`);
|
|
137
|
+
return redirect(`/login?next=${nextParam}`);
|
|
138
|
+
}
|
|
139
|
+
const user = getUserById(deps.db, session.userId);
|
|
140
|
+
if (!user) {
|
|
141
|
+
// Session points at a deleted user — clear posture is "log them out."
|
|
142
|
+
// Hand back to /login; the stale session row will time out on its own.
|
|
143
|
+
return redirect("/login");
|
|
144
|
+
}
|
|
145
|
+
const url = new URL(req.url);
|
|
146
|
+
const next = safeNext(url.searchParams.get("next"));
|
|
147
|
+
const csrf = ensureCsrfToken(req);
|
|
148
|
+
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
149
|
+
return htmlResponse(
|
|
150
|
+
renderChangePassword({
|
|
151
|
+
mode: modeFor(user.passwordChanged),
|
|
152
|
+
csrfToken: csrf.token,
|
|
153
|
+
username: user.username,
|
|
154
|
+
next,
|
|
155
|
+
}),
|
|
156
|
+
200,
|
|
157
|
+
extra,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* POST /account/change-password — verify current + apply new.
|
|
163
|
+
*
|
|
164
|
+
* Order of checks (matches the design doc's §sign-in flow change /
|
|
165
|
+
* §scope-section ordering):
|
|
166
|
+
* 1. Session (else 401 — no body to validate without an identity).
|
|
167
|
+
* 2. CSRF (else 400 — same wire shape as `/login` POST CSRF failure).
|
|
168
|
+
* 3. Required-field presence (else 400).
|
|
169
|
+
* 4. `current_password.length > PASSWORD_MAX_LEN` → 413 BEFORE argon2id
|
|
170
|
+
* verify touches it. Session-gated, so the CPU-DoS surface is
|
|
171
|
+
* narrower than the unauthenticated `/login` POST, but the cap is
|
|
172
|
+
* cheap insurance against a megabyte-current-password submission
|
|
173
|
+
* (PR-3 fold N1).
|
|
174
|
+
* 5. `new_password.length > PASSWORD_MAX_LEN` → 413 BEFORE argon2id
|
|
175
|
+
* hash touches it.
|
|
176
|
+
* 6. `validatePassword(new_password)` → 400 `invalid_password`
|
|
177
|
+
* (12-char floor; same validator the create-user path uses).
|
|
178
|
+
* 7. `new_password !== confirm` → 400 `password_mismatch`.
|
|
179
|
+
* 8. `verifyPassword(user, current_password)` → 401 `invalid_credentials`.
|
|
180
|
+
* Runs argon2id so order matters — 7 happens first to avoid burning
|
|
181
|
+
* a hash on an obviously-broken input.
|
|
182
|
+
* 9. `new_password === current_password` → 400 `password_unchanged`.
|
|
183
|
+
* 10. Hash new + atomic UPDATE (password_hash + password_changed=1 +
|
|
184
|
+
* updated_at) in one transaction (PR-3 fold N2) → 302 → next.
|
|
185
|
+
*
|
|
186
|
+
* Re-render shape on validation failure: the page comes back with an
|
|
187
|
+
* inline error banner (matching `/login`'s POST failure shape), HTTP
|
|
188
|
+
* status reflects the class (400 / 401 / 413). On success: 302 to
|
|
189
|
+
* `next` — the session cookie is unchanged (the user is still signed
|
|
190
|
+
* in; only the password hash and the flag moved).
|
|
191
|
+
*/
|
|
192
|
+
export async function handleAccountChangePasswordPost(
|
|
193
|
+
req: Request,
|
|
194
|
+
deps: ApiAccountDeps,
|
|
195
|
+
): Promise<Response> {
|
|
196
|
+
const session = findActiveSession(deps.db, req);
|
|
197
|
+
if (!session) {
|
|
198
|
+
// No session means no identity — there's no useful re-render here.
|
|
199
|
+
// Same shape as the admin-API endpoints: 401 with a brief HTML
|
|
200
|
+
// response, the operator's flow recovers by signing in again.
|
|
201
|
+
return htmlResponse(
|
|
202
|
+
renderAdminError({
|
|
203
|
+
title: "Not signed in",
|
|
204
|
+
message: "Please sign in before changing your password.",
|
|
205
|
+
}),
|
|
206
|
+
401,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const user = getUserById(deps.db, session.userId);
|
|
210
|
+
if (!user) {
|
|
211
|
+
return htmlResponse(
|
|
212
|
+
renderAdminError({
|
|
213
|
+
title: "Account not found",
|
|
214
|
+
message: "The signed-in account no longer exists. Please sign in again.",
|
|
215
|
+
}),
|
|
216
|
+
401,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const form = await req.formData();
|
|
221
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
222
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
223
|
+
return htmlResponse(
|
|
224
|
+
renderAdminError({
|
|
225
|
+
title: "Invalid form submission",
|
|
226
|
+
message: "The form's CSRF token did not match. Reload the page and try again.",
|
|
227
|
+
}),
|
|
228
|
+
400,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
|
|
232
|
+
|
|
233
|
+
const currentPassword = String(form.get("current_password") ?? "");
|
|
234
|
+
const newPassword = String(form.get("new_password") ?? "");
|
|
235
|
+
const confirmPassword = String(form.get("new_password_confirm") ?? "");
|
|
236
|
+
const next = safeNext(String(form.get("next") ?? ""));
|
|
237
|
+
const mode = modeFor(user.passwordChanged);
|
|
238
|
+
|
|
239
|
+
// Required-field check before any expensive work.
|
|
240
|
+
if (!currentPassword || !newPassword || !confirmPassword) {
|
|
241
|
+
return htmlResponse(
|
|
242
|
+
renderChangePassword({
|
|
243
|
+
mode,
|
|
244
|
+
csrfToken,
|
|
245
|
+
username: user.username,
|
|
246
|
+
next,
|
|
247
|
+
errorMessage: "All three fields are required.",
|
|
248
|
+
}),
|
|
249
|
+
400,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Cap `currentPassword` length BEFORE argon2id verify touches it. The
|
|
254
|
+
// session-authenticated caller would otherwise be able to submit a
|
|
255
|
+
// megabyte body and force a full argon2id hash on arbitrary input
|
|
256
|
+
// (CPU-DoS shape — same flavor as the unauthenticated /api/users POST
|
|
257
|
+
// mitigates with the new-password cap below, but session-gated here
|
|
258
|
+
// since change-password sits behind /login). Same 413 + shape as the
|
|
259
|
+
// new-password cap; same `PASSWORD_MAX_LEN` constant.
|
|
260
|
+
if (currentPassword.length > PASSWORD_MAX_LEN) {
|
|
261
|
+
return htmlResponse(
|
|
262
|
+
renderChangePassword({
|
|
263
|
+
mode,
|
|
264
|
+
csrfToken,
|
|
265
|
+
username: user.username,
|
|
266
|
+
next,
|
|
267
|
+
errorMessage: `Current password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
|
|
268
|
+
}),
|
|
269
|
+
413,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Cap new-password length BEFORE argon2id touches it. 413 fires before
|
|
274
|
+
// any validator or hash call so a megabyte body burns ~0ms server CPU.
|
|
275
|
+
// Same pattern as PR 2's `/api/users` POST.
|
|
276
|
+
if (newPassword.length > PASSWORD_MAX_LEN) {
|
|
277
|
+
return htmlResponse(
|
|
278
|
+
renderChangePassword({
|
|
279
|
+
mode,
|
|
280
|
+
csrfToken,
|
|
281
|
+
username: user.username,
|
|
282
|
+
next,
|
|
283
|
+
errorMessage: `New password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
|
|
284
|
+
}),
|
|
285
|
+
413,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 12-char minimum (PR 1 validator). Floors only; no complexity rules.
|
|
290
|
+
const validity = validatePassword(newPassword);
|
|
291
|
+
if (!validity.valid) {
|
|
292
|
+
return htmlResponse(
|
|
293
|
+
renderChangePassword({
|
|
294
|
+
mode,
|
|
295
|
+
csrfToken,
|
|
296
|
+
username: user.username,
|
|
297
|
+
next,
|
|
298
|
+
errorMessage: "New password must be at least 12 characters (a passphrase is fine).",
|
|
299
|
+
}),
|
|
300
|
+
400,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Confirm-matches check before the argon2id verify — fast feedback for
|
|
305
|
+
// a transposed-character mistake, and avoids one hash call on the
|
|
306
|
+
// common typo path.
|
|
307
|
+
if (newPassword !== confirmPassword) {
|
|
308
|
+
return htmlResponse(
|
|
309
|
+
renderChangePassword({
|
|
310
|
+
mode,
|
|
311
|
+
csrfToken,
|
|
312
|
+
username: user.username,
|
|
313
|
+
next,
|
|
314
|
+
errorMessage: "New password and confirmation do not match.",
|
|
315
|
+
}),
|
|
316
|
+
400,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Verify current password. Argon2id verify is the expensive op; we
|
|
321
|
+
// gated above so it only fires once per legitimate-shape submission.
|
|
322
|
+
const currentOk = await verifyPassword(user, currentPassword);
|
|
323
|
+
if (!currentOk) {
|
|
324
|
+
return htmlResponse(
|
|
325
|
+
renderChangePassword({
|
|
326
|
+
mode,
|
|
327
|
+
csrfToken,
|
|
328
|
+
username: user.username,
|
|
329
|
+
next,
|
|
330
|
+
errorMessage: "Current password is incorrect.",
|
|
331
|
+
}),
|
|
332
|
+
401,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Refuse same-as-current. We check after verify so a 401 ("wrong
|
|
337
|
+
// current password") takes precedence over "your current password
|
|
338
|
+
// happens to equal your new attempt but isn't even your real current
|
|
339
|
+
// password" — a 400 here when current is wrong would leak that the
|
|
340
|
+
// typed `new_password` matches *some* attempted prior. With verify-
|
|
341
|
+
// first, this branch only fires when current is correct AND new
|
|
342
|
+
// equals current — the real "didn't actually change anything" case.
|
|
343
|
+
if (newPassword === currentPassword) {
|
|
344
|
+
return htmlResponse(
|
|
345
|
+
renderChangePassword({
|
|
346
|
+
mode,
|
|
347
|
+
csrfToken,
|
|
348
|
+
username: user.username,
|
|
349
|
+
next,
|
|
350
|
+
errorMessage: "New password must differ from your current password.",
|
|
351
|
+
}),
|
|
352
|
+
400,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Persist new hash + flip the changed flag, atomically.
|
|
357
|
+
//
|
|
358
|
+
// Hash OUTSIDE the transaction. `db.transaction()` on bun:sqlite is
|
|
359
|
+
// sync — argon2id's async hash promise inside the closure would
|
|
360
|
+
// silently break atomicity (same constraint the OAuth token-rotate
|
|
361
|
+
// path documents in oauth-handlers.ts). Hash first, then run both
|
|
362
|
+
// UPDATEs inside the tx so a mid-write process crash can't land us
|
|
363
|
+
// with a fresh hash but a stale flag (benign in this direction — one
|
|
364
|
+
// extra force-redirect on next login — but trivially avoidable).
|
|
365
|
+
const now = deps.now ?? (() => new Date());
|
|
366
|
+
const passwordHash = await argonHash(newPassword);
|
|
367
|
+
const stamp = now().toISOString();
|
|
368
|
+
try {
|
|
369
|
+
deps.db.transaction(() => {
|
|
370
|
+
const result = deps.db
|
|
371
|
+
.prepare(
|
|
372
|
+
"UPDATE users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE id = ?",
|
|
373
|
+
)
|
|
374
|
+
.run(passwordHash, stamp, user.id);
|
|
375
|
+
if (result.changes === 0) throw new UserNotFoundError(user.id);
|
|
376
|
+
})();
|
|
377
|
+
} catch (err) {
|
|
378
|
+
// The user row vanished between the session-resolve check above and
|
|
379
|
+
// the UPDATE. Surface as 401 + "account not found" — same shape as
|
|
380
|
+
// the stale-session-id branch at the top of this handler.
|
|
381
|
+
if (err instanceof UserNotFoundError) {
|
|
382
|
+
return htmlResponse(
|
|
383
|
+
renderAdminError({
|
|
384
|
+
title: "Account not found",
|
|
385
|
+
message: "The signed-in account no longer exists. Please sign in again.",
|
|
386
|
+
}),
|
|
387
|
+
401,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Ops-visibility headers (no downstream consumer): surface password-
|
|
394
|
+
// change events to hub log grep / monitoring without changing the
|
|
395
|
+
// response body. Safe to remove if not in use. `x-parachute-password-
|
|
396
|
+
// changed: 1` is the event marker; `x-secure-context` records whether
|
|
397
|
+
// the request arrived over HTTPS (matches the cookie's `Secure`
|
|
398
|
+
// attribute decision so a log line at the same path tells the
|
|
399
|
+
// operator the transport posture without re-checking the cookie).
|
|
400
|
+
// No new session cookie set — the existing one stays valid. The user
|
|
401
|
+
// remains signed in, just with a fresh hash. (Other devices' sessions
|
|
402
|
+
// also stay valid; Phase 2 adds "sign out everywhere" per the
|
|
403
|
+
// design's session-invalidation discussion.)
|
|
404
|
+
return redirect(next, {
|
|
405
|
+
"x-parachute-password-changed": "1",
|
|
406
|
+
"cache-control": "no-store",
|
|
407
|
+
"x-secure-context": isHttpsRequest(req) ? "https" : "http",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Flip `users.password_changed` from 0 to 1 for the given user.
|
|
413
|
+
* Idempotent — running against an already-`true` row is a no-op.
|
|
414
|
+
*
|
|
415
|
+
* **Not used by the change-password POST itself** — that path inlines
|
|
416
|
+
* the password_changed=1 flip into the same UPDATE that writes the new
|
|
417
|
+
* hash, so the two writes commit atomically inside one transaction
|
|
418
|
+
* (folds N2 of PR #281). This standalone helper is retained for two
|
|
419
|
+
* call sites that don't co-occur with a hash rewrite:
|
|
420
|
+
*
|
|
421
|
+
* 1. Test scaffolding that flips the bit without rotating the hash.
|
|
422
|
+
* 2. Phase 2's admin-reset path, where the operator-side rewrite of
|
|
423
|
+
* the hash flips `password_changed` back to 0 (so the user is
|
|
424
|
+
* forced through change-password on next login) — there's no
|
|
425
|
+
* `markPasswordChanged` call on that flow, but a future
|
|
426
|
+
* "skip-force-change for this re-issued password" flow would
|
|
427
|
+
* want it.
|
|
428
|
+
*
|
|
429
|
+
* Lives here (not in `users.ts`) because the only current call site is
|
|
430
|
+
* the test scaffolding; lift into `users.ts` next to `setPassword` when
|
|
431
|
+
* Phase 2 grows a production caller.
|
|
432
|
+
*/
|
|
433
|
+
export function markPasswordChanged(
|
|
434
|
+
db: Database,
|
|
435
|
+
userId: string,
|
|
436
|
+
now: () => Date = () => new Date(),
|
|
437
|
+
): void {
|
|
438
|
+
const stamp = now().toISOString();
|
|
439
|
+
db.prepare("UPDATE users SET password_changed = 1, updated_at = ? WHERE id = ?").run(
|
|
440
|
+
stamp,
|
|
441
|
+
userId,
|
|
442
|
+
);
|
|
443
|
+
}
|
package/src/api-mint-token.ts
CHANGED
|
@@ -191,6 +191,12 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
191
191
|
clientId: API_MINT_TOKEN_CLIENT_ID,
|
|
192
192
|
issuer: deps.issuer,
|
|
193
193
|
ttlSeconds,
|
|
194
|
+
// Operator-driven CLI/API mint — the bearer already cleared the
|
|
195
|
+
// `parachute:host:auth` privilege gate, so there's no per-user vault
|
|
196
|
+
// pin to enforce. Empty `vault_scope` is the "no restriction"
|
|
197
|
+
// sentinel; the `scopes` themselves remain authorization-bearing as
|
|
198
|
+
// before.
|
|
199
|
+
vaultScope: [],
|
|
194
200
|
...(permissionsClaim !== undefined ? { extraClaims: { permissions: permissionsClaim } } : {}),
|
|
195
201
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
196
202
|
});
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import type { Database } from "bun:sqlite";
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
38
|
+
import { getModuleInstallChannel } from "./hub-settings.ts";
|
|
38
39
|
import { validateAccessToken } from "./jwt-sign.ts";
|
|
39
40
|
import { FIRST_PARTY_FALLBACKS, type ServiceSpec, composeServiceSpec } from "./service-spec.ts";
|
|
40
41
|
import { findService, readManifest, removeService } from "./services-manifest.ts";
|
|
@@ -171,6 +172,20 @@ export interface ApiModulesOpsDeps {
|
|
|
171
172
|
* null when not found.
|
|
172
173
|
*/
|
|
173
174
|
findGlobalInstall?: (pkg: string) => string | null;
|
|
175
|
+
/**
|
|
176
|
+
* Extra env vars merged onto the supervised child at spawn time (hub#267).
|
|
177
|
+
*
|
|
178
|
+
* The first-boot wizard uses this to pass `PARACHUTE_VAULT_NAME=<typed>`
|
|
179
|
+
* through to vault's first-boot path so the operator-typed name flows
|
|
180
|
+
* end-to-end (vault's `server.ts` reads the env var on its first-boot
|
|
181
|
+
* branch and creates the vault under that name instead of the hard-coded
|
|
182
|
+
* `default`). Generic enough that future env-driven config (e.g.
|
|
183
|
+
* `SCRIBE_MODEL`) can ride the same seam without growing a new field.
|
|
184
|
+
*
|
|
185
|
+
* Threaded to the supervisor's `SpawnRequest.env` — the merge happens
|
|
186
|
+
* inside `Bun.spawn` at child spawn time; we don't mutate `process.env`.
|
|
187
|
+
*/
|
|
188
|
+
spawnEnv?: Record<string, string>;
|
|
174
189
|
}
|
|
175
190
|
|
|
176
191
|
interface PathMatch {
|
|
@@ -270,6 +285,7 @@ async function spawnSupervised(
|
|
|
270
285
|
short,
|
|
271
286
|
cmd,
|
|
272
287
|
...(entry.installDir ? { cwd: entry.installDir } : {}),
|
|
288
|
+
...(deps.spawnEnv && Object.keys(deps.spawnEnv).length > 0 ? { env: deps.spawnEnv } : {}),
|
|
273
289
|
};
|
|
274
290
|
return deps.supervisor.start(req);
|
|
275
291
|
}
|
|
@@ -338,8 +354,14 @@ export async function runInstall(
|
|
|
338
354
|
): Promise<void> {
|
|
339
355
|
const registry = deps.registry ?? defaultRegistry;
|
|
340
356
|
const run = deps.run ?? defaultRun;
|
|
341
|
-
|
|
342
|
-
|
|
357
|
+
// hub#275: operator-settable channel (`latest` | `rc`). Read on every
|
|
358
|
+
// op so a toggle change applies to the next install without a hub
|
|
359
|
+
// restart. The hub-settings layer seeds from PARACHUTE_MODULE_CHANNEL
|
|
360
|
+
// on first read; after that the row is source of truth.
|
|
361
|
+
const channel = getModuleInstallChannel(deps.db);
|
|
362
|
+
const spec_str = `${spec.package}@${channel}`;
|
|
363
|
+
registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
|
|
364
|
+
const code = await run(["bun", "add", "-g", spec_str]);
|
|
343
365
|
if (code !== 0) {
|
|
344
366
|
// Bun 1.2.x lockfile-recovery noise: probe the global prefix
|
|
345
367
|
// before treating non-zero as fatal. Mirrors the same defense in
|
|
@@ -350,7 +372,7 @@ export async function runInstall(
|
|
|
350
372
|
registry.update(
|
|
351
373
|
opId,
|
|
352
374
|
{ status: "failed", error: `bun add -g exited ${code}` },
|
|
353
|
-
`bun add -g ${
|
|
375
|
+
`bun add -g ${spec_str} failed (exit ${code})`,
|
|
354
376
|
);
|
|
355
377
|
return;
|
|
356
378
|
}
|
|
@@ -445,8 +467,10 @@ async function runUpgrade(
|
|
|
445
467
|
): Promise<void> {
|
|
446
468
|
const registry = deps.registry ?? defaultRegistry;
|
|
447
469
|
const run = deps.run ?? defaultRun;
|
|
448
|
-
|
|
449
|
-
const
|
|
470
|
+
const channel = getModuleInstallChannel(deps.db);
|
|
471
|
+
const spec_str = `${spec.package}@${channel}`;
|
|
472
|
+
registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
|
|
473
|
+
const code = await run(["bun", "add", "-g", spec_str]);
|
|
450
474
|
if (code !== 0) {
|
|
451
475
|
const findGlobalInstall = deps.findGlobalInstall;
|
|
452
476
|
const probed = findGlobalInstall?.(spec.package) ?? null;
|
|
@@ -454,7 +478,7 @@ async function runUpgrade(
|
|
|
454
478
|
registry.update(
|
|
455
479
|
opId,
|
|
456
480
|
{ status: "failed", error: `bun add -g exited ${code}` },
|
|
457
|
-
`bun add -g ${
|
|
481
|
+
`bun add -g ${spec_str} failed (exit ${code})`,
|
|
458
482
|
);
|
|
459
483
|
return;
|
|
460
484
|
}
|
package/src/api-modules.ts
CHANGED
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import type { Database } from "bun:sqlite";
|
|
26
|
+
import {
|
|
27
|
+
type ModuleInstallChannel,
|
|
28
|
+
getModuleInstallChannel,
|
|
29
|
+
isModuleInstallChannel,
|
|
30
|
+
setModuleInstallChannel,
|
|
31
|
+
} from "./hub-settings.ts";
|
|
26
32
|
import { validateAccessToken } from "./jwt-sign.ts";
|
|
27
33
|
import { FIRST_PARTY_FALLBACKS } from "./service-spec.ts";
|
|
28
34
|
import { readManifest } from "./services-manifest.ts";
|
|
@@ -90,6 +96,14 @@ interface ModulesResponse {
|
|
|
90
96
|
* (the on-box `parachute start <svc>` flow lives outside hub).
|
|
91
97
|
*/
|
|
92
98
|
supervisor_available: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Current module install channel (`latest` | `rc`). Surfaced here so
|
|
101
|
+
* the SPA can render the toggle without a second roundtrip. Read on
|
|
102
|
+
* each request — the hub-settings layer is the source of truth, and
|
|
103
|
+
* a toggle change is visible to the next GET without a hub restart
|
|
104
|
+
* (hub#275).
|
|
105
|
+
*/
|
|
106
|
+
module_install_channel: ModuleInstallChannel;
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
interface CachedVersion {
|
|
@@ -241,6 +255,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
241
255
|
const body: ModulesResponse = {
|
|
242
256
|
modules,
|
|
243
257
|
supervisor_available: supervisor !== undefined,
|
|
258
|
+
module_install_channel: getModuleInstallChannel(deps.db),
|
|
244
259
|
};
|
|
245
260
|
|
|
246
261
|
return new Response(JSON.stringify(body), {
|
|
@@ -249,6 +264,92 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
249
264
|
});
|
|
250
265
|
}
|
|
251
266
|
|
|
267
|
+
/**
|
|
268
|
+
* `PUT /api/modules/channel` — operator-settable module install channel.
|
|
269
|
+
*
|
|
270
|
+
* Bearer-gated on `parachute:host:admin` (same scope as install/upgrade
|
|
271
|
+
* — destructive-ish operator-only). Body: `{ "channel": "latest" | "rc" }`.
|
|
272
|
+
* Writes through to `hub_settings.module_install_channel`; the next
|
|
273
|
+
* runInstall / runUpgrade reads the new value (no hub restart needed).
|
|
274
|
+
*
|
|
275
|
+
* Why `:host:admin` rather than `:host:auth` (the GET scope): changing
|
|
276
|
+
* the channel is an upstream-state change that affects every subsequent
|
|
277
|
+
* module install + upgrade. Same boundary as a `bun add -g` itself.
|
|
278
|
+
*/
|
|
279
|
+
export const API_MODULES_CHANNEL_REQUIRED_SCOPE = "parachute:host:admin";
|
|
280
|
+
|
|
281
|
+
export interface ApiModulesChannelDeps {
|
|
282
|
+
db: Database;
|
|
283
|
+
issuer: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function handleApiModulesChannel(
|
|
287
|
+
req: Request,
|
|
288
|
+
deps: ApiModulesChannelDeps,
|
|
289
|
+
): Promise<Response> {
|
|
290
|
+
if (req.method !== "PUT") {
|
|
291
|
+
return jsonError(405, "method_not_allowed", "use PUT");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Bearer presence + parsing.
|
|
295
|
+
const auth = req.headers.get("authorization");
|
|
296
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
297
|
+
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
298
|
+
}
|
|
299
|
+
const bearer = auth.slice("Bearer ".length).trim();
|
|
300
|
+
if (!bearer) {
|
|
301
|
+
return jsonError(401, "unauthenticated", "empty bearer token");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Bearer validation + scope check.
|
|
305
|
+
try {
|
|
306
|
+
const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
|
|
307
|
+
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
308
|
+
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
309
|
+
}
|
|
310
|
+
const scopes =
|
|
311
|
+
typeof validated.payload.scope === "string"
|
|
312
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
313
|
+
: [];
|
|
314
|
+
if (!scopes.includes(API_MODULES_CHANNEL_REQUIRED_SCOPE)) {
|
|
315
|
+
return jsonError(
|
|
316
|
+
403,
|
|
317
|
+
"insufficient_scope",
|
|
318
|
+
`bearer token lacks ${API_MODULES_CHANNEL_REQUIRED_SCOPE}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
} catch (err) {
|
|
322
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
323
|
+
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Parse + validate body.
|
|
327
|
+
let parsed: unknown;
|
|
328
|
+
try {
|
|
329
|
+
parsed = await req.json();
|
|
330
|
+
} catch {
|
|
331
|
+
return jsonError(400, "invalid_request", "request body must be JSON");
|
|
332
|
+
}
|
|
333
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
334
|
+
return jsonError(400, "invalid_request", "request body must be a JSON object");
|
|
335
|
+
}
|
|
336
|
+
const channel = (parsed as { channel?: unknown }).channel;
|
|
337
|
+
if (!isModuleInstallChannel(channel)) {
|
|
338
|
+
return jsonError(
|
|
339
|
+
400,
|
|
340
|
+
"invalid_channel",
|
|
341
|
+
`channel must be one of: latest, rc (got ${JSON.stringify(channel)})`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
setModuleInstallChannel(deps.db, channel);
|
|
346
|
+
|
|
347
|
+
return new Response(JSON.stringify({ channel }), {
|
|
348
|
+
status: 200,
|
|
349
|
+
headers: { "content-type": "application/json" },
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
252
353
|
function jsonError(status: number, code: string, description: string): Response {
|
|
253
354
|
return new Response(JSON.stringify({ error: code, error_description: description }), {
|
|
254
355
|
status,
|