@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/src/hub-server.ts CHANGED
@@ -48,6 +48,7 @@
48
48
  * /api/modules/:short/upgrade (POST) → bun add @<channel> + restart (async op)
49
49
  * /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
50
50
  * /api/modules/operations/:id (GET) → poll async op status
51
+ * /api/settings/hub-origin (GET + PUT) → canonical hub URL (host:admin)
51
52
  * /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
52
53
  * /api/auth/revoke-token (POST) → revoke registry-row token by jti
53
54
  * /api/auth/tokens (GET) → paginated registry list
@@ -125,6 +126,7 @@ import {
125
126
  import { handleApiModules, handleApiModulesChannel } from "./api-modules.ts";
126
127
  import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
127
128
  import { handleApiRevokeToken } from "./api-revoke-token.ts";
129
+ import { handleApiSettingsHubOrigin } from "./api-settings-hub-origin.ts";
128
130
  import { handleApiTokens } from "./api-tokens.ts";
129
131
  import {
130
132
  handleCreateUser,
@@ -138,6 +140,7 @@ import { ensureCsrfToken } from "./csrf.ts";
138
140
  import { readExposeState } from "./expose-state.ts";
139
141
  import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
140
142
  import { hubDbPath, openHubDb } from "./hub-db.ts";
143
+ import { getHubOrigin } from "./hub-settings.ts";
141
144
  import { type RenderHubOpts, renderHub } from "./hub.ts";
142
145
  import { pemToJwk } from "./jwks.ts";
143
146
  import {
@@ -289,6 +292,27 @@ export function findVaultUpstream(
289
292
  return best;
290
293
  }
291
294
 
295
+ /**
296
+ * True iff at least one vault module is registered in services.json. Drives
297
+ * the wizard-resume redirect on `/` and `/hub.html` (Issue 2 of the
298
+ * first-boot-path hardening bundle): an env-seeded admin with no vault
299
+ * still needs the wizard, so `/` should funnel them there rather than
300
+ * render the empty discovery portal.
301
+ *
302
+ * Reads services.json on every check (cheap — single ~KB parse) so a
303
+ * vault provisioned seconds ago un-gates the discovery page without a
304
+ * hub restart. Returns `false` on a malformed services.json — safer to
305
+ * surface the wizard than to 500 the operator's first page load.
306
+ */
307
+ function hasVaultInstalled(manifestPath: string): boolean {
308
+ try {
309
+ const services = readManifest(manifestPath).services;
310
+ return services.some((s) => isVaultEntry(s));
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
315
+
292
316
  /**
293
317
  * The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
294
318
  * every request reaches it via one of three trusted forwarders (or directly
@@ -866,6 +890,57 @@ function dbNotConfigured(): Response {
866
890
  );
867
891
  }
868
892
 
893
+ /**
894
+ * Resolve the OAuth issuer URL for this request. Precedence, highest
895
+ * first (hub#298):
896
+ *
897
+ * 1. `hub_settings.hub_origin` — operator-set canonical URL from the
898
+ * admin SPA. Wins when present.
899
+ * 2. `configuredIssuer` — `--issuer` flag or `PARACHUTE_HUB_ORIGIN`
900
+ * env var captured at hub start. The deploy-time setting.
901
+ * 3. `new URL(req.url).origin` — the request's own origin. Local dev
902
+ * + Render-assigned subdomains land here when nothing's been
903
+ * configured.
904
+ *
905
+ * Per-request (not cached at hub start) so a PUT to
906
+ * `/api/settings/hub-origin` takes effect on the next request without a
907
+ * restart. The hub_settings read is a single small SQLite query — cheap
908
+ * relative to JWT signing on the same request path.
909
+ *
910
+ * `db` is optional because the wellknown / discovery surfaces are
911
+ * reachable on a hub with no DB configured (the dbNotConfigured 503
912
+ * gate sits behind these in the dispatcher). In that case we skip the
913
+ * settings layer and fall through to env/request precedence.
914
+ */
915
+ export function resolveIssuer(
916
+ req: Request,
917
+ db: Database | undefined,
918
+ configuredIssuer: string | undefined,
919
+ ): string {
920
+ if (db !== undefined) {
921
+ const stored = getHubOrigin(db);
922
+ if (stored) return stored;
923
+ }
924
+ if (configuredIssuer) return configuredIssuer;
925
+ return new URL(req.url).origin;
926
+ }
927
+
928
+ /**
929
+ * Where did the resolved issuer come from? Drives the source-label
930
+ * surfaced in the admin SPA so operators can tell which precedence
931
+ * layer they're on without inspecting the DB or env.
932
+ */
933
+ export type IssuerSource = "settings" | "env" | "request";
934
+
935
+ export function resolveIssuerSource(
936
+ db: Database | undefined,
937
+ configuredIssuer: string | undefined,
938
+ ): IssuerSource {
939
+ if (db !== undefined && getHubOrigin(db)) return "settings";
940
+ if (configuredIssuer) return "env";
941
+ return "request";
942
+ }
943
+
869
944
  export function hubFetch(
870
945
  wellKnownDir: string,
871
946
  deps?: HubFetchDeps,
@@ -889,7 +964,7 @@ export function hubFetch(
889
964
  });
890
965
 
891
966
  const oauthDeps = (req: Request) => {
892
- const issuer = configuredIssuer ?? new URL(req.url).origin;
967
+ const issuer = resolveIssuer(req, getDb?.(), configuredIssuer);
893
968
  return {
894
969
  issuer,
895
970
  // Per-request resolution (closes #245): expose-state.json can change
@@ -1061,23 +1136,43 @@ export function hubFetch(
1061
1136
  return new Response("not found", { status: 404 });
1062
1137
  }
1063
1138
 
1064
- // Fresh-hub redirect: on a hub with no admin row yet, the discovery
1065
- // page (`/`, `/hub.html`) funnels straight to the wizard. The static
1066
- // portal isn't useful pre-setup nothing's installed, the
1067
- // "Signed in" affordance has no session to surface — and the
1068
- // operator landing on `/` in a browser otherwise has to manually
1069
- // type `/admin/setup` to escape. 302 (not 301) so the redirect
1070
- // disappears the moment the wizard finishes.
1139
+ // Fresh-hub redirect: when the wizard still has work to do, the
1140
+ // discovery page (`/`, `/hub.html`) funnels straight to it. Two
1141
+ // wizard-mode conditions trigger the redirect:
1071
1142
  //
1072
- // Sits before the JSON-shaped 503 gate below because `/` is an
1073
- // HTML surface a JSON 503 there would render as raw text in the
1074
- // operator's browser tab. The 503 gate handles API + admin SPA +
1075
- // OAuth callers that branch on the structured body.
1076
- if (getDb && (pathname === "/" || pathname === "/hub.html") && userCount(getDb()) === 0) {
1077
- return new Response(null, {
1078
- status: 302,
1079
- headers: { location: "/admin/setup" },
1080
- });
1143
+ // 1. No admin row exists (the original fresh-deploy case). The
1144
+ // static portal carries no usable signal no installed
1145
+ // services to discover, no admin to sign in as.
1146
+ // 2. Admin exists but no vault is installed (env-seed deploys
1147
+ // where the operator baked admin into env vars but hasn't
1148
+ // walked the wizard's vault step). Pre-fix, env-seeded
1149
+ // operators bounced past the wizard entirely and had to
1150
+ // hand-find /admin/modules + /admin/vaults; surface
1151
+ // "let me finish the wizard" instead.
1152
+ //
1153
+ // The wizard's GET handler already picks the right step
1154
+ // (`deriveWizardState` resumes at vault step when admin exists +
1155
+ // no vault), so we just need the redirect to fire.
1156
+ //
1157
+ // 302 (not 301) so the redirect disappears the moment the wizard
1158
+ // finishes. Sits before the JSON-shaped 503 gate below because `/`
1159
+ // is an HTML surface — a JSON 503 there would render as raw text
1160
+ // in the operator's browser tab. The 503 gate handles API + admin
1161
+ // SPA + OAuth callers that branch on the structured body.
1162
+ if (getDb && (pathname === "/" || pathname === "/hub.html")) {
1163
+ const db = getDb();
1164
+ // Either condition triggers the wizard funnel:
1165
+ // - no admin row (the fresh-deploy case)
1166
+ // - admin row exists but no vault installed (env-seed case)
1167
+ // Short-circuit the manifest read when `noAdmin` is true; the
1168
+ // wizard's first step is admin creation regardless of vault state.
1169
+ const needsWizard = userCount(db) === 0 || !hasVaultInstalled(manifestPath);
1170
+ if (needsWizard) {
1171
+ return new Response(null, {
1172
+ status: 302,
1173
+ headers: { location: "/admin/setup" },
1174
+ });
1175
+ }
1081
1176
  }
1082
1177
 
1083
1178
  // Pre-admin lockout. When the hub has booted with no admin row (the
@@ -1173,7 +1268,15 @@ export function hubFetch(
1173
1268
  // the request's own origin (fine for direct loopback hits).
1174
1269
  try {
1175
1270
  const manifest = readManifest(manifestPath);
1176
- const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
1271
+ // Same precedence as the OAuth issuer (hub#298): hub_settings →
1272
+ // env → request origin. The well-known doc embeds this origin
1273
+ // in service URLs + the issuer metadata link, so it must follow
1274
+ // the same chain — otherwise a public-domain operator who set
1275
+ // `hub_origin` would still see the Render-assigned URL on
1276
+ // `/.well-known/parachute.json` while their JWTs carry the
1277
+ // canonical URL, and discovery clients would split-brain on
1278
+ // which one to trust.
1279
+ const canonicalOrigin = resolveIssuer(req, getDb?.(), configuredIssuer);
1177
1280
  const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
1178
1281
  const [managementUrlByName, serviceUiMeta] = await Promise.all([
1179
1282
  loadManagementUrls(manifest.services, readManifestFn),
@@ -1398,6 +1501,21 @@ export function hubFetch(
1398
1501
  });
1399
1502
  }
1400
1503
 
1504
+ // Canonical hub URL (hub#298). Admin SPA reads + writes the
1505
+ // operator-set issuer override. The handler computes the resolved
1506
+ // issuer + source here so it can surface them in the GET payload
1507
+ // without re-walking the precedence chain inside the handler.
1508
+ if (pathname === "/api/settings/hub-origin") {
1509
+ if (!getDb) return dbNotConfigured();
1510
+ const db = getDb();
1511
+ return handleApiSettingsHubOrigin(req, {
1512
+ db,
1513
+ issuer: oauthDeps(req).issuer,
1514
+ resolvedIssuer: resolveIssuer(req, db, configuredIssuer),
1515
+ resolvedSource: resolveIssuerSource(db, configuredIssuer),
1516
+ });
1517
+ }
1518
+
1401
1519
  // Module operation poll surface — pre-empts the /api/modules/:short/*
1402
1520
  // routes below so `/api/modules/operations/<uuid>` doesn't accidentally
1403
1521
  // match a parseModulesPath("/operations") and fall through.
@@ -52,7 +52,21 @@ export type HubSettingKey =
52
52
  // channel); after the first seed the row is source of truth and the
53
53
  // env var is ignored — admin must use the SPA toggle (or
54
54
  // `PUT /api/modules/channel`) to change channel.
55
- | "module_install_channel";
55
+ | "module_install_channel"
56
+ // hub#298: operator-settable canonical hub URL. The value (when set)
57
+ // is the OAuth issuer claim stamped into every JWT minted by hub.
58
+ // Precedence on each request: this row, then `PARACHUTE_HUB_ORIGIN`
59
+ // env, then `new URL(req.url).origin` as the local-dev fallback.
60
+ //
61
+ // Storing the canonical origin in hub_settings (rather than relying
62
+ // solely on the env var) lets a Render operator attach a custom
63
+ // domain after first boot + flip the issuer URL from the admin SPA
64
+ // without restarting the container. Tokens minted before the change
65
+ // carry the old `iss` claim; tokens minted after carry the new one.
66
+ // Operators must accept that flipping the canonical hub URL
67
+ // invalidates any tokens already in circulation (issuer mismatch on
68
+ // verification) — surfaced in the admin SPA's helper copy.
69
+ | "hub_origin";
56
70
 
57
71
  export type SetupExposeMode = "localhost" | "tailnet" | "public";
58
72
 
@@ -257,3 +271,41 @@ export function getModuleInstallChannel(
257
271
  export function setModuleInstallChannel(db: Database, channel: ModuleInstallChannel): void {
258
272
  setSetting(db, "module_install_channel", channel);
259
273
  }
274
+
275
+ // --- domain helpers: canonical hub origin --------------------------------
276
+
277
+ /**
278
+ * Read the operator-set canonical hub origin from hub_settings. Returns
279
+ * `null` when no row is present — callers fall through to env / request-
280
+ * origin precedence in that case (see `resolveIssuer` in hub-server).
281
+ *
282
+ * Unlike `module_install_channel` this helper does NOT seed from env on
283
+ * first read. The env var (`PARACHUTE_HUB_ORIGIN`) remains a separate
284
+ * precedence layer below this one — operators who set the env var still
285
+ * see "from env" attribution in the admin SPA. Auto-seeding would
286
+ * collapse that layer into the row + lose the source attribution.
287
+ */
288
+ export function getHubOrigin(db: Database): string | null {
289
+ const value = getSetting(db, "hub_origin");
290
+ return value ?? null;
291
+ }
292
+
293
+ /**
294
+ * Write or clear the canonical hub origin. Passing `null` deletes the
295
+ * row, reverting to env / request-origin precedence on subsequent
296
+ * requests. The caller must have validated the URL shape — the
297
+ * function trusts the input and writes verbatim (mirrors
298
+ * `setModuleInstallChannel`'s "typed-callsite is the contract" stance).
299
+ *
300
+ * Empty-string is treated as null (the value would never be a useful
301
+ * issuer) to avoid storing a row that no codepath would honor — the
302
+ * resolveIssuer fallback chain would skip it as falsy and the source
303
+ * label would lie about where the value came from.
304
+ */
305
+ export function setHubOrigin(db: Database, value: string | null): void {
306
+ if (value === null || value === "") {
307
+ deleteSetting(db, "hub_origin");
308
+ return;
309
+ }
310
+ setSetting(db, "hub_origin", value);
311
+ }
package/src/oauth-ui.ts CHANGED
@@ -359,10 +359,30 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
359
359
  approveForm,
360
360
  } = props;
361
361
  const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
362
+ // Substitute unnamed `vault:<verb>` rows with the wildcard display form
363
+ // (`vault:*:<verb>`) so the operator sees the shape that will appear in
364
+ // the token after consent narrows the scope to a specific vault. At
365
+ // approve time no vault has been picked yet — the SPA's
366
+ // `/admin/approve-client/<id>` view uses the same wildcard treatment
367
+ // (see `resolveScopeForDisplay` in `web/ui/src/routes/ApproveClient.tsx`).
368
+ const displayedScopes = requestedScopes.map((s) => substituteVaultDisplay(s, "*"));
362
369
  const scopeRows =
363
- requestedScopes.length === 0
370
+ displayedScopes.length === 0
364
371
  ? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
365
- : requestedScopes.map(renderScopeRow).join("\n");
372
+ : displayedScopes.map(renderScopeRow).join("\n");
373
+ // Wildcard explanation: surface the "the asterisk means the vault is
374
+ // picked later" hint below the scope list when at least one row carries
375
+ // `vault:*:<verb>`. Mirrors the SPA's inline note on
376
+ // `/admin/approve-client/<id>`. Omitted when no scope renders with `*`
377
+ // (all scopes are either non-vault or already-named).
378
+ const wildcardNote = displayedScopes.some((s) => /^vault:\*:(read|write)$/.test(s))
379
+ ? `
380
+ <p class="scope-wildcard-note">
381
+ <code>*</code> — a specific vault is selected during sign-in via the consent
382
+ picker (or the user's assigned vault for multi-user setups). The
383
+ <code>*</code> shows the unbound shape.
384
+ </p>`
385
+ : "";
366
386
  // Vault hint (closes #244): Notes' VaultPopover (notes#115) passes
367
387
  // `vault=<name>` on `/oauth/authorize` for per-vault grants. Surface it
368
388
  // alongside scopes so a multi-vault operator can tell which vault they're
@@ -414,7 +434,7 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
414
434
  </section>
415
435
  <section class="scopes">
416
436
  <h2 class="scopes-title">Permissions requested</h2>
417
- <ul class="scope-list">${scopeRows}</ul>
437
+ <ul class="scope-list">${scopeRows}</ul>${wildcardNote}
418
438
  </section>
419
439
  ${formSection}
420
440
  </div>`;
@@ -507,6 +527,13 @@ function renderUnauthenticatedApproveCtas(hubOrigin: string, clientId: string):
507
527
  * the admin consent screen when the picker hasn't been touched yet, and
508
528
  * on the operator approval page where the vault is selected at the
509
529
  * per-user sign-in step, not at approve time.
530
+ * - `displayVault === "*"` → render with a literal asterisk placeholder
531
+ * (`vault:*:verb`). Used on the server-rendered approve-pending page
532
+ * and the SPA's `/admin/approve-client/<id>` view: at approve time the
533
+ * vault isn't bound yet (no consent picker has run), so the asterisk
534
+ * signals "wildcard — a specific vault is selected later in the flow."
535
+ * Callers that render this form should also surface the inline
536
+ * explanation below the scope list (see `renderApprovePending`).
510
537
  * - `displayVault === "name"` → render as `vault:name:verb` literally.
511
538
  *
512
539
  * Non-vault scopes pass through untouched. Already-named `vault:<x>:<verb>`
@@ -1143,6 +1170,21 @@ const STYLES = `
1143
1170
  color: ${PALETTE.fgDim};
1144
1171
  font-style: italic;
1145
1172
  }
1173
+ .scope-wildcard-note {
1174
+ margin: 0.6rem 0 0;
1175
+ font-size: 0.82rem;
1176
+ color: ${PALETTE.fgDim};
1177
+ font-style: italic;
1178
+ line-height: 1.45;
1179
+ }
1180
+ .scope-wildcard-note code {
1181
+ font-family: ${FONT_MONO};
1182
+ font-style: normal;
1183
+ background: ${PALETTE.bgSoft};
1184
+ padding: 0.05rem 0.3rem;
1185
+ border-radius: 4px;
1186
+ color: ${PALETTE.fgMuted};
1187
+ }
1146
1188
  .badge {
1147
1189
  display: inline-block;
1148
1190
  font-size: 0.7rem;
@@ -40,6 +40,12 @@
40
40
  import type { Database } from "bun:sqlite";
41
41
  import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
42
42
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
43
+ import {
44
+ BOOTSTRAP_TOKEN_PREFIX,
45
+ consumeBootstrapToken,
46
+ getBootstrapToken,
47
+ verifyBootstrapToken,
48
+ } from "./bootstrap-token.ts";
43
49
  import {
44
50
  CSRF_FIELD_NAME,
45
51
  ensureCsrfToken,
@@ -254,12 +260,60 @@ export interface RenderAccountStepProps {
254
260
  errorMessage?: string;
255
261
  /** Pre-fill the username field after a validation failure. */
256
262
  username?: string;
263
+ /**
264
+ * Whether the bootstrap-token field should render and be required.
265
+ * True under `parachute serve` wizard mode (no admin, no env-seed) —
266
+ * `commands/serve.ts` mints + logs the token on boot and the form
267
+ * requires it to claim the admin row. False on the on-box CLI surface
268
+ * (the operator already has shell access; gating the form behind a
269
+ * token they'd also need to read from logs adds friction with no
270
+ * security gain).
271
+ *
272
+ * UX: when true, the token field is the FIRST field on the form so
273
+ * an operator who hasn't seen the log line stops here rather than
274
+ * filling username + password and bouncing off a 401.
275
+ */
276
+ requireBootstrapToken?: boolean;
277
+ /**
278
+ * Pre-fill the bootstrap-token field after a validation failure on a
279
+ * field OTHER than the token itself. We never echo a wrong token back
280
+ * — the form re-renders with an empty token field so the operator has
281
+ * to re-look-up the correct value.
282
+ */
283
+ bootstrapToken?: string;
257
284
  }
258
285
 
259
286
  export function renderAccountStep(props: RenderAccountStepProps): string {
260
- const { csrfToken, errorMessage, username } = props;
287
+ const { csrfToken, errorMessage, username, requireBootstrapToken, bootstrapToken } = props;
261
288
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
262
289
  const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
290
+ const tokenAttr = bootstrapToken ? ` value="${escapeAttr(bootstrapToken)}"` : "";
291
+ // Bootstrap-token field comes FIRST when required. An operator who
292
+ // missed the log line is stopped here rather than after filling
293
+ // username + password.
294
+ const bootstrapField = requireBootstrapToken
295
+ ? `
296
+ <label class="field">
297
+ <span class="field-label">Bootstrap token</span>
298
+ <input type="text" name="bootstrap_token" autocomplete="off"
299
+ autofocus required minlength="20" maxlength="200"
300
+ spellcheck="false" autocapitalize="off"
301
+ placeholder="${escapeAttr(BOOTSTRAP_TOKEN_PREFIX)}…"${tokenAttr} />
302
+ <span class="field-hint">Find this in your hub's startup logs.
303
+ Look for the <code>${escapeHtml(BOOTSTRAP_TOKEN_PREFIX)}</code> line.</span>
304
+ </label>`
305
+ : "";
306
+ // When the token is required we drop `autofocus` off the username field
307
+ // so it doesn't fight the token field's focus.
308
+ const usernameAutofocus = requireBootstrapToken ? "" : " autofocus";
309
+ const tokenCallout = requireBootstrapToken
310
+ ? `<aside class="bootstrap-callout">
311
+ <strong>One-time setup credential.</strong> This hub was deployed without
312
+ baked-in admin credentials, so it generated a one-time bootstrap token
313
+ on startup. Paste it below to claim the admin account. The token
314
+ expires once the admin is created (or when the hub restarts).
315
+ </aside>`
316
+ : "";
263
317
  const body = `
264
318
  <div class="card">
265
319
  <div class="card-header">
@@ -278,13 +332,15 @@ export function renderAccountStep(props: RenderAccountStepProps): string {
278
332
  <p>After this you'll name your first vault. The hub will install it
279
333
  and issue a token your Claude Code MCP client can use.</p>
280
334
  </section>
335
+ ${tokenCallout}
281
336
  ${error}
282
337
  <form method="POST" action="/admin/setup/account" class="auth-form">
283
338
  ${renderCsrfHiddenInput(csrfToken)}
339
+ ${bootstrapField}
284
340
  <label class="field">
285
341
  <span class="field-label">Username</span>
286
- <input type="text" name="username" autocomplete="username"
287
- autofocus required minlength="2" maxlength="64"
342
+ <input type="text" name="username" autocomplete="username"${usernameAutofocus}
343
+ required minlength="2" maxlength="64"
288
344
  pattern="[A-Za-z0-9_.-]+" title="letters, digits, _ . - (2–64 chars)"
289
345
  ${usernameAttr} />
290
346
  <span class="field-hint">letters, digits, <code>_</code>, <code>.</code>, <code>-</code></span>
@@ -307,7 +363,8 @@ export function renderAccountStep(props: RenderAccountStepProps): string {
307
363
  <p>Set <code>PARACHUTE_INITIAL_ADMIN_USERNAME</code> and
308
364
  <code>PARACHUTE_INITIAL_ADMIN_PASSWORD</code> on the container and
309
365
  restart. The hub will create the admin row on next boot and skip this
310
- wizard.</p>
366
+ wizard (no bootstrap token needed — the env vars themselves are the
367
+ claim).</p>
311
368
  </details>
312
369
  </div>`;
313
370
  return baseDocument("Set up your Parachute hub — account", body);
@@ -1049,11 +1106,23 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1049
1106
  });
1050
1107
  }
1051
1108
 
1052
- // Step 1+2 (no admin yet).
1053
- return new Response(renderAccountStep({ csrfToken: csrf.token }), {
1054
- status: 200,
1055
- headers: extraHeaders,
1056
- });
1109
+ // Step 1+2 (no admin yet). Render with the bootstrap-token field iff a
1110
+ // token is currently active — `commands/serve.ts` only mints one on
1111
+ // wizard-mode boot (no env-seed), so the field's presence is a 1:1
1112
+ // signal of "the operator needs a token to claim this hub." On the
1113
+ // on-box CLI surface and on env-seed-followed-by-deleted-admin paths,
1114
+ // the token is absent and the form renders the historical shape.
1115
+ const requireToken = getBootstrapToken() !== undefined;
1116
+ return new Response(
1117
+ renderAccountStep({
1118
+ csrfToken: csrf.token,
1119
+ requireBootstrapToken: requireToken,
1120
+ }),
1121
+ {
1122
+ status: 200,
1123
+ headers: extraHeaders,
1124
+ },
1125
+ );
1057
1126
  }
1058
1127
 
1059
1128
  /**
@@ -1076,17 +1145,65 @@ export async function handleSetupAccountPost(
1076
1145
  return badRequestPage("Invalid form submission", "Reload and try again.");
1077
1146
  }
1078
1147
  // Already-bootstrapped: bounce. The wizard's GET state will resolve to
1079
- // step 3 or step 4 on the next request.
1148
+ // step 3 or step 4 on the next request. We return 410 Gone for the
1149
+ // case where a bootstrap token was active this boot AND has already
1150
+ // been consumed by a prior POST — distinguishes "you missed your
1151
+ // window" from "this hub never had a wizard-mode boot" so a racing
1152
+ // attacker sees a hard-stop rather than a soft redirect.
1153
+ const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
1154
+ const requireToken = getBootstrapToken() !== undefined;
1080
1155
  if (userCount(deps.db) > 0) {
1081
- return redirect("/admin/setup");
1156
+ if (!requireToken) {
1157
+ return redirect("/admin/setup");
1158
+ }
1159
+ // Defense in depth: a token was active but an admin already exists.
1160
+ // Treat as consumed.
1161
+ return new Response(renderClaimAlreadyHappenedPage(), {
1162
+ status: 410,
1163
+ headers: { "content-type": "text/html; charset=utf-8" },
1164
+ });
1165
+ }
1166
+ // Bootstrap-token gate. Only enforced when wizard mode is active —
1167
+ // env-seed admins never reach this path (they're admin-exists by
1168
+ // boot time), CLI mode never mints a token (the on-box operator
1169
+ // already has shell auth). Wrong token → 401 + form re-render with
1170
+ // an empty token field; right token → fall through.
1171
+ if (requireToken) {
1172
+ const suppliedToken = String(form.get("bootstrap_token") ?? "").trim();
1173
+ if (!verifyBootstrapToken(suppliedToken)) {
1174
+ const username = String(form.get("username") ?? "").trim();
1175
+ return htmlResponse(
1176
+ renderAccountStep({
1177
+ csrfToken,
1178
+ username,
1179
+ requireBootstrapToken: true,
1180
+ // Deliberately do NOT echo the wrong supplied token back —
1181
+ // forces re-look-up rather than tab-completing a typo.
1182
+ errorMessage:
1183
+ "Wrong bootstrap token. Re-check your hub's startup logs for the " +
1184
+ "`parachute-bootstrap-…` line and try again.",
1185
+ }),
1186
+ 401,
1187
+ );
1188
+ }
1082
1189
  }
1083
1190
  const username = String(form.get("username") ?? "").trim();
1084
1191
  const password = String(form.get("password") ?? "");
1085
1192
  const confirm = String(form.get("password_confirm") ?? "");
1086
- const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
1087
1193
  const fieldErr = validateAccountFields({ username, password, confirm });
1088
1194
  if (fieldErr) {
1089
- return htmlResponse(renderAccountStep({ csrfToken, username, errorMessage: fieldErr }), 400);
1195
+ return htmlResponse(
1196
+ renderAccountStep({
1197
+ csrfToken,
1198
+ username,
1199
+ // Re-render with the token field present iff still required (it
1200
+ // was valid this attempt — the field-error came from username/
1201
+ // password). Empty value so the operator pastes again.
1202
+ requireBootstrapToken: requireToken,
1203
+ errorMessage: fieldErr,
1204
+ }),
1205
+ 400,
1206
+ );
1090
1207
  }
1091
1208
  try {
1092
1209
  // Wizard-admin chose their password through this very form; skip the
@@ -1095,6 +1212,14 @@ export async function handleSetupAccountPost(
1095
1212
  // (the wizard never asks the first admin to pin themselves to a
1096
1213
  // single vault; that's a non-admin user pattern).
1097
1214
  const user = await createUser(deps.db, username, password, { passwordChanged: true });
1215
+ // Consume the bootstrap token AFTER the admin row is committed.
1216
+ // Doing it before would let a `createUser` exception (UNIQUE-collision,
1217
+ // disk full, anything) leave the token un-consumed but the admin row
1218
+ // partially written — and the operator without a way to retry.
1219
+ // Doing it after means a successful claim invalidates the token for
1220
+ // any racer who saw it over the operator's shoulder during the
1221
+ // window between log-print and form-submit.
1222
+ if (requireToken) consumeBootstrapToken();
1098
1223
  const session = createSession(deps.db, { userId: user.id });
1099
1224
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
1100
1225
  secure: isHttpsRequest(req),
@@ -1116,6 +1241,7 @@ export async function handleSetupAccountPost(
1116
1241
  renderAccountStep({
1117
1242
  csrfToken,
1118
1243
  username,
1244
+ requireBootstrapToken: requireToken,
1119
1245
  errorMessage: "Failed to create account. The username may already be taken.",
1120
1246
  }),
1121
1247
  400,
@@ -1123,6 +1249,34 @@ export async function handleSetupAccountPost(
1123
1249
  }
1124
1250
  }
1125
1251
 
1252
+ /**
1253
+ * Static error page surfaced when an `/admin/setup/account` POST arrives
1254
+ * after the bootstrap token has already been consumed by a successful
1255
+ * admin claim. Returned with HTTP 410 Gone (vs. the 302 redirect a
1256
+ * stale-tab without a token gets) so a scripted attacker reading the
1257
+ * status code sees an unmistakable "you missed the window" signal.
1258
+ *
1259
+ * No retry CTA — the wizard is past its account step; pointing the
1260
+ * operator at /login is the right answer.
1261
+ */
1262
+ function renderClaimAlreadyHappenedPage(): string {
1263
+ const body = `
1264
+ <div class="card">
1265
+ ${header("account")}
1266
+ <h1 class="error-title">Admin already claimed</h1>
1267
+ <p class="subtitle">This hub's bootstrap token was already used to
1268
+ create the admin account. The token is one-shot — it can't be
1269
+ reused to claim a second admin or rotate the existing one.</p>
1270
+ <p class="subtitle">If you're the legitimate operator, sign in at
1271
+ <a href="/login"><code>/login</code></a>. If you've lost the
1272
+ password, restart the hub (which mints a fresh token) and use
1273
+ <code>parachute auth set-password</code> from a shell with
1274
+ access to the hub's PARACHUTE_HOME.</p>
1275
+ <p><a class="btn btn-primary" href="/login">Go to sign-in</a></p>
1276
+ </div>`;
1277
+ return baseDocument("Admin already claimed", body);
1278
+ }
1279
+
1126
1280
  /**
1127
1281
  * POST `/admin/setup/vault`. Form-encoded.
1128
1282
  *
@@ -1954,6 +2108,17 @@ const STYLES = `
1954
2108
  }
1955
2109
  .error-title { color: ${PALETTE.danger}; }
1956
2110
 
2111
+ .bootstrap-callout {
2112
+ background: ${PALETTE.warnSoft};
2113
+ border-left: 3px solid ${PALETTE.warn};
2114
+ border-radius: 0 6px 6px 0;
2115
+ padding: 0.7rem 0.9rem;
2116
+ margin: 0 0 1rem;
2117
+ font-size: 0.9rem;
2118
+ color: ${PALETTE.fg};
2119
+ }
2120
+ .bootstrap-callout strong { color: ${PALETTE.fg}; }
2121
+
1957
2122
  .op-log {
1958
2123
  background: ${PALETTE.bg};
1959
2124
  border: 1px solid ${PALETTE.borderLight};