Files
EvoScientist-WebUI/docs/superpowers/specs/2026-08-12-first-start-admin-setup-design.md
T

7.7 KiB
Raw Blame History

First-Start Admin Setup — Design

Date: 2026-08-12 Status: Approved (brainstorming 2026-08-12)

Goal

When the WebUI user store is empty and no WEBUI_AUTH_USERNAME / WEBUI_AUTH_PASSWORD env seeding is configured, the deployment currently dead-ends: ensureBootstrapAdmin() throws on every auth path, surfacing a 500-class configuration error. Replace that dead-end with a first-start setup flow: the login page detects the empty store and renders an admin-creation form; submitting creates the first admin, then returns to the normal login form.

Decisions locked in during brainstorming:

  • Env seeding kept, unchanged priority. If the env vars are present, ensureBootstrapAdmin() seeds the first admin exactly as today and the setup UI never appears.
  • No auto-login. After creating the admin, the UI returns to the login form (username prefilled) with a success notice; the operator logs in manually.
  • Setup lives on the login page. No new route/page; the login page swaps its form based on a public status endpoint.

Current state (verified)

  • src/lib/server/userStore.ts:216 ensureBootstrapAdmin(): if (countUsers() > 0) return; then requires both env vars, throws UserStoreError otherwise; seeds via createUser(username, password, "admin") when present.
  • Call sites: src/lib/server/auth.ts:58 (verifyCredentials, wraps the throw in AuthConfigurationError), src/lib/server/actor.ts:39 (requireActor), src/app/api/auth/users/route.ts:29,47, src/app/api/auth/me/route.ts:29.
  • createUser(username, password, role) (userStore.ts:154) validates the username (validateUsername) and rejects duplicates; hashes the password itself. No password-strength check inside — that lives in src/lib/userManagement.ts (isValidPassword, 8–256 chars; isValidUsername, ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$).
  • Login page src/app/[locale]/login/page.tsx is a client component posting to /api/auth/login, with captcha flow, useTranslations("login"), and next return-path handling.

Components

1. ensureBootstrapAdmin() — stop throwing (userStore.ts:216)

Behavior change, one branch:

export function ensureBootstrapAdmin(): void {
  if (countUsers() > 0) return;
  const username = process.env.WEBUI_AUTH_USERNAME?.trim();
  const password = process.env.WEBUI_AUTH_PASSWORD;
  if (!username || !password) return; // was: throw UserStoreError
  createUser(username, password, "admin");
}

Downstream effect of an empty store, per call site (no code changes needed):

  • verifyCredentials → verifyUserPassword returns null → normal invalid-credentials login failure (no more 500).
  • requireActor → roleForUsername returns null → 401 as today.
  • users/me routes → the existing actor/auth guards reject before any admin-only logic runs.

2. Public setup routes (Next.js route handlers)

Both unauthenticated — they exist precisely for the no-session state.

GET /api/auth/setup-status → 200 {needsSetup: boolean}, needsSetup = countUsers() === 0. Calls ensureBootstrapAdmin() first so an env-seeded deployment flips to false on the first probe. No-store caching header.

POST /api/auth/setup — body {username: string, password: string}:

  1. ensureBootstrapAdmin() (env may seed at any moment; env wins).
  2. If countUsers() > 0 → 409 {error} — this is also the race guard against two concurrent first-start submissions.
  3. Validate with isValidUsername / isValidPassword → on failure 400 {error, field: "username" | "password"} (field lets the client place the inline message under the offending input).
  4. createUser(username, password, "admin") → 201 {username, role: "admin"} (no password material in the response).

Error payload shape follows the existing auth-route convention ({error: message} JSON plus optional structured fields, matching the login route's responses).

3. Login page setup form (src/app/[locale]/login/page.tsx)

On mount, the page fetches GET /api/auth/setup-status:

  • needsSetup: false (or request failure) → existing login form, zero behavior change.
  • needsSetup: true → render the setup form in place of the login form: title, username, password, confirm-password fields, submit button. No captcha (the flow is one-shot and store-guarded server-side).

Client-side validation before submit: username pattern, password length, password === confirm — failures show inline messages and send no request.

Submit flow:

  • POST /api/auth/setup → 201: show a success notice ("admin created, please log in"), switch back to the login form with the username field prefilled from the submitted value. No auto-login (locked decision).
  • 409: another admin appeared meanwhile → refetch setup-status, fall back to the login form, show the "already initialized" message.
  • 400: inline error under the offending field.

While the status probe is in flight the login form renders as today (the probe is fast; the setup state is the rare case and may pop in).

4. i18n

New setup namespace in both zh/en catalogs (added in lockstep, plus the messages.test.ts module list): title, subtitle (why this screen exists), field labels, password rules hint, submit button, success notice, alreadyCompleted message, invalidUsername / invalidPassword / passwordMismatch errors, failed{status} generic failure.

5. Testing

  • vitest route tests (src/app/api/auth/setup-status/route.test.ts, src/app/api/auth/setup/route.test.ts), following the existing auth-route test pattern (temp sqlite user store, env var control):
    • setup-status: empty store → {needsSetup: true}; with a user → false; env vars set + empty store → seeds then false.
    • setup: valid body → 201, admin exists in store, password verified via verifyUserPassword; second POST → 409; bad username → 400 with field: "username"; short/long password → 400 with field: "password"; env seeded between probe and POST → 409.
    • ensureBootstrapAdmin regression: no env + empty store no longer throws (was the 500 source); env seeding path unchanged.
  • Manual: wipe the users table, open the login page, see the setup form, create the admin, log in normally; verify zh/en copy.

Data flow

Probe:   login page mount → GET /api/auth/setup-status (public)
         → ensureBootstrapAdmin (env seed if configured)
         → {needsSetup: countUsers() === 0}

Setup:   form submit → POST /api/auth/setup (public, store-empty guarded)
         → ensureBootstrapAdmin → countUsers()>0 ? 409
         → validate → createUser(admin) → 201
         → UI switches to login form (username prefilled), no session set

Error handling

  • Status probe failure (network/500): render the login form (safe default; an empty store then simply fails login as invalid credentials).
  • 409 race (two operators, or env seeded in between): UI refetches status and shows alreadyCompleted.
  • All 400s are field-scoped inline errors; no toast needed.

Security posture

  • The unauthenticated surface is exactly two routes; the write route is useless the moment any user exists (409), so the exposure window is "between deploy and first setup" — the same window env seeding already occupies, now with a UI instead of a 500.
  • No session/JWT material is issued by the setup route; it only creates a user row. Authentication still goes through /api/auth/login.
  • Password rules identical to the admin-managed user creation path; the store hashes with the existing hashPassword.

Out of scope

  • Setup wizard beyond the first admin (model config, channels, etc.).
  • Recovery flows for "admin exists but password lost".
  • Changing the env-seeding mechanism itself.