@openparachute/hub 0.5.10 → 0.5.12-rc.2

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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * `GET|PUT /api/settings/hub-origin` — operator-settable canonical hub
3
+ * URL (hub#298).
4
+ *
5
+ * The stored value is the OAuth issuer claim hub stamps into every JWT.
6
+ * Precedence chain (per `resolveIssuer` in hub-server.ts):
7
+ *
8
+ * 1. hub_settings.hub_origin (this endpoint writes here)
9
+ * 2. PARACHUTE_HUB_ORIGIN env / --issuer flag
10
+ * 3. request origin (local-dev fallback)
11
+ *
12
+ * The endpoint surfaces both the stored value *and* the resolved value
13
+ * + source so the SPA can render "current: https://… (from env)" while
14
+ * the input shows the empty stored row. That separation matters: an
15
+ * operator looking at the SPA needs to tell "this hub already has a
16
+ * canonical URL configured via env" apart from "no canonical URL —
17
+ * tokens carry the request origin."
18
+ *
19
+ * Bearer-gated on `parachute:host:admin` (same scope as
20
+ * `/api/modules/channel`): flipping the issuer claim invalidates any
21
+ * tokens already in circulation against the prior issuer, so it's a
22
+ * destructive-ish operator-only action.
23
+ *
24
+ * URL validation on PUT:
25
+ * - Must parse via `new URL()`.
26
+ * - Scheme must be `http:` or `https:` (no `file:`, no protocol-
27
+ * relative). Bare hostnames don't parse without a scheme and are
28
+ * rejected upstream by URL.
29
+ * - Must have a hostname (rejects `https:///path`).
30
+ * - No trailing slash (the stored value is concatenated into JWT iss
31
+ * claims + well-known URLs — a trailing slash would produce
32
+ * `https://host//.well-known/…`).
33
+ * - No path / query / fragment (only origin shape allowed).
34
+ *
35
+ * The shape mirrors `handleApiModulesChannel` for consistency — same
36
+ * Bearer parsing, same scope-check posture, same error vocabulary.
37
+ */
38
+
39
+ import type { Database } from "bun:sqlite";
40
+ import type { IssuerSource } from "./hub-server.ts";
41
+ import { getHubOrigin, setHubOrigin } from "./hub-settings.ts";
42
+ import { validateAccessToken } from "./jwt-sign.ts";
43
+
44
+ /** Scope required on the bearer token to call either endpoint. */
45
+ export const API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE = "parachute:host:admin";
46
+
47
+ export interface ApiSettingsHubOriginDeps {
48
+ db: Database;
49
+ issuer: string;
50
+ /**
51
+ * The currently-resolved issuer + its source layer. Computed by the
52
+ * dispatcher (which has the request + `configuredIssuer` already in
53
+ * hand) and threaded through so this handler doesn't have to re-do
54
+ * the precedence walk. Returned on GET.
55
+ */
56
+ resolvedIssuer: string;
57
+ resolvedSource: IssuerSource;
58
+ }
59
+
60
+ interface GetResponseBody {
61
+ /** Stored value from hub_settings.hub_origin, or null. */
62
+ hub_origin: string | null;
63
+ /** Resolved issuer applied to this request (precedence-aware). */
64
+ resolved_issuer: string;
65
+ /** Which precedence layer the resolved value came from. */
66
+ source: IssuerSource;
67
+ }
68
+
69
+ interface PutResponseBody {
70
+ /** Echo of the now-stored value (null if cleared). */
71
+ hub_origin: string | null;
72
+ }
73
+
74
+ /**
75
+ * Validation outcome. The "normalized" branch is what gets passed to
76
+ * setHubOrigin — string or null. Errors carry the field name + a short
77
+ * description that flows into the 400 error_description for an
78
+ * operator-friendly message.
79
+ */
80
+ type ValidateOutcome = { ok: true; normalized: string | null } | { ok: false; description: string };
81
+
82
+ /**
83
+ * Validate the body's `hub_origin` field. Accepts:
84
+ * - `null` → clear the stored value, revert to env/request precedence.
85
+ * - A `http:` or `https:` URL string with a hostname, no trailing slash,
86
+ * no path/query/fragment.
87
+ * Everything else → 400.
88
+ */
89
+ export function validateHubOrigin(value: unknown): ValidateOutcome {
90
+ if (value === null) return { ok: true, normalized: null };
91
+ if (typeof value !== "string") {
92
+ return {
93
+ ok: false,
94
+ description: `hub_origin must be a string or null (got ${typeof value})`,
95
+ };
96
+ }
97
+ if (value.length === 0) {
98
+ // Empty string is a footgun shape — store as null instead. We don't
99
+ // want a row that resolveIssuer would skip as falsy while
100
+ // resolveIssuerSource claims "from settings."
101
+ return { ok: true, normalized: null };
102
+ }
103
+ if (value.endsWith("/")) {
104
+ return {
105
+ ok: false,
106
+ description: "hub_origin must not have a trailing slash",
107
+ };
108
+ }
109
+ let parsed: URL;
110
+ try {
111
+ parsed = new URL(value);
112
+ } catch {
113
+ return {
114
+ ok: false,
115
+ description: "hub_origin must be a valid URL",
116
+ };
117
+ }
118
+ // Reject embedded credentials explicitly. The normalization step below
119
+ // re-stringifies as `protocol + "//" + host`, which would silently strip
120
+ // any user:pass component — an operator who typos credentials in
121
+ // wouldn't notice the strip. Surface it as a hard error instead.
122
+ if (parsed.username || parsed.password) {
123
+ return {
124
+ ok: false,
125
+ description: "hub_origin must not include credentials",
126
+ };
127
+ }
128
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
129
+ return {
130
+ ok: false,
131
+ description: `hub_origin scheme must be http: or https: (got ${parsed.protocol})`,
132
+ };
133
+ }
134
+ if (!parsed.hostname) {
135
+ return {
136
+ ok: false,
137
+ description: "hub_origin must have a hostname",
138
+ };
139
+ }
140
+ // Disallow path/query/fragment — the stored value is concatenated
141
+ // into iss claims and well-known URLs. `new URL("https://host")`
142
+ // returns `pathname === "/"` so accept that as the canonical empty.
143
+ if (parsed.pathname !== "" && parsed.pathname !== "/") {
144
+ return {
145
+ ok: false,
146
+ description: "hub_origin must not include a path",
147
+ };
148
+ }
149
+ if (parsed.search) {
150
+ return {
151
+ ok: false,
152
+ description: "hub_origin must not include a query string",
153
+ };
154
+ }
155
+ if (parsed.hash) {
156
+ return {
157
+ ok: false,
158
+ description: "hub_origin must not include a fragment",
159
+ };
160
+ }
161
+ // Normalize: URL stringifies host-only inputs with a trailing slash
162
+ // (`new URL("https://host").toString() === "https://host/"`). The
163
+ // stored shape is the bare origin — strip it back out.
164
+ const normalized = `${parsed.protocol}//${parsed.host}`;
165
+ return { ok: true, normalized };
166
+ }
167
+
168
+ export async function handleApiSettingsHubOrigin(
169
+ req: Request,
170
+ deps: ApiSettingsHubOriginDeps,
171
+ ): Promise<Response> {
172
+ if (req.method !== "GET" && req.method !== "PUT") {
173
+ return jsonError(405, "method_not_allowed", "use GET or PUT");
174
+ }
175
+
176
+ // Bearer presence + parsing — identical shape to api-modules
177
+ // for consistency across hub-internal admin endpoints.
178
+ const auth = req.headers.get("authorization");
179
+ if (!auth || !auth.startsWith("Bearer ")) {
180
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
181
+ }
182
+ const bearer = auth.slice("Bearer ".length).trim();
183
+ if (!bearer) {
184
+ return jsonError(401, "unauthenticated", "empty bearer token");
185
+ }
186
+
187
+ // Bearer validation + scope check.
188
+ try {
189
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
190
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
191
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
192
+ }
193
+ const scopes =
194
+ typeof validated.payload.scope === "string"
195
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
196
+ : [];
197
+ if (!scopes.includes(API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE)) {
198
+ return jsonError(
199
+ 403,
200
+ "insufficient_scope",
201
+ `bearer token lacks ${API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE}`,
202
+ );
203
+ }
204
+ } catch (err) {
205
+ const msg = err instanceof Error ? err.message : String(err);
206
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
207
+ }
208
+
209
+ if (req.method === "GET") {
210
+ const body: GetResponseBody = {
211
+ hub_origin: getHubOrigin(deps.db),
212
+ resolved_issuer: deps.resolvedIssuer,
213
+ source: deps.resolvedSource,
214
+ };
215
+ return new Response(JSON.stringify(body), {
216
+ status: 200,
217
+ headers: { "content-type": "application/json" },
218
+ });
219
+ }
220
+
221
+ // PUT — parse + validate body.
222
+ let parsed: unknown;
223
+ try {
224
+ parsed = await req.json();
225
+ } catch {
226
+ return jsonError(400, "invalid_request", "request body must be JSON");
227
+ }
228
+ if (typeof parsed !== "object" || parsed === null) {
229
+ return jsonError(400, "invalid_request", "request body must be a JSON object");
230
+ }
231
+ if (!("hub_origin" in parsed)) {
232
+ return jsonError(400, "invalid_request", "request body must include a `hub_origin` field");
233
+ }
234
+ const result = validateHubOrigin((parsed as { hub_origin: unknown }).hub_origin);
235
+ if (!result.ok) {
236
+ return jsonError(400, "invalid_hub_origin", result.description);
237
+ }
238
+
239
+ setHubOrigin(deps.db, result.normalized);
240
+
241
+ const body: PutResponseBody = { hub_origin: result.normalized };
242
+ return new Response(JSON.stringify(body), {
243
+ status: 200,
244
+ headers: { "content-type": "application/json" },
245
+ });
246
+ }
247
+
248
+ function jsonError(status: number, code: string, description: string): Response {
249
+ return new Response(JSON.stringify({ error: code, error_description: description }), {
250
+ status,
251
+ headers: { "content-type": "application/json" },
252
+ });
253
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Bootstrap token for first-boot wizard claim (hub#297).
3
+ *
4
+ * On a fresh deploy with no admin row AND no `PARACHUTE_INITIAL_ADMIN_*`
5
+ * env-seed, the hub enters "wizard mode" — the `/admin/setup` URL is
6
+ * unauthenticated and the first POST to `/admin/setup/account` claims
7
+ * the admin. On a public-reachable deploy (Render, Fly, a tailnet with
8
+ * funnel on) the URL is reachable the moment the platform provisions
9
+ * the hostname, so an attacker who beats the operator to the form can
10
+ * claim the admin themselves. Same shape HashiCorp Vault hardens with
11
+ * its `vault operator init` unseal-key dance.
12
+ *
13
+ * This module is the gate. On hub start in wizard mode:
14
+ *
15
+ * 1. `generateBootstrapToken()` produces a fresh `parachute-bootstrap-<rand>`
16
+ * string. It lives in this module's in-memory state — never persisted,
17
+ * regenerated on every process restart so a leaked stale value can't
18
+ * claim a hub that's already been restarted past its window.
19
+ * 2. The caller logs it prominently on the startup banner so the
20
+ * operator (the only one with shell access to the box / Render logs)
21
+ * can copy it into the wizard form.
22
+ * 3. The wizard's account form prompts for the token; the POST handler
23
+ * calls `verifyBootstrapToken(...)` (constant-time compare) before
24
+ * letting `createUser` run. Wrong token → 401; right token →
25
+ * `consumeBootstrapToken()` clears the in-memory value so a later
26
+ * racer can't reuse it.
27
+ *
28
+ * Env-seeded admins (`PARACHUTE_INITIAL_ADMIN_USERNAME` +
29
+ * `PARACHUTE_INITIAL_ADMIN_PASSWORD`) bypass the token entirely — they've
30
+ * claimed the hub by setting env vars on the platform, so the wizard's
31
+ * account step is never reached. The token is generated only when
32
+ * `seedInitialAdminIfNeeded` returns `"needs-setup"`.
33
+ *
34
+ * Threading: the wizard reads the token via a getter injected into
35
+ * `SetupWizardDeps`, not via a direct module import. That keeps tests
36
+ * able to drive a known token without touching process state, and keeps
37
+ * the on-box CLI (`parachute expose`) able to skip token-gating entirely
38
+ * (it never enters wizard mode — the on-box operator already has shell
39
+ * access to call `parachute auth create-admin`).
40
+ */
41
+
42
+ import { randomBytes, timingSafeEqual } from "node:crypto";
43
+
44
+ const BOOTSTRAP_TOKEN_PREFIX = "parachute-bootstrap-";
45
+
46
+ /**
47
+ * Module-scoped, mutable. Set by `generateBootstrapToken` / `setBootstrapToken`,
48
+ * read by `getBootstrapToken`, cleared by `consumeBootstrapToken` (on
49
+ * successful claim) or `clearBootstrapToken` (test cleanup). Never written
50
+ * to disk. Re-generated on every hub start when the wizard-mode condition
51
+ * holds; absent otherwise (env-seed path or admin-exists path skips
52
+ * generation altogether).
53
+ */
54
+ let currentToken: string | undefined;
55
+
56
+ /**
57
+ * Generate a fresh bootstrap token and stash it. Returns the full
58
+ * `parachute-bootstrap-<rand>` string. The random tail is 32 bytes
59
+ * base64url-encoded (~43 chars), so the full token is ~63 chars —
60
+ * comfortably unguessable, not so long the operator can't copy it.
61
+ *
62
+ * Idempotent within a single boot: calling twice replaces the prior
63
+ * value. In practice the caller (`commands/serve.ts`) only calls once
64
+ * during the wizard-mode branch of `seedInitialAdminIfNeeded`, so this
65
+ * mostly matters for tests that re-init the module between cases.
66
+ */
67
+ export function generateBootstrapToken(): string {
68
+ // 32 bytes of randomness → 43 base64url chars (no padding). Plenty of
69
+ // entropy (256 bits) against any attacker who can guess at line rate.
70
+ const raw = randomBytes(32);
71
+ const tail = raw.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
72
+ currentToken = `${BOOTSTRAP_TOKEN_PREFIX}${tail}`;
73
+ return currentToken;
74
+ }
75
+
76
+ /**
77
+ * Read the current bootstrap token. Returns `undefined` when:
78
+ * - no token has been generated this boot (env-seeded admin or
79
+ * admin-already-exists path); OR
80
+ * - the token has been consumed by a successful admin-claim POST.
81
+ *
82
+ * Callers can use the `undefined` signal to render the form without a
83
+ * token field (the env-seed-no-vault flow under Issue 2 — admin already
84
+ * exists, wizard is just for vault provisioning, no token needed).
85
+ */
86
+ export function getBootstrapToken(): string | undefined {
87
+ return currentToken;
88
+ }
89
+
90
+ /**
91
+ * Constant-time compare an operator-supplied string against the current
92
+ * bootstrap token. Returns `false` when no token is active OR when the
93
+ * supplied value doesn't match. Inputs of different length short-circuit
94
+ * to `false` without touching `timingSafeEqual` — `timingSafeEqual`
95
+ * throws on length-mismatched buffers, but length itself is non-secret
96
+ * (the token format is fixed) so we don't need a constant-time length
97
+ * check.
98
+ *
99
+ * Empty / non-string input returns `false`. The caller's API is "did the
100
+ * operator type the right token?"; missing-and-wrong are the same UX.
101
+ */
102
+ export function verifyBootstrapToken(supplied: string | null | undefined): boolean {
103
+ if (currentToken === undefined) return false;
104
+ if (typeof supplied !== "string" || supplied.length === 0) return false;
105
+ if (supplied.length !== currentToken.length) return false;
106
+ const a = Buffer.from(supplied, "utf8");
107
+ const b = Buffer.from(currentToken, "utf8");
108
+ // Defense in depth: timingSafeEqual asserts same length and throws
109
+ // otherwise. We've already length-checked above, so the throw can only
110
+ // fire if `supplied` carries a multi-byte unicode scalar that encodes
111
+ // to a different number of UTF-8 bytes than its `.length`. Safer to
112
+ // wrap than to leak a stack trace into the operator's response.
113
+ if (a.length !== b.length) return false;
114
+ return timingSafeEqual(a, b);
115
+ }
116
+
117
+ /**
118
+ * Consume the bootstrap token after a successful admin-claim. Subsequent
119
+ * `verifyBootstrapToken` calls return `false`; `getBootstrapToken`
120
+ * returns `undefined`. Use this on the success branch of the account
121
+ * POST so a racing attacker who saw the right token in the operator's
122
+ * over-the-shoulder screencast can't replay it.
123
+ */
124
+ export function consumeBootstrapToken(): void {
125
+ currentToken = undefined;
126
+ }
127
+
128
+ /**
129
+ * Test seam: clear the in-memory token without going through the
130
+ * "claim" path. Production callers should never touch this — the
131
+ * lifecycle is "generate on wizard-mode boot, consume on successful
132
+ * admin claim." Tests that drive multiple wizard scenarios in one
133
+ * process need a way to reset the module between cases.
134
+ *
135
+ * Same effect as `consumeBootstrapToken` today, but kept as a separate
136
+ * surface so future changes (e.g. "log a warning on explicit consume,
137
+ * not on test cleanup") don't muddle the two intents.
138
+ */
139
+ export function _resetBootstrapTokenForTests(): void {
140
+ currentToken = undefined;
141
+ }
142
+
143
+ /**
144
+ * Test seam: directly set the in-memory token to a known value so
145
+ * tests can construct a request with the expected token without going
146
+ * through `generateBootstrapToken` (and the random tail it produces).
147
+ * Production callers always use `generateBootstrapToken`.
148
+ */
149
+ export function _setBootstrapTokenForTests(token: string): void {
150
+ currentToken = token;
151
+ }
152
+
153
+ export { BOOTSTRAP_TOKEN_PREFIX };
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { existsSync, mkdirSync } from "node:fs";
28
28
  import { join } from "node:path";
29
+ import { generateBootstrapToken } from "../bootstrap-token.ts";
29
30
  // NOTE: CONFIG_DIR/WELL_KNOWN_DIR/SERVICES_MANIFEST_PATH are evaluated at
30
31
  // import time from process.env.PARACHUTE_HOME. The `env` parameter on
31
32
  // `serve()` cannot reroute them — set PARACHUTE_HOME before importing for
@@ -75,6 +76,31 @@ export interface ServeResult {
75
76
 
76
77
  const DEFAULT_PORT = 1939;
77
78
 
79
+ /**
80
+ * Build the startup banner line.
81
+ *
82
+ * `0.0.0.0` is a bind-host meta-address — the kernel uses it to mean "listen
83
+ * on all interfaces," but Chrome and other browsers refuse to navigate to it
84
+ * (and any cross-resource fetch that mixes `0.0.0.0` with `localhost` trips
85
+ * cross-origin checks). Operators who paste the banner URL into a browser
86
+ * need the loopback form. When the operator has explicitly chosen a
87
+ * hostname via `PARACHUTE_BIND_HOST` (e.g. `127.0.0.1`, a LAN IP), we
88
+ * honour their choice and print it directly — they know what they wired.
89
+ */
90
+ export function formatListeningBanner(args: {
91
+ hostname: string;
92
+ port: number;
93
+ configDir: string;
94
+ dbPath: string;
95
+ issuer?: string;
96
+ adminBootstrap: string;
97
+ }): string {
98
+ const { hostname, port, configDir, dbPath, issuer, adminBootstrap } = args;
99
+ const displayHost = hostname === "0.0.0.0" ? "localhost" : hostname;
100
+ const boundNote = hostname === "0.0.0.0" ? ` (bound on all interfaces: 0.0.0.0:${port})` : "";
101
+ return `parachute serve: listening on http://${displayHost}:${port}${boundNote} (PARACHUTE_HOME=${configDir}, db=${dbPath}, issuer=${issuer ?? "<request-origin>"}, admin=${adminBootstrap})`;
102
+ }
103
+
78
104
  function parsePort(raw: string | undefined): number | undefined {
79
105
  if (raw === undefined || raw === "") return undefined;
80
106
  const n = Number.parseInt(raw, 10);
@@ -111,6 +137,31 @@ export async function seedInitialAdminIfNeeded(
111
137
  return "seeded";
112
138
  }
113
139
 
140
+ /**
141
+ * Format the multi-line wizard-mode banner the operator must see in
142
+ * startup logs to claim the freshly-deployed hub. The token MUST be
143
+ * surfaced visibly enough that an operator scrolling Render's log tab
144
+ * spots it on the first scan — that's the design tension behind the
145
+ * line-spacing and the `[wizard]` prefix on every line.
146
+ *
147
+ * Threaded out as a pure function so tests can lock the shape; the
148
+ * banner is the security-critical interface (an operator who misses
149
+ * the token can't proceed; an attacker who reads it before the
150
+ * operator wins the race).
151
+ */
152
+ export function formatBootstrapTokenBanner(token: string): string {
153
+ return [
154
+ "[wizard] No admin exists — wizard mode active. To claim ownership of this hub:",
155
+ "[wizard] 1. Visit http://localhost:1939/admin/setup (or your deployed URL)",
156
+ "[wizard] 2. Paste this bootstrap token into the form:",
157
+ "[wizard]",
158
+ `[wizard] ${token}`,
159
+ "[wizard]",
160
+ "[wizard] This token grants permission to create the first admin. It expires when",
161
+ "[wizard] admin is created OR when hub restarts.",
162
+ ].join("\n");
163
+ }
164
+
114
165
  /**
115
166
  * Run the hub fetch loop in the foreground. Resolves when `Bun.serve` is
116
167
  * bound; the returned `stop()` shuts the server down for tests.
@@ -150,6 +201,12 @@ export async function serve(opts: ServeOpts = {}): Promise<{
150
201
  log(
151
202
  "parachute serve: no admin account configured. Set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD, or visit /admin/setup once the hub is reachable.",
152
203
  );
204
+ // Mint a bootstrap token + log it. The wizard's account POST will
205
+ // require this token, so an attacker who beats the operator to the
206
+ // freshly-provisioned URL still can't claim the admin row without
207
+ // shell access to the platform's startup logs.
208
+ const token = generateBootstrapToken();
209
+ log(formatBootstrapTokenBanner(token));
153
210
  }
154
211
 
155
212
  const supervisor = opts.supervisor ?? new Supervisor();
@@ -194,7 +251,14 @@ export async function serve(opts: ServeOpts = {}): Promise<{
194
251
  });
195
252
 
196
253
  log(
197
- `parachute serve: listening on http://${hostname}:${port} (PARACHUTE_HOME=${CONFIG_DIR}, db=${dbPath}, issuer=${issuer ?? "<request-origin>"}, admin=${adminBootstrap})`,
254
+ formatListeningBanner({
255
+ hostname,
256
+ port,
257
+ configDir: CONFIG_DIR,
258
+ dbPath,
259
+ issuer,
260
+ adminBootstrap,
261
+ }),
198
262
  );
199
263
 
200
264
  return {