@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.
- package/package.json +1 -1
- package/src/__tests__/api-modules-ops.test.ts +283 -1
- package/src/__tests__/api-settings-hub-origin.test.ts +452 -0
- package/src/__tests__/bootstrap-token.test.ts +148 -0
- package/src/__tests__/hub-origin-resolution.test.ts +154 -0
- package/src/__tests__/hub-settings.test.ts +94 -0
- package/src/__tests__/oauth-ui.test.ts +117 -0
- package/src/__tests__/serve.test.ts +132 -1
- package/src/__tests__/setup-gate.test.ts +93 -0
- package/src/__tests__/setup-wizard.test.ts +392 -0
- package/src/api-modules-ops.ts +120 -1
- package/src/api-settings-hub-origin.ts +253 -0
- package/src/bootstrap-token.ts +153 -0
- package/src/commands/serve.ts +65 -1
- package/src/hub-server.ts +136 -18
- package/src/hub-settings.ts +53 -1
- package/src/oauth-ui.ts +45 -3
- package/src/setup-wizard.ts +178 -13
- package/src/well-known.ts +82 -1
- package/web/ui/dist/assets/index-BKFoB4gE.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-XhxYXDT5.js +0 -61
|
@@ -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 };
|
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
-
|
|
254
|
+
formatListeningBanner({
|
|
255
|
+
hostname,
|
|
256
|
+
port,
|
|
257
|
+
configDir: CONFIG_DIR,
|
|
258
|
+
dbPath,
|
|
259
|
+
issuer,
|
|
260
|
+
adminBootstrap,
|
|
261
|
+
}),
|
|
198
262
|
);
|
|
199
263
|
|
|
200
264
|
return {
|