@openparachute/hub 0.5.10-rc.9 → 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 +74 -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-settings.test.ts +152 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +912 -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 +216 -3
- package/src/__tests__/users.test.ts +196 -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 +15 -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 +30 -0
- package/src/hub-server.ts +138 -18
- package/src/hub-settings.ts +98 -1
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +237 -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 +134 -16
- package/src/users.ts +210 -3
- 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
|
@@ -217,6 +217,36 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
217
217
|
);
|
|
218
218
|
`,
|
|
219
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
|
+
},
|
|
220
250
|
];
|
|
221
251
|
|
|
222
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";
|
|
@@ -964,6 +980,22 @@ export function hubFetch(
|
|
|
964
980
|
});
|
|
965
981
|
}
|
|
966
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
|
+
|
|
967
999
|
// Platform health check (Render, Fly, Kubernetes, etc.). Plain JSON,
|
|
968
1000
|
// no DB required — the route reports liveness, not readiness. Anything
|
|
969
1001
|
// more invasive (DB ping, schema check) would let a transient lock turn
|
|
@@ -1243,11 +1275,22 @@ export function hubFetch(
|
|
|
1243
1275
|
return new Response(res.body, { status: res.status, headers: merged });
|
|
1244
1276
|
}
|
|
1245
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.
|
|
1246
1283
|
if (pathname === "/oauth/authorize") {
|
|
1247
|
-
if (!getDb) return dbNotConfigured();
|
|
1248
|
-
if (req.method === "GET")
|
|
1249
|
-
|
|
1250
|
-
|
|
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 }));
|
|
1251
1294
|
}
|
|
1252
1295
|
|
|
1253
1296
|
// Inline approve form for the operator-driven pending-client flow (#208).
|
|
@@ -1255,27 +1298,35 @@ export function hubFetch(
|
|
|
1255
1298
|
// by handleAuthorizeGet when the operator hits a pending client. Three
|
|
1256
1299
|
// gates inside the handler: CSRF, active session, same-origin Origin.
|
|
1257
1300
|
if (pathname === "/oauth/authorize/approve") {
|
|
1258
|
-
if (!getDb) return dbNotConfigured();
|
|
1259
|
-
if (req.method !== "POST")
|
|
1260
|
-
|
|
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)));
|
|
1261
1306
|
}
|
|
1262
1307
|
|
|
1263
1308
|
if (pathname === "/oauth/token") {
|
|
1264
|
-
if (!getDb) return dbNotConfigured();
|
|
1265
|
-
if (req.method !== "POST")
|
|
1266
|
-
|
|
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)));
|
|
1267
1314
|
}
|
|
1268
1315
|
|
|
1269
1316
|
if (pathname === "/oauth/register") {
|
|
1270
|
-
if (!getDb) return dbNotConfigured();
|
|
1271
|
-
if (req.method !== "POST")
|
|
1272
|
-
|
|
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)));
|
|
1273
1322
|
}
|
|
1274
1323
|
|
|
1275
1324
|
if (pathname === "/oauth/revoke") {
|
|
1276
|
-
if (!getDb) return dbNotConfigured();
|
|
1277
|
-
if (req.method !== "POST")
|
|
1278
|
-
|
|
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)));
|
|
1279
1330
|
}
|
|
1280
1331
|
|
|
1281
1332
|
if (pathname === "/vaults") {
|
|
@@ -1335,6 +1386,18 @@ export function hubFetch(
|
|
|
1335
1386
|
return handleApiModules(req, modulesDeps);
|
|
1336
1387
|
}
|
|
1337
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
|
+
|
|
1338
1401
|
// Module operation poll surface — pre-empts the /api/modules/:short/*
|
|
1339
1402
|
// routes below so `/api/modules/operations/<uuid>` doesn't accidentally
|
|
1340
1403
|
// match a parseModulesPath("/operations") and fall through.
|
|
@@ -1472,6 +1535,45 @@ export function hubFetch(
|
|
|
1472
1535
|
});
|
|
1473
1536
|
}
|
|
1474
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
|
+
|
|
1475
1577
|
// Canonical login/logout. The handlers themselves are unchanged from
|
|
1476
1578
|
// when they lived at /admin/login + /admin/logout; the rename surfaced
|
|
1477
1579
|
// via #231-followup so the URL reflects the surface's actual scope
|
|
@@ -1491,6 +1593,24 @@ export function hubFetch(
|
|
|
1491
1593
|
return handleAdminLogoutPost(getDb(), req);
|
|
1492
1594
|
}
|
|
1493
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
|
+
|
|
1494
1614
|
// Legacy `/admin/config` (server-rendered module-config portal, #46)
|
|
1495
1615
|
// retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
|
|
1496
1616
|
// post-login redirect lands somewhere useful. The route stays here in
|
package/src/hub-settings.ts
CHANGED
|
@@ -41,7 +41,18 @@ export type HubSettingKey =
|
|
|
41
41
|
// install-command snippet without re-deriving from services.json
|
|
42
42
|
// (vault's first-boot may write its own paths shape that the wizard
|
|
43
43
|
// can't trust to match `<name>` exactly until the spawn settles).
|
|
44
|
-
| "setup_vault_name"
|
|
44
|
+
| "setup_vault_name"
|
|
45
|
+
// hub#275: which dist-tag the runtime module installer uses
|
|
46
|
+
// (`bun add -g <pkg>@<channel>`). `"latest"` (default) tracks the
|
|
47
|
+
// stable channel; `"rc"` follows the release-candidate chain so
|
|
48
|
+
// operators on the rc cadence can pull pre-release builds without
|
|
49
|
+
// hand-editing the install command. Seeded from
|
|
50
|
+
// `PARACHUTE_MODULE_CHANNEL` on first read (operator can ship a fresh
|
|
51
|
+
// box with the env var set and have the row land with their preferred
|
|
52
|
+
// channel); after the first seed the row is source of truth and the
|
|
53
|
+
// env var is ignored — admin must use the SPA toggle (or
|
|
54
|
+
// `PUT /api/modules/channel`) to change channel.
|
|
55
|
+
| "module_install_channel";
|
|
45
56
|
|
|
46
57
|
export type SetupExposeMode = "localhost" | "tailnet" | "public";
|
|
47
58
|
|
|
@@ -160,3 +171,89 @@ export function consumeFirstClientAutoApproveWindow(
|
|
|
160
171
|
deleteSetting(db, "pending_first_client_auto_approve_until");
|
|
161
172
|
return true;
|
|
162
173
|
}
|
|
174
|
+
|
|
175
|
+
// --- domain helpers: module install channel ------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Which dist-tag `bun add -g <pkg>@<channel>` should use. `"latest"`
|
|
179
|
+
* tracks the stable channel; `"rc"` tracks pre-release builds. Exposed
|
|
180
|
+
* here (not buried in api-modules-ops) because the admin SPA reads it
|
|
181
|
+
* via `/api/modules` and writes it via `/api/modules/channel` — the
|
|
182
|
+
* setting is the cross-cutting source of truth, the install path is
|
|
183
|
+
* one consumer.
|
|
184
|
+
*/
|
|
185
|
+
export type ModuleInstallChannel = "latest" | "rc";
|
|
186
|
+
|
|
187
|
+
/** Exported so the API handler + the SPA toggle can share validation. */
|
|
188
|
+
export const MODULE_INSTALL_CHANNELS: readonly ModuleInstallChannel[] = ["latest", "rc"];
|
|
189
|
+
|
|
190
|
+
export function isModuleInstallChannel(s: unknown): s is ModuleInstallChannel {
|
|
191
|
+
return typeof s === "string" && MODULE_INSTALL_CHANNELS.includes(s as ModuleInstallChannel);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Env var that seeds `module_install_channel` on first read. Read only
|
|
196
|
+
* when the hub_settings row is absent — once the row exists, the env
|
|
197
|
+
* var is ignored on subsequent boots (admin must use the SPA toggle or
|
|
198
|
+
* the API to change channel). Lets Aaron's fresh-machine deploys ship
|
|
199
|
+
* with `PARACHUTE_MODULE_CHANNEL=rc` baked into the platform's env
|
|
200
|
+
* config without baking the channel into the binary or first-boot.
|
|
201
|
+
*/
|
|
202
|
+
export const PARACHUTE_MODULE_CHANNEL_ENV = "PARACHUTE_MODULE_CHANNEL";
|
|
203
|
+
|
|
204
|
+
/** Fallback when nothing else is set — the stable channel. */
|
|
205
|
+
export const DEFAULT_MODULE_INSTALL_CHANNEL: ModuleInstallChannel = "latest";
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read the configured module install channel. On first call (no row in
|
|
209
|
+
* hub_settings), seeds from `process.env.PARACHUTE_MODULE_CHANNEL` if
|
|
210
|
+
* valid, otherwise defaults to `"latest"` (an invalid env value warns
|
|
211
|
+
* + still falls back to "latest"). After that first seed, the
|
|
212
|
+
* hub_settings row is source of truth.
|
|
213
|
+
*
|
|
214
|
+
* The `env` + `warn` knobs are test seams — production uses
|
|
215
|
+
* `process.env` + `console.warn`. Tests inject a deterministic shape so
|
|
216
|
+
* the warn-on-invalid branch can be asserted without console-capture.
|
|
217
|
+
*/
|
|
218
|
+
export function getModuleInstallChannel(
|
|
219
|
+
db: Database,
|
|
220
|
+
opts: {
|
|
221
|
+
env?: NodeJS.ProcessEnv;
|
|
222
|
+
warn?: (msg: string) => void;
|
|
223
|
+
} = {},
|
|
224
|
+
): ModuleInstallChannel {
|
|
225
|
+
const existing = getSetting(db, "module_install_channel");
|
|
226
|
+
if (existing !== undefined) {
|
|
227
|
+
// Row already seeded — trust it. If somehow corrupted (manual sqlite
|
|
228
|
+
// edit, schema drift), fall back to "latest" silently rather than
|
|
229
|
+
// crashing the install path. Re-seeding the row is left to the
|
|
230
|
+
// admin's explicit setModuleInstallChannel call.
|
|
231
|
+
if (isModuleInstallChannel(existing)) return existing;
|
|
232
|
+
return DEFAULT_MODULE_INSTALL_CHANNEL;
|
|
233
|
+
}
|
|
234
|
+
const env = opts.env ?? process.env;
|
|
235
|
+
const warn = opts.warn ?? ((msg: string) => console.warn(msg));
|
|
236
|
+
const fromEnv = env[PARACHUTE_MODULE_CHANNEL_ENV];
|
|
237
|
+
let seed: ModuleInstallChannel = DEFAULT_MODULE_INSTALL_CHANNEL;
|
|
238
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
239
|
+
if (isModuleInstallChannel(fromEnv)) {
|
|
240
|
+
seed = fromEnv;
|
|
241
|
+
} else {
|
|
242
|
+
warn(
|
|
243
|
+
`[hub-settings] ${PARACHUTE_MODULE_CHANNEL_ENV}="${fromEnv}" is not a valid channel — expected one of ${MODULE_INSTALL_CHANNELS.join(", ")}. Falling back to "${DEFAULT_MODULE_INSTALL_CHANNEL}".`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
setSetting(db, "module_install_channel", seed);
|
|
248
|
+
return seed;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Write the module install channel. Validated by the caller (the API
|
|
253
|
+
* handler + the SPA already constrain to the union); the function
|
|
254
|
+
* itself only accepts the typed shape, so a TypeScript-clean callsite
|
|
255
|
+
* can't write a malformed value.
|
|
256
|
+
*/
|
|
257
|
+
export function setModuleInstallChannel(db: Database, channel: ModuleInstallChannel): void {
|
|
258
|
+
setSetting(db, "module_install_channel", channel);
|
|
259
|
+
}
|