@openparachute/hub 0.5.10-rc.6 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +139 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-server.test.ts +29 -4
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +1059 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +1500 -13
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +30 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +54 -0
- package/src/hub-server.ts +162 -18
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +34 -9
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +256 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +1100 -56
- package/src/supervisor.ts +66 -14
- package/src/users.ts +210 -3
- package/src/vault-name.ts +71 -0
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/src/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
|
|
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")
|
|
1225
|
-
|
|
1226
|
-
|
|
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")
|
|
1236
|
-
|
|
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")
|
|
1242
|
-
|
|
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")
|
|
1248
|
-
|
|
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")
|
|
1254
|
-
|
|
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
|