@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,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
+ }
@@ -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
  });
@@ -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
- registry.update(opId, { status: "running" }, `running bun add -g ${spec.package}@latest`);
342
- const code = await run(["bun", "add", "-g", `${spec.package}@latest`]);
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 ${spec.package}@latest failed (exit ${code})`,
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
- registry.update(opId, { status: "running" }, `running bun add -g ${spec.package}@latest`);
449
- const code = await run(["bun", "add", "-g", `${spec.package}@latest`]);
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 ${spec.package}@latest failed (exit ${code})`,
481
+ `bun add -g ${spec_str} failed (exit ${code})`,
458
482
  );
459
483
  return;
460
484
  }
@@ -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,