docs(webui): spec for admin system config (branding, login terms, security, backup)

This commit is contained in:
m4
2026-08-11 07:51:14 +08:00
parent b75de8b5e6
commit aa0f308208
@@ -0,0 +1,161 @@
# System Config — Admin System Parameters — Design
Date: 2026-08-11
Status: Approved (approach A: WebUI-owned JSON store, user confirmed each section)
## Goal
Add a "System config" entry to the top-right Configuration dropdown, opening an
admin-only dialog with four tabs — General (branding), Login terms, Security,
Data backup. Parameters are a WebUI-owned override layer stored in
`~/.evoscientist/system-config.json`; unset fields fall back to env/code
defaults. No Python backend changes.
Decisions locked in during brainstorming:
- **Login terms:** display-only on the login page (no agreement checkbox).
- **Security:** captcha policy, session & password policy, auth on/off toggle.
Session expiry stays **fixed** (no sliding renewal — rejected 2026-08-08).
- **Backup:** WebUI + backend data, manual export AND scheduled automatic
backups to a server directory, with a history list (download/delete).
- **Permission:** admin role only (UI hides entry; server double-checks).
- **Approach A:** WebUI self-managed storage, no backend changes.
## 1. Storage — `src/lib/server/systemConfig.ts`
Follows the `userStore` pattern: read/write `~/.evoscientist/system-config.json`
(via `webuiDataDir()`), atomic write (temp file + rename), in-memory cache with
mtime invalidation.
```ts
interface SystemConfig {
branding: { wordmark: string; logoFile: string | null; faviconFile: string | null };
loginTerms: { enabled: boolean; markdown: string };
security: {
authEnabled: boolean | null; // null = follow env
sessionTtlHours: number | null;
passwordMinLength: number | null;
captchaEnabled: boolean | null;
captchaThreshold: number | null; // failures before captcha
};
backup: {
backendDataDir: string;
schedule: { enabled: boolean; intervalHours: number; keepCount: number };
};
}
```
`null` = no override → fall back to env/code default. The config file is never
required to exist; `getSystemConfig()` returns defaults when absent.
`effectiveSecurity()` (same module) is the single derivation point:
auth.ts / captcha.ts / loginFailures.ts call it instead of reading the file
directly. Unit-testable without touching auth internals.
## 2. API layer
| Route | Auth | Purpose |
|---|---|---|
| `GET/PUT /api/system/config` | admin | full config read/update |
| `POST /api/system/branding` | admin | multipart logo/favicon upload → `~/.evoscientist/branding/{logo,favicon}.<ext>`; `DELETE` restores default |
| `GET /api/system/config/public` | none | public subset: `{ wordmark, logoVersion, faviconVersion, loginTerms: { enabled, markdown } }` (version = file mtime, for cache-busting) |
| `GET /api/system/branding/logo` `/favicon` | none | stream branding file, `Cache-Control: public, max-age=60`; 404 → caller falls back to bundled asset |
| `POST /api/system/backup` | admin | run backup now |
| `GET /api/system/backup` | admin | history list (name, size, mtime, origin manual/auto) |
| `GET/DELETE /api/system/backup/[file]` | admin | download / delete an archive (filename validated: `^backup-\d{8}T\d{6}\.tar\.gz$`, no path traversal) |
Admin check = `requireActor(request)` + `roleForUsername(username) === "admin"`,
else 403. Cross-origin guard (`isCrossOrigin`) on all routes, per the
`/api/system/version` pattern.
## 3. General tab (branding)
- **Wordmark:** text input, non-empty, ≤30 chars. Applied to the header
wordmark (`page.tsx`, currently hardcoded "AI4Scientist") and the login page
title.
- **Logo:** file picker (PNG/SVG/JPEG, ≤512KB), local preview before save.
Header `<Image>` becomes `<img src="/api/system/branding/logo?v=<logoVersion>">`
(dynamic src — next/image doesn't fit), falling back to
`/evoscientist-logo.png` when no custom logo. "Restore default" button.
- **Favicon:** same upload flow. Root layout injects
`<link rel="icon" href="/api/system/branding/favicon?v=<faviconVersion>">`
when a custom favicon exists (server component reads config at render).
- **Consumers:** header and login page SWR-fetch `/api/system/config/public`
(works unauthenticated — the login page needs it). Saving in the dialog
revalidates the SWR key so branding updates immediately.
## 4. Login terms tab
- Enable toggle + Markdown textarea with rendered preview (`MarkdownContent`).
- When enabled, the login page renders a scrollable terms card
(`max-h`, `MarkdownContent`) below the title, sourced from
`/api/system/config/public`. Display-only — no checkbox, no gating.
## 5. Security tab
Every control is an override with a "restore default" affordance:
- **Auth toggle:** tri-state — follow env (default) / force on / force off.
`isAuthenticationEnabled()` checks the override first. Force-off gets a
confirm dialog (danger: opens access). The existing env-disabled code paths
(login page bypass, `authEnabled=false` UI) are reused as-is.
- **Session TTL:** hours, 1–720. Applies to newly issued sessions only;
existing sessions expire on their original fixed `expiresAt`.
- **Password min length:** 6–64. Enforced in `userStore.setUserPassword`;
existing passwords unaffected.
- **Captcha:** tri-state (default/on/off) + failure threshold 1–10.
`loginFailures`/`captcha` read `effectiveSecurity()`. Threshold ignored when
captcha is off.
## 6. Data backup tab
- **Manual:** "Back up now" → `POST /api/system/backup` → server runs
`tar -czf` over `webuiDataDir()` + configured `backendDataDir`, excluding
`backups/` itself and `*.tmp`. Output:
`~/.evoscientist/backups/backup-<YYYYMMDDTHHmmss>.tar.gz`.
- **Scheduled:** enable + interval hours (1–168) + keep count (1–100).
In-process `setInterval` singleton on `globalThis` (survives dev hot reload
without duplicates), lazily started on first config read. Each run uses the
same archive function as manual (origin recorded as `auto`), then prunes
oldest archives beyond keep count. A mutex prevents overlapping runs.
- **History:** table of archives (name, size, date, origin) with download and
delete.
- **Backend data dir:** path text input, validated on save (must exist and be
readable). Used by both manual and scheduled backups.
Errors (missing dir, disk full, tar failure) go to `errorLogStore` and surface
as toasts; scheduler exceptions are caught and logged, never crash the process.
## 7. Dialog & entry point
- Configuration dropdown gains "System config" (admin only — role from
`/api/auth/me`; non-admin simply doesn't see it).
- `SystemConfigDialog.tsx`: four tabs (General / Login terms / Security /
Backup), one form state loaded via `GET /api/system/config`, single Save per
tab (`PUT`), unsaved-changes guard on close.
## Error handling
- Config file missing/corrupt → defaults + errorLog entry; never 500 the page.
- Upload rejects oversize/wrong type with 400 + toast.
- Non-admin API calls → 403 (UI never offers the entry).
- Backup failures → errorLog + toast; scheduler keeps running.
## Testing & verification
- Unit: `systemConfig.ts` (defaults, round-trip, atomic write, corrupt file),
`effectiveSecurity()` precedence, backup archive (temp-dir fixture: tar
contents, exclusions, keep-count pruning, mutex), route tests (403 for
non-admin, 400 validation, filename traversal rejected).
- Existing auth/captcha/loginFailures tests stay green.
- Manual gate (dev :4716): admin changes branding → header + login page +
favicon update; non-admin sees no menu item and gets 403; scheduled backup
fires and appears in history; restore-default flows.
## Out of scope
- Backend-owned config storage (approach B — rejected).
- Login-terms agreement checkbox / re-consent flow (display-only chosen).
- Sliding/idle session renewal (rejected 2026-08-08).
- Backup restore UI, remote/off-host backup targets.
- Multi-admin audit trail of config changes.