@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
package/src/cors.ts ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * CORS posture for the public OAuth + discovery surface.
3
+ *
4
+ * Background. Third-party SPAs (Aaron's Gitcoin Brain UI on
5
+ * `https://unforced-dev.github.io`, future user-built clients, OIDC libraries
6
+ * pulling discovery + JWKS) need to talk to a self-hosted hub from a foreign
7
+ * origin. The OAuth Dynamic Client Registration spec (RFC 7591) is *designed*
8
+ * for cross-origin use: any SPA registers itself, runs the auth-code flow,
9
+ * exchanges the code at `/oauth/token`. Without CORS headers on those
10
+ * endpoints, browser preflights fail and the entire third-party-SPA story is
11
+ * broken before it starts.
12
+ *
13
+ * The matrix:
14
+ *
15
+ * in-scope (echo-origin + `Allow-Credentials: true` for browser callers,
16
+ * wildcard `*` + `Allow-Credentials: false` for non-browser
17
+ * callers without an Origin header):
18
+ * /oauth/* — DCR, authorize, token, revoke
19
+ *
20
+ * `/.well-known/*` handlers carry their own inline CORS posture in
21
+ * `hub-server.ts` (a narrower `Allow-Methods: GET, OPTIONS` since they're
22
+ * read-only) and aren't routed through this module.
23
+ *
24
+ * out-of-scope (same-origin only, no CORS headers):
25
+ * /api/* — admin Bearer surface
26
+ * /admin/* — admin SPA shell
27
+ * /login, /logout, /account/* — interactive session pages
28
+ * /vault/*, /<service-mount>/* — module-level content proxy
29
+ *
30
+ * Why echo the request Origin instead of `*`:
31
+ *
32
+ * The rc.17 posture used a static `Access-Control-Allow-Origin: *` +
33
+ * `Allow-Credentials: false`. That works for SPAs that fetch with
34
+ * `credentials: 'omit'`, but most SPA frameworks (the Gitcoin Brain UI's
35
+ * default among them) fetch with `credentials: 'include'`. Browsers reject
36
+ * any `*` ACAO response when the request was made with credentials mode
37
+ * `include` — even when the endpoint doesn't actually use cookies. The CORS
38
+ * spec requires an *explicit* origin echo paired with
39
+ * `Access-Control-Allow-Credentials: true` for that combination to work.
40
+ *
41
+ * Why this isn't a security regression vs `*`:
42
+ *
43
+ * Browsers already restrict the *response* readability by Origin under SOP —
44
+ * an attacker page at `evil.example` issuing a `fetch(hub, {credentials:
45
+ * 'include'})` only gets to *read* the response if the server says yes by
46
+ * echoing `evil.example` back in ACAO. Echoing back the same origin the
47
+ * browser already sent reveals nothing the attacker couldn't reach by
48
+ * standing up their own server. The protocol-level gates (PKCE +
49
+ * redirect_uri matching + the operator-driven approval flow) still bound
50
+ * what a malicious cross-origin caller can *do*. This is the canonical
51
+ * posture for OAuth authorization servers — see [Okta], [Auth0],
52
+ * [Keycloak] — for exactly this reason: OAuth endpoints are public by
53
+ * design, bearer-token-based not cookie-based, and an allowlist at this
54
+ * layer adds friction without preventing any attack the protocol doesn't
55
+ * already cover.
56
+ *
57
+ * Why fall back to `*` + `credentials: false` when there's no Origin:
58
+ *
59
+ * A request without an `Origin` header is a non-browser caller (`curl`
60
+ * without `-H Origin: …`, a server-side fetch). Echoing back nothing would
61
+ * leave the response with no ACAO at all — fine for non-browser callers
62
+ * since they don't enforce CORS, but breaks the contract that a
63
+ * curl-shaped probe to `/oauth/...` should still come back with a
64
+ * well-formed CORS preamble for diagnostic purposes. The wildcard +
65
+ * credentials:false branch matches the rc.17 shape exactly for that case.
66
+ *
67
+ * Why we don't allowlist per-Origin:
68
+ *
69
+ * For OAuth specifically: an allowlist defeats the purpose of an open
70
+ * identity provider. For the broader admin / API surface, an allowlist
71
+ * *is* the right shape — but that surface stays same-origin-only here and
72
+ * doesn't pass through this module.
73
+ *
74
+ * Header rationale:
75
+ *
76
+ * Access-Control-Allow-Origin
77
+ * The request's `Origin` header verbatim when present; `*` otherwise
78
+ * (non-browser caller — see fallback note above).
79
+ *
80
+ * Access-Control-Allow-Credentials
81
+ * `true` when echoing a specific origin (required for browsers fetching
82
+ * with `credentials: 'include'`); `false` on the `*` fallback (the
83
+ * wildcard branch must pair with credentials:false per CORS spec).
84
+ *
85
+ * Vary: Origin
86
+ * Set on every echo-origin response. Without it, a response for
87
+ * `evil.example` can be cached by the browser's HTTP cache (or a
88
+ * downstream CDN) and reused for a subsequent `good.example` request,
89
+ * leaking the wrong ACAO and breaking CORS in unpredictable ways.
90
+ * Critical for cache correctness.
91
+ *
92
+ * Access-Control-Allow-Methods: GET, POST, OPTIONS
93
+ * The union of methods the in-scope route family supports. Per-route
94
+ * could be narrower (e.g. /oauth/token is POST-only), but advertising
95
+ * the union is the simpler shape and browsers don't enforce a per-route
96
+ * check anyway — the *actual* request method gates execution at the
97
+ * handler.
98
+ *
99
+ * Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With
100
+ * The headers SPAs realistically send: bearer auth, JSON bodies, and the
101
+ * X-Requested-With marker some HTTP clients add automatically. Anything
102
+ * else (custom headers) lands in the preflight rejection at the browser,
103
+ * which is the right shape — surface unexpected headers to the developer.
104
+ *
105
+ * Access-Control-Max-Age: 86400
106
+ * 24h preflight cache. The route surface is stable (RFCs nailed down
107
+ * years ago); long max-age cuts preflight chatter to ~1/day per SPA.
108
+ *
109
+ * Access-Control-Expose-Headers: WWW-Authenticate
110
+ * OAuth error responses ride in `WWW-Authenticate` (RFC 6750 §3); a
111
+ * cross-origin SPA needs to read it to surface "invalid_token" /
112
+ * "insufficient_scope" failure modes. Other headers (Content-Type,
113
+ * Content-Length, Date, …) are CORS-safelisted by default — no need to
114
+ * enumerate them.
115
+ *
116
+ * [Okta]: https://developer.okta.com/docs/concepts/api-access-management/#cors
117
+ * [Auth0]: https://auth0.com/docs/get-started/applications/configure-cors
118
+ * [Keycloak]: https://www.keycloak.org/docs/latest/server_admin/#con-web-origins-keycloak_server_administration_guide
119
+ */
120
+
121
+ /**
122
+ * Static header set that's identical regardless of whether the caller had an
123
+ * Origin: the always-allow exposure of WWW-Authenticate so cross-origin
124
+ * SPAs can read RFC 6750 error responses.
125
+ *
126
+ * Origin / credentials / Vary are computed per-request in `applyCorsHeaders`
127
+ * + `corsPreflightResponse` because they depend on the request's Origin
128
+ * header.
129
+ */
130
+ const CORS_STATIC_RESPONSE_HEADERS: Readonly<Record<string, string>> = {
131
+ "access-control-expose-headers": "WWW-Authenticate",
132
+ };
133
+
134
+ /**
135
+ * Static portion of the preflight headers — method/header allowlists +
136
+ * max-age. The dynamic Origin/credentials/Vary are computed in
137
+ * `corsPreflightResponse`.
138
+ */
139
+ const CORS_STATIC_PREFLIGHT_HEADERS: Readonly<Record<string, string>> = {
140
+ "access-control-allow-methods": "GET, POST, OPTIONS",
141
+ "access-control-allow-headers": "Authorization, Content-Type, X-Requested-With",
142
+ "access-control-max-age": "86400",
143
+ };
144
+
145
+ /**
146
+ * Compute the per-request CORS origin + credentials + Vary triple.
147
+ *
148
+ * Browser caller (Origin header present): echo the origin, set
149
+ * `Allow-Credentials: true`, set `Vary: Origin` (cache correctness).
150
+ *
151
+ * Non-browser caller (no Origin): wildcard `*` + `Allow-Credentials: false`
152
+ * — safer when there's no specific origin to honor, matches the rc.17
153
+ * fallback shape for `curl`-style probes.
154
+ */
155
+ function corsOriginHeaders(req: Request): Record<string, string> {
156
+ const origin = req.headers.get("origin");
157
+ if (origin) {
158
+ return {
159
+ "access-control-allow-origin": origin,
160
+ "access-control-allow-credentials": "true",
161
+ vary: "Origin",
162
+ };
163
+ }
164
+ return {
165
+ "access-control-allow-origin": "*",
166
+ "access-control-allow-credentials": "false",
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Headers folded onto *actual* (non-preflight) responses for in-scope routes.
172
+ * Re-exported as a static lookup for tests + the rare caller that wants to
173
+ * spread the always-on subset (Expose-Headers) into a fresh Headers init.
174
+ *
175
+ * The dynamic Origin/Credentials/Vary triple is *not* here — it's a function
176
+ * of the incoming request's `Origin` header. Use `applyCorsHeaders(req,
177
+ * response)` to attach the full set.
178
+ */
179
+ export const CORS_RESPONSE_HEADERS = CORS_STATIC_RESPONSE_HEADERS;
180
+
181
+ /**
182
+ * Static portion of the preflight headers. Exported for tests that pin the
183
+ * method/header allowlist + max-age values. The per-request
184
+ * Origin/Credentials/Vary triple is computed in `corsPreflightResponse`.
185
+ */
186
+ export const CORS_PREFLIGHT_HEADERS: Readonly<Record<string, string>> = {
187
+ ...CORS_STATIC_RESPONSE_HEADERS,
188
+ ...CORS_STATIC_PREFLIGHT_HEADERS,
189
+ };
190
+
191
+ /**
192
+ * Does this pathname participate in the public-CORS surface this module owns?
193
+ *
194
+ * Matches the OAuth surface (`/oauth/...`) only. The four `/.well-known/*`
195
+ * documents (oauth-authorization-server, parachute.json, jwks.json,
196
+ * parachute-revocation.json) are *also* part of the cross-origin contract,
197
+ * but they each carry their own CORS handling inline in `hub-server.ts` (a
198
+ * narrower `Allow-Methods: GET, OPTIONS` since they're read-only) and
199
+ * predate this module. Including them here would mean two CORS code paths
200
+ * disagreeing on the method list; leaving them in their existing block keeps
201
+ * one CORS posture per route family.
202
+ *
203
+ * Anything else — admin/API/content/login — stays same-origin-only and must
204
+ * NOT pass through this predicate.
205
+ *
206
+ * Prefix-match on `/oauth/` (with trailing slash) so the bare path `/oauth`
207
+ * doesn't match — there's no route at `/oauth` and the prefix would
208
+ * accidentally widen if anyone later mounts something there.
209
+ */
210
+ export function isCorsAllowedRoute(pathname: string): boolean {
211
+ return pathname.startsWith("/oauth/");
212
+ }
213
+
214
+ /**
215
+ * 204 response for an OPTIONS preflight on an in-scope route.
216
+ *
217
+ * Browsers issue this before any non-simple cross-origin request (custom
218
+ * Content-Type, Authorization header, non-GET/POST/HEAD method). The response
219
+ * body is empty by spec; the browser only reads the headers.
220
+ *
221
+ * The Origin/Credentials/Vary triple is computed from the request's `Origin`
222
+ * header — see `corsOriginHeaders` for the per-request shape.
223
+ */
224
+ export function corsPreflightResponse(req: Request): Response {
225
+ return new Response(null, {
226
+ status: 204,
227
+ headers: {
228
+ ...corsOriginHeaders(req),
229
+ ...CORS_STATIC_RESPONSE_HEADERS,
230
+ ...CORS_STATIC_PREFLIGHT_HEADERS,
231
+ },
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Fold the CORS response headers onto an existing Response.
237
+ *
238
+ * Returns a *new* Response that shares the body but has the merged Headers.
239
+ * Existing headers on the input response take precedence — but the CORS
240
+ * headers don't typically collide with anything a handler would set, so in
241
+ * practice this just adds the three-to-four CORS headers.
242
+ *
243
+ * Why a new Response: Response.headers is immutable post-construction. We
244
+ * could mutate during the handler instead, but folding at the dispatcher
245
+ * level keeps the per-handler code free of CORS concerns and makes "which
246
+ * routes are CORS-friendly" a single-source-of-truth in `isCorsAllowedRoute`.
247
+ *
248
+ * The signature takes the request so the Origin echo + credentials posture
249
+ * can be computed per-call. The dispatcher in `hub-server.ts` already has
250
+ * the request in scope at every `applyCorsHeaders` call site.
251
+ */
252
+ export function applyCorsHeaders(req: Request, response: Response): Response {
253
+ const merged = new Headers(response.headers);
254
+ const dynamic = corsOriginHeaders(req);
255
+ for (const [k, v] of Object.entries({ ...dynamic, ...CORS_STATIC_RESPONSE_HEADERS })) {
256
+ if (!merged.has(k)) merged.set(k, v);
257
+ }
258
+ return new Response(response.body, {
259
+ status: response.status,
260
+ statusText: response.statusText,
261
+ headers: merged,
262
+ });
263
+ }
package/src/hub-db.ts CHANGED
@@ -193,6 +193,60 @@ const MIGRATIONS: readonly Migration[] = [
193
193
  CREATE INDEX tokens_subject ON tokens (subject) WHERE subject IS NOT NULL;
194
194
  `,
195
195
  },
196
+ {
197
+ version: 7,
198
+ sql: `
199
+ -- Hub-level key/value settings (hub#268). Used by:
200
+ -- * setup_expose_mode — operator's "how will this hub be reached?"
201
+ -- choice from the first-boot wizard expose step. Values:
202
+ -- 'localhost' | 'tailnet' | 'public'.
203
+ -- * pending_first_client_auto_approve_until — ISO-8601 timestamp
204
+ -- set when the wizard finishes; first OAuth client registration
205
+ -- within the window is auto-approved + the row cleared (single
206
+ -- use). Absent / past-due means the standard pending-approval
207
+ -- flow applies.
208
+ --
209
+ -- Single-row-per-key schema. updated_at lets us age out stale
210
+ -- entries if a future pattern needs it; nothing currently relies on
211
+ -- it. Bare KV — no audit log, no history — these are hub-local
212
+ -- operator preferences, not user-facing data.
213
+ CREATE TABLE hub_settings (
214
+ key TEXT PRIMARY KEY,
215
+ value TEXT NOT NULL,
216
+ updated_at TEXT NOT NULL
217
+ );
218
+ `,
219
+ },
220
+ {
221
+ version: 8,
222
+ sql: `
223
+ -- Multi-user Phase 1 (hub#252, design 2026-05-20-multi-user-phase-1.md).
224
+ -- Two columns on \`users\`:
225
+ --
226
+ -- * password_changed (INTEGER 0/1) — tracks whether a user has
227
+ -- changed their password since account creation. The admin-creates-
228
+ -- user flow (PR 2) lands new accounts with 0; the user's forced
229
+ -- change-password flow (PR 3) flips it to 1. SQLite has no native
230
+ -- BOOL, so 0/1 + a TS helper in users.ts handles the translation.
231
+ -- * assigned_vault (TEXT, nullable) — the vault instance name the
232
+ -- user is pinned to (Phase 1 is single-vault-per-user). NULL means
233
+ -- "no per-vault restriction" — the wizard's first admin and any
234
+ -- other admin-role user. The OAuth issuer (PR 4) reads this at
235
+ -- mint time to narrow the token's vault scope. No FK: vault names
236
+ -- resolve through services.json, not a DB row.
237
+ --
238
+ -- Backfill: every existing user pre-dates this migration. The only
239
+ -- accounts that could exist are the wizard's first admin (chose their
240
+ -- own password via the wizard form) or env-seeded admins (operator
241
+ -- baked the password into PARACHUTE_INITIAL_ADMIN_PASSWORD). Both
242
+ -- already-chosen-by-the-account-holder paths, so flip every existing
243
+ -- row to password_changed=1 — no spurious force-change on first sign-
244
+ -- in for already-bootstrapped hubs.
245
+ ALTER TABLE users ADD COLUMN password_changed INTEGER NOT NULL DEFAULT 0;
246
+ ALTER TABLE users ADD COLUMN assigned_vault TEXT;
247
+ UPDATE users SET password_changed = 1;
248
+ `,
249
+ },
196
250
  ];
197
251
 
198
252
  export function openHubDb(path: string = hubDbPath()): Database {
package/src/hub-server.ts CHANGED
@@ -42,9 +42,10 @@
42
42
  * /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
43
43
  * /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
44
44
  * /api/modules (GET) → curated + installed module catalog (host:auth)
45
+ * /api/modules/channel (PUT) → operator channel toggle (host:admin)
45
46
  * /api/modules/:short/install (POST) → bun add + spawn (async op)
46
47
  * /api/modules/:short/restart (POST) → supervisor restart (sync)
47
- * /api/modules/:short/upgrade (POST) → bun add @latest + restart (async op)
48
+ * /api/modules/:short/upgrade (POST) → bun add @<channel> + restart (async op)
48
49
  * /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
49
50
  * /api/modules/operations/:id (GET) → poll async op status
50
51
  * /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
@@ -54,8 +55,15 @@
54
55
  * /api/grants/<client_id> (DELETE) → revoke a single OAuth grant
55
56
  * /api/oauth/clients/<id> (GET) → OAuth client details
56
57
  * /api/oauth/clients/<id>/approve (POST) → flip a pending client to approved
58
+ * /api/users (GET + POST) → list / create user (host:admin)
59
+ * /api/users/vaults (GET) → vault-name list for assigned-vault picker (host:admin)
60
+ * /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
57
61
  * /login (GET + POST) → operator password login
58
62
  * /logout (POST) → end admin session
63
+ * /account/change-password (GET + POST) → user self-service change-password
64
+ * (force-redirect target for users
65
+ * with password_changed=false; also
66
+ * reachable directly to rotate)
59
67
  * /admin/config* → 301 → /admin/vaults (legacy
60
68
  * portal retired post-SPA-rework)
61
69
  *
@@ -102,6 +110,7 @@ import {
102
110
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
103
111
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
104
112
  import { handleCreateVault } from "./admin-vaults.ts";
113
+ import { handleAccountChangePasswordGet, handleAccountChangePasswordPost } from "./api-account.ts";
105
114
  import { handleApiMe } from "./api-me.ts";
106
115
  import { handleApiMintToken } from "./api-mint-token.ts";
107
116
  import {
@@ -113,11 +122,18 @@ import {
113
122
  handleUpgrade,
114
123
  parseModulesPath,
115
124
  } from "./api-modules-ops.ts";
116
- import { handleApiModules } from "./api-modules.ts";
125
+ import { handleApiModules, handleApiModulesChannel } from "./api-modules.ts";
117
126
  import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
118
127
  import { handleApiRevokeToken } from "./api-revoke-token.ts";
119
128
  import { handleApiTokens } from "./api-tokens.ts";
129
+ import {
130
+ handleCreateUser,
131
+ handleDeleteUser,
132
+ handleListUsers,
133
+ handleListVaults,
134
+ } from "./api-users.ts";
120
135
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
136
+ import { applyCorsHeaders, corsPreflightResponse, isCorsAllowedRoute } from "./cors.ts";
121
137
  import { ensureCsrfToken } from "./csrf.ts";
122
138
  import { readExposeState } from "./expose-state.ts";
123
139
  import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
@@ -149,7 +165,9 @@ import { findActiveSession } from "./sessions.ts";
149
165
  import {
150
166
  type SetupWizardDeps,
151
167
  handleSetupAccountPost,
168
+ handleSetupExposePost,
152
169
  handleSetupGet,
170
+ handleSetupInstallPost,
153
171
  handleSetupVaultPost,
154
172
  } from "./setup-wizard.ts";
155
173
  import { getAllPublicKeys } from "./signing-keys.ts";
@@ -962,6 +980,22 @@ export function hubFetch(
962
980
  });
963
981
  }
964
982
 
983
+ // CORS preflight for the public OAuth + discovery surface. Browsers
984
+ // issue OPTIONS before any non-simple cross-origin request — third-party
985
+ // SPAs hitting `/oauth/register` (RFC 7591 DCR), `/oauth/token`,
986
+ // `/.well-known/oauth-authorization-server`, etc. Handling this above
987
+ // the route table means an OPTIONS to e.g. `/oauth/register` doesn't
988
+ // hit the method-not-allowed branch in the handler — the preflight is a
989
+ // CORS-protocol artifact, not a "real" request to the endpoint. The
990
+ // single `isCorsAllowedRoute` predicate is the source of truth for
991
+ // which paths carry wildcard-CORS; see `src/cors.ts` for the rationale.
992
+ // Out-of-scope paths (`/api/*`, `/admin/*`, `/login`, `/account/*`,
993
+ // `/vault/*`, generic service proxy) fall through and OPTIONS reaches
994
+ // whatever default the downstream handler enforces (typically 405).
995
+ if (req.method === "OPTIONS" && isCorsAllowedRoute(pathname)) {
996
+ return corsPreflightResponse(req);
997
+ }
998
+
965
999
  // Platform health check (Render, Fly, Kubernetes, etc.). Plain JSON,
966
1000
  // no DB required — the route reports liveness, not readiness. Anything
967
1001
  // more invasive (DB ping, schema check) would let a transient lock turn
@@ -1010,6 +1044,20 @@ export function hubFetch(
1010
1044
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1011
1045
  return handleSetupVaultPost(req, wizardDeps);
1012
1046
  }
1047
+ if (pathname === "/admin/setup/expose") {
1048
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1049
+ return handleSetupExposePost(req, wizardDeps);
1050
+ }
1051
+ // hub#272 Item B: post-wizard direct module-install POSTs from
1052
+ // the done-screen "What's next?" tiles. Path shape is
1053
+ // `/admin/setup/install/<short>`; the handler rejects on
1054
+ // unknown shorts, on `vault` (the wizard's own step owns that),
1055
+ // and on missing session/CSRF — same gates as the vault POST.
1056
+ if (pathname.startsWith("/admin/setup/install/")) {
1057
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1058
+ const short = pathname.slice("/admin/setup/install/".length);
1059
+ return handleSetupInstallPost(req, short, wizardDeps);
1060
+ }
1013
1061
  return new Response("not found", { status: 404 });
1014
1062
  }
1015
1063
 
@@ -1103,9 +1151,17 @@ export function hubFetch(
1103
1151
  // cross-origin from their own loopback port. Wildcard CORS is the
1104
1152
  // shape it needs. Browsers send an OPTIONS preflight when the request
1105
1153
  // adds non-simple headers; answer it with 204 + the same allow-list.
1154
+ //
1155
+ // `cache-control: no-store` matters here: the discovery page (`/`)
1156
+ // fetches this doc and renders Service tiles from it; without
1157
+ // no-store, the browser's HTTP cache returns the stale services list
1158
+ // the next time the operator navigates back to `/` after installing
1159
+ // a module via the admin SPA. The doc is small and built per-request
1160
+ // anyway, so giving up cacheability has no real cost (hub#268 Item 1).
1106
1161
  const corsHeaders = {
1107
1162
  "access-control-allow-origin": "*",
1108
1163
  "access-control-allow-methods": "GET, OPTIONS",
1164
+ "cache-control": "no-store",
1109
1165
  };
1110
1166
  if (req.method === "OPTIONS") {
1111
1167
  return new Response(null, { status: 204, headers: corsHeaders });
@@ -1219,11 +1275,22 @@ export function hubFetch(
1219
1275
  return new Response(res.body, { status: res.status, headers: merged });
1220
1276
  }
1221
1277
 
1278
+ // OAuth surface — every handler return is wrapped in `applyCorsHeaders`
1279
+ // so third-party SPAs can fetch these endpoints cross-origin (the entire
1280
+ // point of OAuth DCR: arbitrary SPAs register → authorize → exchange
1281
+ // tokens). Preflight OPTIONS already returned at the top of dispatch.
1282
+ // See `src/cors.ts` for the wildcard-origin rationale.
1222
1283
  if (pathname === "/oauth/authorize") {
1223
- if (!getDb) return dbNotConfigured();
1224
- if (req.method === "GET") return handleAuthorizeGet(getDb(), req, oauthDeps(req));
1225
- if (req.method === "POST") return handleAuthorizePost(getDb(), req, oauthDeps(req));
1226
- return new Response("method not allowed", { status: 405 });
1284
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1285
+ if (req.method === "GET") {
1286
+ // handleAuthorizeGet is sync (returns Response, not Promise<Response>).
1287
+ // handleAuthorizePost is async keep the await on POST only.
1288
+ return applyCorsHeaders(req, handleAuthorizeGet(getDb(), req, oauthDeps(req)));
1289
+ }
1290
+ if (req.method === "POST") {
1291
+ return applyCorsHeaders(req, await handleAuthorizePost(getDb(), req, oauthDeps(req)));
1292
+ }
1293
+ return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1227
1294
  }
1228
1295
 
1229
1296
  // Inline approve form for the operator-driven pending-client flow (#208).
@@ -1231,27 +1298,35 @@ export function hubFetch(
1231
1298
  // by handleAuthorizeGet when the operator hits a pending client. Three
1232
1299
  // gates inside the handler: CSRF, active session, same-origin Origin.
1233
1300
  if (pathname === "/oauth/authorize/approve") {
1234
- if (!getDb) return dbNotConfigured();
1235
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1236
- return handleApproveClientPost(getDb(), req, oauthDeps(req));
1301
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1302
+ if (req.method !== "POST") {
1303
+ return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1304
+ }
1305
+ return applyCorsHeaders(req, await handleApproveClientPost(getDb(), req, oauthDeps(req)));
1237
1306
  }
1238
1307
 
1239
1308
  if (pathname === "/oauth/token") {
1240
- if (!getDb) return dbNotConfigured();
1241
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1242
- return handleToken(getDb(), req, oauthDeps(req));
1309
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1310
+ if (req.method !== "POST") {
1311
+ return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1312
+ }
1313
+ return applyCorsHeaders(req, await handleToken(getDb(), req, oauthDeps(req)));
1243
1314
  }
1244
1315
 
1245
1316
  if (pathname === "/oauth/register") {
1246
- if (!getDb) return dbNotConfigured();
1247
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1248
- return handleRegister(getDb(), req, oauthDeps(req));
1317
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1318
+ if (req.method !== "POST") {
1319
+ return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1320
+ }
1321
+ return applyCorsHeaders(req, await handleRegister(getDb(), req, oauthDeps(req)));
1249
1322
  }
1250
1323
 
1251
1324
  if (pathname === "/oauth/revoke") {
1252
- if (!getDb) return dbNotConfigured();
1253
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1254
- return handleRevoke(getDb(), req, oauthDeps(req));
1325
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1326
+ if (req.method !== "POST") {
1327
+ return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1328
+ }
1329
+ return applyCorsHeaders(req, await handleRevoke(getDb(), req, oauthDeps(req)));
1255
1330
  }
1256
1331
 
1257
1332
  if (pathname === "/vaults") {
@@ -1311,6 +1386,18 @@ export function hubFetch(
1311
1386
  return handleApiModules(req, modulesDeps);
1312
1387
  }
1313
1388
 
1389
+ // Channel toggle (hub#275) — pre-empts the /api/modules/:short/*
1390
+ // routes below so `/api/modules/channel` doesn't accidentally match
1391
+ // `parseModulesPath` (which would reject it as a non-curated short
1392
+ // anyway, but precedence makes the intent explicit).
1393
+ if (pathname === "/api/modules/channel") {
1394
+ if (!getDb) return dbNotConfigured();
1395
+ return handleApiModulesChannel(req, {
1396
+ db: getDb(),
1397
+ issuer: oauthDeps(req).issuer,
1398
+ });
1399
+ }
1400
+
1314
1401
  // Module operation poll surface — pre-empts the /api/modules/:short/*
1315
1402
  // routes below so `/api/modules/operations/<uuid>` doesn't accidentally
1316
1403
  // match a parseModulesPath("/operations") and fall through.
@@ -1448,6 +1535,45 @@ export function hubFetch(
1448
1535
  });
1449
1536
  }
1450
1537
 
1538
+ // Multi-user Phase 1 admin endpoints (hub#252, design 2026-05-20).
1539
+ // `/api/users` collection (GET list / POST create) and
1540
+ // `/api/users/vaults` for the assigned-vault picker. Per-id route
1541
+ // `/api/users/:id` (DELETE only — Phase 1 doesn't ship edit) is
1542
+ // handled by the `startsWith("/api/users/")` branch below, with the
1543
+ // `/api/users/vaults` sub-path pre-empted *before* the catch-all so
1544
+ // a literal `vaults` segment can't be mistaken for a user id.
1545
+ if (pathname === "/api/users") {
1546
+ if (!getDb) return dbNotConfigured();
1547
+ const usersDeps = {
1548
+ db: getDb(),
1549
+ issuer: oauthDeps(req).issuer,
1550
+ manifestPath,
1551
+ };
1552
+ if (req.method === "GET") return handleListUsers(req, usersDeps);
1553
+ if (req.method === "POST") return handleCreateUser(req, usersDeps);
1554
+ return new Response("method not allowed", { status: 405 });
1555
+ }
1556
+ if (pathname === "/api/users/vaults") {
1557
+ if (!getDb) return dbNotConfigured();
1558
+ return handleListVaults(req, {
1559
+ db: getDb(),
1560
+ issuer: oauthDeps(req).issuer,
1561
+ manifestPath,
1562
+ });
1563
+ }
1564
+ if (pathname.startsWith("/api/users/")) {
1565
+ if (!getDb) return dbNotConfigured();
1566
+ const id = decodeURIComponent(pathname.slice("/api/users/".length));
1567
+ if (!id || id.includes("/")) {
1568
+ return new Response("not found", { status: 404 });
1569
+ }
1570
+ return handleDeleteUser(req, id, {
1571
+ db: getDb(),
1572
+ issuer: oauthDeps(req).issuer,
1573
+ manifestPath,
1574
+ });
1575
+ }
1576
+
1451
1577
  // Canonical login/logout. The handlers themselves are unchanged from
1452
1578
  // when they lived at /admin/login + /admin/logout; the rename surfaced
1453
1579
  // via #231-followup so the URL reflects the surface's actual scope
@@ -1467,6 +1593,24 @@ export function hubFetch(
1467
1593
  return handleAdminLogoutPost(getDb(), req);
1468
1594
  }
1469
1595
 
1596
+ // Multi-user Phase 1 PR 3 — user self-service change-password surface
1597
+ // (hub#252, design §sign-in flow change). Both GET (render form) and
1598
+ // POST (apply change) require a session cookie. The handler itself
1599
+ // does the session check + 302 to /login when missing — same posture
1600
+ // as the rest of /account/* will use as Phase 2 broadens this prefix.
1601
+ //
1602
+ // This route is intentionally NOT gated by `password_changed === false`
1603
+ // — that's only the *redirect* path from /login. A signed-in user with
1604
+ // `password_changed: true` can still navigate here to rotate their
1605
+ // password (design §"Direct navigation").
1606
+ if (pathname === "/account/change-password") {
1607
+ if (!getDb) return dbNotConfigured();
1608
+ const accountDeps = { db: getDb() };
1609
+ if (req.method === "GET") return handleAccountChangePasswordGet(req, accountDeps);
1610
+ if (req.method === "POST") return handleAccountChangePasswordPost(req, accountDeps);
1611
+ return new Response("method not allowed", { status: 405 });
1612
+ }
1613
+
1470
1614
  // Legacy `/admin/config` (server-rendered module-config portal, #46)
1471
1615
  // retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
1472
1616
  // post-login redirect lands somewhere useful. The route stays here in