From 26a477464e70b8fe1a4f08de800e8861886baec2 Mon Sep 17 00:00:00 2001 From: m4 Date: Wed, 12 Aug 2026 22:12:15 +0800 Subject: [PATCH] docs: first-start admin setup design spec --- ...26-08-12-first-start-admin-setup-design.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-12-first-start-admin-setup-design.md diff --git a/docs/superpowers/specs/2026-08-12-first-start-admin-setup-design.md b/docs/superpowers/specs/2026-08-12-first-start-admin-setup-design.md new file mode 100644 index 0000000..83c64a7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-first-start-admin-setup-design.md @@ -0,0 +1,179 @@ +# 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: + +```ts +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.