From 7c9193df2e862888c62dd4c2198341988cf0855c Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 11 Aug 2026 08:29:08 +0800 Subject: [PATCH] docs(webui): implementation plan for admin system config --- .../plans/2026-08-11-system-config.md | 2722 +++++++++++++++++ 1 file changed, 2722 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-system-config.md diff --git a/docs/superpowers/plans/2026-08-11-system-config.md b/docs/superpowers/plans/2026-08-11-system-config.md new file mode 100644 index 0000000..2132191 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-system-config.md @@ -0,0 +1,2722 @@ +# System Config Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Admin-only "System config" dialog (General branding / Login terms / Security / Data backup) backed by a WebUI-owned JSON override store, with logo/favicon upload, and manual + scheduled tar.gz backups of WebUI and backend data. + +**Architecture:** `system-config.json` in `webuiDataDir()` (`~/.evoscientist`) is an override layer — `null` fields fall back to env/code defaults via `effectiveSecurity()` / merge-with-defaults. Four new API route groups under `/api/system/` (config, branding, backup) reuse the `requireActor` + `requireAdmin` + `routeErrorResponse` pattern. Branding assets are served from a public path prefix so the login page can render them unauthenticated. Backups are `tar` subprocess archives; an in-process scheduler (started from `instrumentation.ts`) ticks every 60 s. + +**Tech Stack:** Next.js 16 App Router (proxy.ts middleware), Vitest, SWR, Tailwind, `tar` CLI via `execFile`. + +**Spec:** `docs/superpowers/specs/2026-08-11-system-config-design.md` + +## Global Constraints + +- `npm test` baseline: **310 passed / 1 skipped** — must stay green; `npx tsc --noEmit` clean. +- NEVER `git add -A` / `git add .`. Do not touch untracked user WIP: `src/app/components/ContextUsageIndicator.tsx`, `src/app/components/VersionBadge.tsx`, `src/app/api/system/version*/` (tracked — leave alone), `src/app/api/usage/records/`, `src/lib/contextUsage.ts`. +- **`src/app/page.tsx` contains uncommitted user WIP** (VersionBadge move, AI4Scientist rename, Image-models dropdown item) in the exact regions Tasks 8 and 10 edit. Tasks edit it but DO NOT commit it — the controller leaves those hunks in the working tree for the user. Everything else commits normally. +- Defaults must reproduce current behavior exactly: auth env-only (`WEBUI_AUTH_ENABLED === "true"`), captcha after **3** failures, password min **8**, session TTL **12 h**. +- Session expiry stays **fixed** — no sliding renewal (user rejected 2026-08-08). +- Vitest pattern for server modules: `vi.mock("server-only", () => ({}))`, set `process.env.EVOSCIENTIST_DATA_DIR` to a `fs.mkdtempSync` dir BEFORE dynamic-importing the module under test, restore env + `fs.rmSync` in `afterAll` (see `src/lib/server/usageConfig.test.ts`). +- Route tests call exported handlers directly (`await import("./route")` after env setup) — see `src/app/api/usage/routes.test.ts`. With `WEBUI_AUTH_ENABLED` unset, `requireActor` returns `{ sub: "local-admin", role: "admin" }`, so admin-gated routes are directly testable. +- Commit prefix `feat(webui):` / `fix(webui):`, one commit per task. +- Branding asset GET routes live under the **public** prefix `/api/system/branding/asset/` (spec said `/api/system/branding/logo` — moved so the proxy can allowlist GET assets without exposing the admin upload/delete route on the same path). +- Backup filenames carry origin: `backup--.tar.gz`, regex `^backup-\d{8}T\d{6}-(manual|auto)\.tar\.gz$`. + +--- + +### Task 1: System config store (`systemConfig.ts`) + +**Files:** +- Create: `src/lib/server/systemConfig.ts` +- Test: `src/lib/server/systemConfig.test.ts` + +**Interfaces:** +- Consumes: `webuiDataDir()` from `src/lib/server/dataDir.ts`; `sessionTtlSeconds()` from `src/lib/auth.ts`. +- Produces (used by Tasks 2-7, 10): + - `interface SystemConfig` (shape below), `class SystemConfigError extends Error` + - `getSystemConfig(): SystemConfig` (defaults when file absent/corrupt, mtime cache) + - `saveSystemConfig(config: SystemConfig): void` (atomic temp+rename, mode 0o600) + - `validateSystemConfig(input: unknown): SystemConfig` (throws `SystemConfigError` with user-facing message) + - `effectiveSecurity(): { authEnabled: boolean; sessionTtlSeconds: number; passwordMinLength: number; captchaEnabled: boolean; captchaThreshold: number }` + - `systemConfigPath(): string`, `brandingDir(): string` + - `brandingVersion(kind: "logo" | "favicon"): number` (mtime ms of the configured file, 0 when none) + - `publicSystemConfig(): { wordmark: string; logoVersion: number; faviconVersion: number; loginTerms: { enabled: boolean; markdown: string } }` + - `resetSystemConfigCacheForTests(): void` + +- [ ] **Step 1: Write the failing test** + +`src/lib/server/systemConfig.test.ts`: + +```ts +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-syscfg-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const { + getSystemConfig, + saveSystemConfig, + validateSystemConfig, + effectiveSecurity, + systemConfigPath, + resetSystemConfigCacheForTests, + SystemConfigError, +} = await import("./systemConfig"); + +describe("systemConfig", () => { + beforeEach(() => { + fs.rmSync(systemConfigPath(), { force: true }); + resetSystemConfigCacheForTests(); + }); + + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("returns defaults when the file is absent", () => { + const config = getSystemConfig(); + expect(config.branding.wordmark).toBe("AI4Scientist"); + expect(config.branding.logoFile).toBeNull(); + expect(config.loginTerms).toEqual({ enabled: false, markdown: "" }); + expect(config.security.authEnabled).toBeNull(); + expect(config.backup.schedule).toEqual({ + enabled: false, + intervalHours: 24, + keepCount: 7, + }); + }); + + it("round-trips a saved config and merges partial files with defaults", () => { + const config = getSystemConfig(); + config.branding.wordmark = "MyLab"; + config.security.sessionTtlHours = 4; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + expect(getSystemConfig().branding.wordmark).toBe("MyLab"); + expect(getSystemConfig().security.sessionTtlHours).toBe(4); + + fs.writeFileSync( + systemConfigPath(), + JSON.stringify({ branding: { wordmark: "Partial" } }) + ); + resetSystemConfigCacheForTests(); + const merged = getSystemConfig(); + expect(merged.branding.wordmark).toBe("Partial"); + expect(merged.security.captchaThreshold).toBeNull(); + }); + + it("falls back to defaults on a corrupt file", () => { + fs.writeFileSync(systemConfigPath(), "{not json"); + resetSystemConfigCacheForTests(); + expect(getSystemConfig().branding.wordmark).toBe("AI4Scientist"); + }); + + it("validates bounds and types", () => { + expect(() => + validateSystemConfig({ branding: { wordmark: "" } }) + ).toThrow(SystemConfigError); + expect(() => + validateSystemConfig({ security: { sessionTtlHours: 0 } }) + ).toThrow(/session/i); + expect(() => + validateSystemConfig({ security: { passwordMinLength: 3 } }) + ).toThrow(/password/i); + expect(() => + validateSystemConfig({ backup: { schedule: { keepCount: 0 } } }) + ).toThrow(/keep/i); + const valid = validateSystemConfig({ + security: { captchaEnabled: false, captchaThreshold: 5 }, + }); + expect(valid.security.captchaEnabled).toBe(false); + expect(valid.security.captchaThreshold).toBe(5); + }); + + it("rejects a non-existent backend data dir", () => { + expect(() => + validateSystemConfig({ backup: { backendDataDir: "/no/such/dir-xyz" } }) + ).toThrow(/backend data dir/i); + }); + + it("derives effective security with env/code defaults", () => { + const sec = effectiveSecurity(); + expect(sec.authEnabled).toBe(false); // WEBUI_AUTH_ENABLED unset + expect(sec.sessionTtlSeconds).toBe(12 * 60 * 60); + expect(sec.passwordMinLength).toBe(8); + expect(sec.captchaEnabled).toBe(true); + expect(sec.captchaThreshold).toBe(3); + + const config = getSystemConfig(); + config.security = { + authEnabled: true, + sessionTtlHours: 2, + passwordMinLength: 12, + captchaEnabled: false, + captchaThreshold: 5, + }; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + const overridden = effectiveSecurity(); + expect(overridden).toEqual({ + authEnabled: true, + sessionTtlSeconds: 7200, + passwordMinLength: 12, + captchaEnabled: false, + captchaThreshold: 5, + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/systemConfig.test.ts` +Expected: FAIL — `Cannot find module './systemConfig'`. + +- [ ] **Step 3: Implement `src/lib/server/systemConfig.ts`** + +```ts +import "server-only"; + +import fs from "node:fs"; +import path from "node:path"; +import { sessionTtlSeconds } from "@/lib/auth"; +import { webuiDataDir } from "./dataDir"; + +export interface SystemConfig { + branding: { wordmark: string; logoFile: string | null; faviconFile: string | null }; + loginTerms: { enabled: boolean; markdown: string }; + security: { + authEnabled: boolean | null; + sessionTtlHours: number | null; + passwordMinLength: number | null; + captchaEnabled: boolean | null; + captchaThreshold: number | null; + }; + backup: { + backendDataDir: string; + schedule: { enabled: boolean; intervalHours: number; keepCount: number }; + }; +} + +export class SystemConfigError extends Error {} + +const DEFAULTS: SystemConfig = { + branding: { wordmark: "AI4Scientist", logoFile: null, faviconFile: null }, + loginTerms: { enabled: false, markdown: "" }, + security: { + authEnabled: null, + sessionTtlHours: null, + passwordMinLength: null, + captchaEnabled: null, + captchaThreshold: null, + }, + backup: { + backendDataDir: "", + schedule: { enabled: false, intervalHours: 24, keepCount: 7 }, + }, +}; + +const globalCache = globalThis as { + __evoscientistSystemConfig?: { mtimeMs: number; config: SystemConfig }; +}; + +export function systemConfigPath(): string { + return path.join(webuiDataDir(), "system-config.json"); +} + +export function brandingDir(): string { + return path.join(webuiDataDir(), "branding"); +} + +export function resetSystemConfigCacheForTests(): void { + globalCache.__evoscientistSystemConfig = undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function merge(raw: unknown): SystemConfig { + const source = isRecord(raw) ? raw : {}; + const pick = (key: K): Partial => + isRecord(source[key]) ? (source[key] as Partial) : {}; + return { + branding: { ...DEFAULTS.branding, ...pick("branding") }, + loginTerms: { ...DEFAULTS.loginTerms, ...pick("loginTerms") }, + security: { ...DEFAULTS.security, ...pick("security") }, + backup: { + ...DEFAULTS.backup, + ...pick("backup"), + schedule: { + ...DEFAULTS.backup.schedule, + ...(isRecord((source.backup as Record | undefined)?.schedule) + ? ((source.backup as Record).schedule as Partial< + SystemConfig["backup"]["schedule"] + >) + : {}), + }, + }, + }; +} + +export function getSystemConfig(): SystemConfig { + const file = systemConfigPath(); + let mtimeMs = 0; + try { + mtimeMs = fs.statSync(file).mtimeMs; + } catch { + // Absent config file: defaults. + } + const cached = globalCache.__evoscientistSystemConfig; + if (cached && cached.mtimeMs === mtimeMs) return cached.config; + let raw: unknown = null; + if (mtimeMs > 0) { + try { + raw = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + raw = null; // Corrupt file: defaults, never crash the page. + } + } + const config = merge(raw); + globalCache.__evoscientistSystemConfig = { mtimeMs, config }; + return config; +} + +export function saveSystemConfig(config: SystemConfig): void { + const file = systemConfigPath(); + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, file); + resetSystemConfigCacheForTests(); +} + +function optBool(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function optInt( + value: unknown, + min: number, + max: number, + label: string +): number | null { + if (value === undefined || value === null) return null; + if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) { + throw new SystemConfigError(`${label} must be an integer between ${min} and ${max}.`); + } + return value as number; +} + +export function validateSystemConfig(input: unknown): SystemConfig { + const source = isRecord(input) ? input : {}; + const merged = merge(source); + + const wordmark = merged.branding.wordmark; + if ( + typeof wordmark !== "string" || + wordmark.trim().length === 0 || + wordmark.length > 30 + ) { + throw new SystemConfigError("Wordmark must be 1-30 characters."); + } + merged.branding.wordmark = wordmark.trim(); + for (const key of ["logoFile", "faviconFile"] as const) { + const value = merged.branding[key]; + if (value !== null && typeof value !== "string") { + throw new SystemConfigError(`branding.${key} must be a string or null.`); + } + } + + merged.loginTerms.enabled = merged.loginTerms.enabled === true; + if ( + typeof merged.loginTerms.markdown !== "string" || + merged.loginTerms.markdown.length > 20000 + ) { + throw new SystemConfigError("Login terms must be text of at most 20000 characters."); + } + + const rawSecurity = isRecord(source.security) ? source.security : {}; + merged.security = { + authEnabled: optBool(rawSecurity.authEnabled), + sessionTtlHours: optInt(rawSecurity.sessionTtlHours, 1, 720, "Session TTL"), + passwordMinLength: optInt(rawSecurity.passwordMinLength, 6, 64, "Password minimum length"), + captchaEnabled: optBool(rawSecurity.captchaEnabled), + captchaThreshold: optInt(rawSecurity.captchaThreshold, 1, 10, "Captcha threshold"), + }; + + const rawBackup = isRecord(source.backup) ? source.backup : {}; + const backendDataDir = + typeof rawBackup.backendDataDir === "string" ? rawBackup.backendDataDir.trim() : ""; + if (backendDataDir.length > 500) { + throw new SystemConfigError("Backend data dir is too long."); + } + if (backendDataDir) { + let stat: fs.Stats; + try { + stat = fs.statSync(backendDataDir); + } catch { + throw new SystemConfigError( + `Backend data dir does not exist: ${backendDataDir}` + ); + } + if (!stat.isDirectory()) { + throw new SystemConfigError( + `Backend data dir is not a directory: ${backendDataDir}` + ); + } + } + const rawSchedule = isRecord(rawBackup.schedule) ? rawBackup.schedule : {}; + merged.backup = { + backendDataDir, + schedule: { + enabled: rawSchedule.enabled === true, + intervalHours: + optInt(rawSchedule.intervalHours, 1, 168, "Backup interval") ?? + DEFAULTS.backup.schedule.intervalHours, + keepCount: + optInt(rawSchedule.keepCount, 1, 100, "Backup keep count") ?? + DEFAULTS.backup.schedule.keepCount, + }, + }; + return merged; +} + +export interface EffectiveSecurity { + authEnabled: boolean; + sessionTtlSeconds: number; + passwordMinLength: number; + captchaEnabled: boolean; + captchaThreshold: number; +} + +export function effectiveSecurity(): EffectiveSecurity { + const security = getSystemConfig().security; + return { + authEnabled: security.authEnabled ?? process.env.WEBUI_AUTH_ENABLED === "true", + sessionTtlSeconds: security.sessionTtlHours + ? security.sessionTtlHours * 60 * 60 + : sessionTtlSeconds(), + passwordMinLength: security.passwordMinLength ?? 8, + captchaEnabled: security.captchaEnabled ?? true, + captchaThreshold: security.captchaThreshold ?? 3, + }; +} + +export function brandingVersion(kind: "logo" | "favicon"): number { + const file = + kind === "logo" ? getSystemConfig().branding.logoFile : getSystemConfig().branding.faviconFile; + if (!file) return 0; + try { + return fs.statSync(path.join(brandingDir(), file)).mtimeMs; + } catch { + return 0; + } +} + +export interface PublicSystemConfig { + wordmark: string; + logoVersion: number; + faviconVersion: number; + loginTerms: { enabled: boolean; markdown: string }; +} + +export function publicSystemConfig(): PublicSystemConfig { + const config = getSystemConfig(); + return { + wordmark: config.branding.wordmark, + logoVersion: brandingVersion("logo"), + faviconVersion: brandingVersion("favicon"), + loginTerms: { + enabled: config.loginTerms.enabled, + markdown: config.loginTerms.enabled ? config.loginTerms.markdown : "", + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/systemConfig.test.ts` +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/systemConfig.ts src/lib/server/systemConfig.test.ts +git commit -m "feat(webui): system config override store with effective security derivation" +``` + +--- + +### Task 2: Config API routes (admin GET/PUT + public GET) + +**Files:** +- Create: `src/app/api/system/config/route.ts` +- Create: `src/app/api/system/config/public/route.ts` +- Test: `src/app/api/system/config/routes.test.ts` + +**Interfaces:** +- Consumes: Task 1 (`getSystemConfig`, `saveSystemConfig`, `validateSystemConfig`, `publicSystemConfig`, `SystemConfigError`); `requireActor`, `requireAdmin` from `src/lib/server/actor.ts`; `isCrossOrigin` from `src/lib/server/workspace.ts`; `NO_STORE`, `routeErrorResponse` from `src/lib/server/routeErrors.ts`. +- Produces: `GET /api/system/config` → `SystemConfig`; `PUT /api/system/config` (body = partial config JSON) → saved `SystemConfig`; `GET /api/system/config/public` → `PublicSystemConfig`. + +- [ ] **Step 1: Write the failing test** + +`src/app/api/system/config/routes.test.ts`: + +```ts +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-syscfg-route-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const adminRoute = await import("./route"); +const publicRoute = await import("./public/route"); +const { systemConfigPath } = await import("@/lib/server/systemConfig"); + +function request(url: string, options: RequestInit = {}): Request { + return new Request(url, options); +} + +describe("system config routes", () => { + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("GET returns defaults for the local admin", async () => { + const response = await adminRoute.GET( + request("http://localhost/api/system/config") as never + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + branding: { wordmark: "AI4Scientist" }, + }); + }); + + it("PUT validates, persists, and the public route reflects it", async () => { + const put = await adminRoute.PUT( + request("http://localhost/api/system/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + branding: { wordmark: "TestLab" }, + loginTerms: { enabled: true, markdown: "**Be nice.**" }, + }), + }) as never + ); + expect(put.status).toBe(200); + expect(JSON.parse(fs.readFileSync(systemConfigPath(), "utf8"))).toMatchObject({ + branding: { wordmark: "TestLab" }, + }); + + const pub = await publicRoute.GET( + request("http://localhost/api/system/config/public") as never + ); + expect(pub.status).toBe(200); + expect(await pub.json()).toMatchObject({ + wordmark: "TestLab", + logoVersion: 0, + loginTerms: { enabled: true, markdown: "**Be nice.**" }, + }); + }); + + it("PUT rejects invalid values with 400", async () => { + const response = await adminRoute.PUT( + request("http://localhost/api/system/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ branding: { wordmark: "" } }), + }) as never + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "INVALID_REQUEST" }); + }); + + it("public route hides terms markdown when disabled", async () => { + await adminRoute.PUT( + request("http://localhost/api/system/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ loginTerms: { enabled: false, markdown: "secret" } }), + }) as never + ); + const pub = await publicRoute.GET( + request("http://localhost/api/system/config/public") as never + ); + expect(await pub.json()).toMatchObject({ + loginTerms: { enabled: false, markdown: "" }, + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/app/api/system/config/routes.test.ts` +Expected: FAIL — cannot find `./route`. + +- [ ] **Step 3: Implement the routes** + +`src/app/api/system/config/route.ts`: + +```ts +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import { + getSystemConfig, + saveSystemConfig, + validateSystemConfig, +} from "@/lib/server/systemConfig"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + requireAdmin(actor); + return NextResponse.json(getSystemConfig(), { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} + +export async function PUT(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + requireAdmin(actor); + const body = (await request.json().catch(() => null)) as unknown; + const config = validateSystemConfig(body); + saveSystemConfig(config); + return NextResponse.json(config, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} +``` + +`src/app/api/system/config/public/route.ts`: + +```ts +import { type NextRequest, NextResponse } from "next/server"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import { publicSystemConfig } from "@/lib/server/systemConfig"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Unauthenticated branding/terms subset — the login page needs it before + * the user has a session. Contains no secrets: wordmark, asset versions and + * the (opt-in) public terms text only. */ +export async function GET(request: NextRequest) { + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + return NextResponse.json(publicSystemConfig(), { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/app/api/system/config/routes.test.ts` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/system/config/ +git commit -m "feat(webui): admin + public system config API routes" +``` + +--- + +### Task 3: Branding upload/stream routes + +**Files:** +- Create: `src/app/api/system/branding/route.ts` (POST upload, DELETE restore) +- Create: `src/app/api/system/branding/asset/[asset]/route.ts` (public GET) +- Test: `src/app/api/system/branding/routes.test.ts` + +**Interfaces:** +- Consumes: Task 1 (`getSystemConfig`, `saveSystemConfig`, `brandingDir`); actor/route helpers as in Task 2. +- Produces: + - `POST /api/system/branding` (multipart: `kind` = `logo`|`favicon`, `file`) → `{ file: string; version: number }`; limits: ≤512KB, mime png/jpeg/svg (+ico for favicon) + - `DELETE /api/system/branding?kind=logo|favicon` → `{ ok: true }` + - `GET /api/system/branding/asset/[asset]` (public) → image bytes, `Cache-Control: public, max-age=60`, 404 when no custom asset + +- [ ] **Step 1: Write the failing test** + +`src/app/api/system/branding/routes.test.ts`: + +```ts +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-brand-route-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const branding = await import("./route"); +const asset = await import("./asset/[asset]/route"); +const { getSystemConfig, brandingDir } = await import("@/lib/server/systemConfig"); + +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64" +); + +function upload(kind: string, body: Buffer | string, type: string) { + const form = new FormData(); + form.set("kind", kind); + form.set("file", new File([body], `test.${type.split("/")[1]}`, { type })); + return branding.POST( + new Request("http://localhost/api/system/branding", { + method: "POST", + body: form, + }) as never + ); +} + +describe("branding routes", () => { + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("uploads a logo, serves it publicly, and reports a version", async () => { + const response = await upload("logo", PNG, "image/png"); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.file).toBe("logo.png"); + expect(body.version).toBeGreaterThan(0); + expect(getSystemConfig().branding.logoFile).toBe("logo.png"); + expect(fs.existsSync(path.join(brandingDir(), "logo.png"))).toBe(true); + + const served = await asset.GET( + new Request("http://localhost/api/system/branding/asset/logo") as never, + { params: Promise.resolve({ asset: "logo" }) } + ); + expect(served.status).toBe(200); + expect(served.headers.get("Content-Type")).toBe("image/png"); + expect(Buffer.from(await served.arrayBuffer())).toEqual(PNG); + }); + + it("rejects wrong mime and oversize uploads", async () => { + expect((await upload("logo", "plain text", "text/plain")).status).toBe(400); + const big = Buffer.alloc(600 * 1024, 1); + expect((await upload("logo", big, "image/png")).status).toBe(400); + }); + + it("404s for unknown asset names and missing files", async () => { + const bad = await asset.GET( + new Request("http://localhost/api/system/branding/asset/nope") as never, + { params: Promise.resolve({ asset: "nope" }) } + ); + expect(bad.status).toBe(404); + const missing = await asset.GET( + new Request("http://localhost/api/system/branding/asset/favicon") as never, + { params: Promise.resolve({ asset: "favicon" }) } + ); + expect(missing.status).toBe(404); + }); + + it("DELETE restores the default", async () => { + const response = await branding.DELETE( + new Request("http://localhost/api/system/branding?kind=logo", { + method: "DELETE", + }) as never + ); + expect(response.status).toBe(200); + expect(getSystemConfig().branding.logoFile).toBeNull(); + expect(fs.existsSync(path.join(brandingDir(), "logo.png"))).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/app/api/system/branding/routes.test.ts` +Expected: FAIL — cannot find `./route`. + +- [ ] **Step 3: Implement the routes** + +`src/app/api/system/branding/route.ts`: + +```ts +import fs from "node:fs"; +import path from "node:path"; +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import { + brandingDir, + getSystemConfig, + saveSystemConfig, +} from "@/lib/server/systemConfig"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_BYTES = 512 * 1024; +const KINDS = ["logo", "favicon"] as const; +type Kind = (typeof KINDS)[number]; +const MIME_TO_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/svg+xml": "svg", + "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", +}; + +function parseKind(value: unknown): Kind { + if (value === "logo" || value === "favicon") return value; + throw new Error("kind must be 'logo' or 'favicon'."); +} + +export async function POST(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + requireAdmin(actor); + const form = await request.formData(); + const kind = parseKind(form.get("kind")); + const file = form.get("file"); + if (!(file instanceof File)) throw new Error("Missing upload file."); + const ext = MIME_TO_EXT[file.type]; + if (!ext || (kind === "logo" && ext === "ico")) { + throw new Error("Unsupported image type (use PNG, JPEG or SVG)."); + } + const bytes = Buffer.from(await file.arrayBuffer()); + if (bytes.length === 0) throw new Error("Upload is empty."); + if (bytes.length > MAX_BYTES) { + throw new Error("Image must be 512KB or smaller."); + } + const dir = brandingDir(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + for (const stale of fs.readdirSync(dir)) { + if (stale.startsWith(`${kind}.`)) fs.rmSync(path.join(dir, stale), { force: true }); + } + const name = `${kind}.${ext}`; + fs.writeFileSync(path.join(dir, name), bytes, { mode: 0o600 }); + const config = getSystemConfig(); + config.branding[kind === "logo" ? "logoFile" : "faviconFile"] = name; + saveSystemConfig(config); + const version = fs.statSync(path.join(dir, name)).mtimeMs; + return NextResponse.json({ file: name, version }, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} + +export async function DELETE(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + requireAdmin(actor); + const kind = parseKind(request.nextUrl.searchParams.get("kind")); + const dir = brandingDir(); + if (fs.existsSync(dir)) { + for (const stale of fs.readdirSync(dir)) { + if (stale.startsWith(`${kind}.`)) fs.rmSync(path.join(dir, stale), { force: true }); + } + } + const config = getSystemConfig(); + config.branding[kind === "logo" ? "logoFile" : "faviconFile"] = null; + saveSystemConfig(config); + return NextResponse.json({ ok: true }, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} +``` + +`src/app/api/system/branding/asset/[asset]/route.ts`: + +```ts +import fs from "node:fs"; +import path from "node:path"; +import { type NextRequest, NextResponse } from "next/server"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { brandingDir, getSystemConfig } from "@/lib/server/systemConfig"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const CONTENT_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + svg: "image/svg+xml", + ico: "image/x-icon", +}; + +/** Public branding assets (logo/favicon). Filenames come from the config + * file, never from the URL, so there is no path traversal surface. */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ asset: string }> } +) { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: { "Cache-Control": "no-store" } } + ); + } + const { asset } = await params; + if (asset !== "logo" && asset !== "favicon") { + return new NextResponse("Not found.", { status: 404 }); + } + const file = + asset === "logo" + ? getSystemConfig().branding.logoFile + : getSystemConfig().branding.faviconFile; + const ext = file?.split(".").pop() ?? ""; + const contentType = CONTENT_TYPES[ext]; + const target = file ? path.join(brandingDir(), file) : null; + if (!file || !contentType || !target || !fs.existsSync(target)) { + return new NextResponse("Not found.", { status: 404 }); + } + return new NextResponse(new Uint8Array(fs.readFileSync(target)), { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=60", + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/app/api/system/branding/routes.test.ts` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/system/branding/ +git commit -m "feat(webui): branding upload + public asset routes" +``` + +--- + +### Task 4: Wire security overrides into auth, captcha, login, proxy + +**Files:** +- Modify: `src/lib/server/actor.ts` (line 29) +- Modify: `src/lib/server/auth.ts` (line 37, `requireSecret`) +- Modify: `src/lib/server/loginFailures.ts` (`shouldRequireCaptcha`) +- Modify: `src/app/api/auth/login/route.ts` (`isAuthenticationEnabled`, `sessionTtlSeconds` usages) +- Modify: `src/app/api/auth/status/route.ts`, `me/route.ts`, `captcha/route.ts`, `password/route.ts` +- Modify: `src/proxy.ts` (runtime + `isAuthenticationEnabled` + public paths) +- Test: `src/lib/server/securityOverrides.test.ts` + +**Interfaces:** +- Consumes: `effectiveSecurity()` from Task 1. +- Produces: every auth decision point honors the config override; proxy allowlists `/api/system/config/public` and `/api/system/branding/asset/`. + +Swap pattern at every site: `isAuthenticationEnabled()` → `effectiveSecurity().authEnabled` (drop the `@/lib/auth` import of `isAuthenticationEnabled` where now unused). Exact sites: + +- `src/lib/server/actor.ts:29` — `if (!effectiveSecurity().authEnabled) {` +- `src/lib/server/auth.ts:37` — same, inside `requireSecret` +- `src/app/api/auth/login/route.ts:29` — same; line 90: `sessionTtlSeconds()` → `effectiveSecurity().sessionTtlSeconds` (keep `rememberTtlSeconds()` unchanged) +- `src/app/api/auth/status/route.ts:8` — `{ enabled: effectiveSecurity().authEnabled }` +- `src/app/api/auth/me/route.ts:12`, `captcha/route.ts:9`, `password/route.ts:40` — same swap +- `src/app/api/auth/password/route.ts:31` — replace `if (password.length < 8) return "New password must contain at least 8 characters.";` with: + ```ts + const minLength = effectiveSecurity().passwordMinLength; + if (password.length < minLength) + return `New password must contain at least ${minLength} characters.`; + ``` +- `src/lib/server/loginFailures.ts` `shouldRequireCaptcha`: + ```ts + export function shouldRequireCaptcha(ip: string): boolean { + const { captchaEnabled, captchaThreshold } = effectiveSecurity(); + if (!captchaEnabled) return false; + const entry = entries.get(ip); + if (!entry) return false; + if (Date.now() - entry.touchedAt > ENTRY_TTL_MS) { + entries.delete(ip); + return false; + } + return entry.count >= captchaThreshold; + } + ``` + (Keep the exported `FAILURE_THRESHOLD` — other code/tests reference it.) +- `src/proxy.ts`: + - Add top-level `export const runtime = "nodejs";` (Next 16 proxy supports the Node runtime; required for fs-backed config reads). + - Line 104: `if (!effectiveSecurity().authEnabled || isPublicPath(...))` with `import { effectiveSecurity } from "@/lib/server/systemConfig";` + - `isPublicPath`: add `pathname === "/api/system/config/public" ||` and `pathname.startsWith("/api/system/branding/asset/") ||` + +- [ ] **Step 1: Write the failing test** + +`src/lib/server/securityOverrides.test.ts`: + +```ts +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-sec-override-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const { + getSystemConfig, + saveSystemConfig, + resetSystemConfigCacheForTests, +} = await import("./systemConfig"); +const loginFailures = await import("./loginFailures"); + +describe("security overrides", () => { + beforeEach(() => { + const config = getSystemConfig(); + config.security = { + authEnabled: null, + sessionTtlHours: null, + passwordMinLength: null, + captchaEnabled: null, + captchaThreshold: null, + }; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + loginFailures.clearAllFailuresForTests(); + }); + + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("requires captcha after 3 failures by default", () => { + loginFailures.recordFailure("1.1.1.1"); + loginFailures.recordFailure("1.1.1.1"); + expect(loginFailures.shouldRequireCaptcha("1.1.1.1")).toBe(false); + loginFailures.recordFailure("1.1.1.1"); + expect(loginFailures.shouldRequireCaptcha("1.1.1.1")).toBe(true); + }); + + it("honors a captcha threshold override", () => { + const config = getSystemConfig(); + config.security.captchaThreshold = 1; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + loginFailures.recordFailure("2.2.2.2"); + expect(loginFailures.shouldRequireCaptcha("2.2.2.2")).toBe(true); + }); + + it("never requires captcha when disabled", () => { + const config = getSystemConfig(); + config.security.captchaEnabled = false; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + for (let i = 0; i < 10; i += 1) loginFailures.recordFailure("3.3.3.3"); + expect(loginFailures.shouldRequireCaptcha("3.3.3.3")).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/securityOverrides.test.ts` +Expected: FAIL — tests 2 and 3 fail (override not honored yet). + +- [ ] **Step 3: Apply the swaps listed above** + +- [ ] **Step 4: Verify** + +Run: `npx vitest run src/lib/server/securityOverrides.test.ts src/lib/server/auth.test.ts src/lib/server/captcha.test.ts src/lib/server/loginFailures.test.ts && npx tsc --noEmit` +Expected: all pass. Then full suite: `npm test` — 310+3 passed / 1 skipped. + +- [ ] **Step 5: Manual proxy check** + +`npm run build` must compile (verifies `export const runtime = "nodejs"` is accepted in `src/proxy.ts`). If the build rejects a Node-runtime proxy, STOP and escalate to the controller — do not silently revert the override wiring. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/actor.ts src/lib/server/auth.ts src/lib/server/loginFailures.ts src/lib/server/securityOverrides.test.ts src/app/api/auth/ src/proxy.ts +git commit -m "feat(webui): honor security overrides in auth, captcha, login, and proxy" +``` + +--- + +### Task 5: Backup archive module (`backup.ts`) + +**Files:** +- Create: `src/lib/server/backup.ts` +- Test: `src/lib/server/backup.test.ts` + +**Interfaces:** +- Consumes: `webuiDataDir()`, `getSystemConfig()`. +- Produces (Tasks 6-7): + - `interface BackupEntry { name: string; size: number; createdAt: string; origin: "manual" | "auto" }` + - `backupsDir(): string` + - `isValidBackupName(name: string): boolean` + - `createBackup(origin: "manual" | "auto"): Promise` (mutex: concurrent calls share one run) + - `listBackups(): BackupEntry[]` (newest first) + - `pruneBackups(keepCount: number): string[]` (names deleted) + - `backupFilePath(name: string): string` (throws on invalid name) + +- [ ] **Step 1: Write the failing test** + +`src/lib/server/backup.test.ts`: + +```ts +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-backup-")); +const backendDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-backend-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const backup = await import("./backup"); +const { getSystemConfig, saveSystemConfig, resetSystemConfigCacheForTests } = + await import("./systemConfig"); + +describe("backup", () => { + beforeEach(() => { + fs.rmSync(backup.backupsDir(), { recursive: true, force: true }); + const config = getSystemConfig(); + config.backup.backendDataDir = backendDir; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + }); + + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + fs.rmSync(backendDir, { recursive: true, force: true }); + }); + + it("creates a tar.gz containing webui + backend data, excluding backups/", async () => { + fs.writeFileSync(path.join(dataDir, "webui-users.sqlite3"), "users"); + fs.mkdirSync(backup.backupsDir(), { recursive: true }); + fs.writeFileSync(path.join(backup.backupsDir(), "stale.txt"), "x"); + fs.writeFileSync(path.join(backendDir, "memory.md"), "memory"); + + const entry = await backup.createBackup("manual"); + expect(entry.name).toMatch(/^backup-\d{8}T\d{6}-manual\.tar\.gz$/); + expect(entry.size).toBeGreaterThan(0); + + const listing = execFileSync("tar", [ + "-tzf", + path.join(backup.backupsDir(), entry.name), + ]).toString(); + expect(listing).toContain("webui-users.sqlite3"); + expect(listing).toContain("memory.md"); + expect(listing).not.toContain("stale.txt"); + }); + + it("validates names and rejects traversal", () => { + expect(backup.isValidBackupName("backup-20260811T073000-auto.tar.gz")).toBe(true); + expect(backup.isValidBackupName("../etc/passwd")).toBe(false); + expect(backup.isValidBackupName("backup.tar.gz")).toBe(false); + expect(() => backup.backupFilePath("..%2f..")).toThrow(); + }); + + it("lists newest first and prunes beyond keep count", async () => { + const dir = backup.backupsDir(); + fs.mkdirSync(dir, { recursive: true }); + const names = [ + "backup-20260801T000000-manual.tar.gz", + "backup-20260802T000000-auto.tar.gz", + "backup-20260803T000000-manual.tar.gz", + ]; + for (const name of names) fs.writeFileSync(path.join(dir, name), "x"); + fs.writeFileSync(path.join(dir, "ignore-me.txt"), "x"); + + expect(backup.listBackups().map((e) => e.name)).toEqual([...names].reverse()); + expect(backup.pruneBackups(2)).toEqual([names[0]]); + expect(backup.listBackups().map((e) => e.name)).toEqual([...names].reverse().slice(0, 2)); + }); + + it("shares one in-flight run across concurrent callers", async () => { + const [a, b] = await Promise.all([ + backup.createBackup("manual"), + backup.createBackup("auto"), + ]); + expect(a.name).toBe(b.name); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/backup.test.ts` +Expected: FAIL — cannot find `./backup`. + +- [ ] **Step 3: Implement `src/lib/server/backup.ts`** + +```ts +import "server-only"; + +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import { webuiDataDir } from "./dataDir"; +import { getSystemConfig } from "./systemConfig"; + +const execFileAsync = promisify(execFile); + +export interface BackupEntry { + name: string; + size: number; + createdAt: string; + origin: "manual" | "auto"; +} + +const NAME_RE = /^backup-(\d{8}T\d{6})-(manual|auto)\.tar\.gz$/; + +export function backupsDir(): string { + return path.join(webuiDataDir(), "backups"); +} + +export function isValidBackupName(name: string): boolean { + return NAME_RE.test(name); +} + +export function backupFilePath(name: string): string { + if (!isValidBackupName(name)) throw new Error("Invalid backup file name."); + return path.join(backupsDir(), name); +} + +function timestamp(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` + + `T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` + ); +} + +let running: Promise | null = null; + +export function createBackup(origin: "manual" | "auto"): Promise { + if (running) return running; + running = runBackup(origin).finally(() => { + running = null; + }); + return running; +} + +async function runBackup(origin: "manual" | "auto"): Promise { + const config = getSystemConfig(); + const dataDir = webuiDataDir(); + const outDir = backupsDir(); + fs.mkdirSync(outDir, { recursive: true }); + const name = `backup-${timestamp(new Date())}-${origin}.tar.gz`; + const out = path.join(outDir, name); + const base = path.basename(dataDir); + const args = [ + "-czf", out, + "--exclude", `${base}/backups`, + "-C", path.dirname(dataDir), base, + ]; + const backend = config.backup.backendDataDir.trim(); + if (backend) { + if (!fs.existsSync(backend)) { + throw new Error(`Backend data directory does not exist: ${backend}`); + } + args.push("-C", path.dirname(backend), path.basename(backend)); + } + try { + await execFileAsync("tar", args); + } catch (error) { + fs.rmSync(out, { force: true }); + throw error instanceof Error ? error : new Error("Backup archive failed."); + } + const stat = fs.statSync(out); + return { name, size: stat.size, createdAt: stat.mtime.toISOString(), origin }; +} + +export function listBackups(): BackupEntry[] { + const dir = backupsDir(); + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter(isValidBackupName) + .map((name) => { + const stat = fs.statSync(path.join(dir, name)); + return { + name, + size: stat.size, + createdAt: stat.mtime.toISOString(), + origin: name.match(NAME_RE)![2] as "manual" | "auto", + }; + }) + .sort((a, b) => b.name.localeCompare(a.name)); +} + +export function pruneBackups(keepCount: number): string[] { + const removed: string[] = []; + for (const entry of listBackups().slice(Math.max(0, keepCount))) { + fs.rmSync(backupFilePath(entry.name), { force: true }); + removed.push(entry.name); + } + return removed; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/backup.test.ts` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/backup.ts src/lib/server/backup.test.ts +git commit -m "feat(webui): tar.gz backup archive module with prune and mutex" +``` + +--- + +### Task 6: Backup scheduler + instrumentation + +**Files:** +- Create: `src/lib/server/backupScheduler.ts` +- Create: `src/instrumentation.ts` +- Test: `src/lib/server/backupScheduler.test.ts` + +**Interfaces:** +- Consumes: `createBackup`, `pruneBackups` (Task 5); `getSystemConfig` (Task 1); `appendErrorLog(sub, { source, code, message })` from `src/lib/server/errorLogStore.ts`. +- Produces: `startBackupScheduler(): void` (idempotent, `unref`'d 60 s interval on `globalThis`); `tickBackupSchedule(now?: number): Promise` (exported for tests); `stopBackupSchedulerForTests(): void`. + +- [ ] **Step 1: Write the failing test** + +`src/lib/server/backupScheduler.test.ts`: + +```ts +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-sched-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const scheduler = await import("./backupScheduler"); +const { listBackups, backupsDir } = await import("./backup"); +const { getSystemConfig, saveSystemConfig, resetSystemConfigCacheForTests } = + await import("./systemConfig"); + +const HOUR = 3600_000; +let now = 1_800_000_000_000; + +describe("backupScheduler.tickBackupSchedule", () => { + beforeEach(() => { + fs.rmSync(backupsDir(), { recursive: true, force: true }); + scheduler.stopBackupSchedulerForTests(); + const config = getSystemConfig(); + config.backup.backendDataDir = ""; + config.backup.schedule = { enabled: true, intervalHours: 1, keepCount: 2 }; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + }); + + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("runs when due, skips when not due, and prunes", async () => { + await scheduler.tickBackupSchedule(now); + expect(listBackups()).toHaveLength(1); + + await scheduler.tickBackupSchedule(now + 30 * 60_000); // 30 min later: not due + expect(listBackups()).toHaveLength(1); + + await scheduler.tickBackupSchedule(now + HOUR); + expect(listBackups()).toHaveLength(2); + + await scheduler.tickBackupSchedule(now + 2 * HOUR); // creates 3rd, prunes to 2 + expect(listBackups()).toHaveLength(2); + expect(listBackups()[0].origin).toBe("auto"); + }); + + it("does nothing when disabled", async () => { + const config = getSystemConfig(); + config.backup.schedule.enabled = false; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + await scheduler.tickBackupSchedule(now + 10 * HOUR); + expect(listBackups()).toHaveLength(0); + }); +}); +``` + +Note: `createBackup` names archives with second precision; the three runs above are spaced ≥1 h in fake time but `timestamp(new Date())` uses REAL time — two runs within the same real second would collide. To keep the test deterministic, `runBackup` in Task 5 names files from real time; spaced test runs are fine because each `tickBackupSchedule` executes sequentially and tar takes >1 s? Not guaranteed. Add to Task 5's `runBackup`: if `fs.existsSync(out)`, append `-2`, `-3`… before the `.tar.gz`? That breaks the name regex. Instead: sleep not allowed. **Amendment to Task 5** (apply now if not yet implemented, else as a fix): in `runBackup`, when the computed name already exists, wait via `await new Promise((r) => setTimeout(r, 1100))` and recompute. Update Task 5 accordingly before this task. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/backupScheduler.test.ts` +Expected: FAIL — cannot find `./backupScheduler`. + +- [ ] **Step 3: Implement** + +`src/lib/server/backupScheduler.ts`: + +```ts +import "server-only"; + +import { createBackup, pruneBackups } from "./backup"; +import { getSystemConfig } from "./systemConfig"; +import { appendErrorLog } from "./errorLogStore"; + +const TICK_MS = 60_000; + +const globalState = globalThis as { + __evoscientistBackupTimer?: NodeJS.Timeout; + __evoscientistBackupLastRunAt?: number; +}; + +/** One scheduler tick: run an auto-backup when the schedule says one is due, + * then prune to the configured keep count. Never throws — the interval must + * survive individual failures. */ +export async function tickBackupSchedule(now = Date.now()): Promise { + const schedule = getSystemConfig().backup.schedule; + if (!schedule.enabled) return; + const lastRunAt = globalState.__evoscientistBackupLastRunAt ?? 0; + if (now - lastRunAt < schedule.intervalHours * 3_600_000) return; + try { + await createBackup("auto"); + pruneBackups(schedule.keepCount); + globalState.__evoscientistBackupLastRunAt = now; + } catch (error) { + try { + appendErrorLog("system", { + source: "backup", + code: null, + message: + error instanceof Error ? error.message : "Scheduled backup failed.", + }); + } catch { + // Logging must never mask scheduler liveness. + } + } +} + +export function startBackupScheduler(): void { + if (globalState.__evoscientistBackupTimer) return; + const timer = setInterval(() => { + void tickBackupSchedule(); + }, TICK_MS); + timer.unref(); + globalState.__evoscientistBackupTimer = timer; +} + +export function stopBackupSchedulerForTests(): void { + if (globalState.__evoscientistBackupTimer) { + clearInterval(globalState.__evoscientistBackupTimer); + } + globalState.__evoscientistBackupTimer = undefined; + globalState.__evoscientistBackupLastRunAt = undefined; +} +``` + +`src/instrumentation.ts`: + +```ts +export async function register(): Promise { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { startBackupScheduler } = await import("./lib/server/backupScheduler"); + startBackupScheduler(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/backupScheduler.test.ts` +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/backupScheduler.ts src/lib/server/backupScheduler.test.ts src/instrumentation.ts +git commit -m "feat(webui): scheduled backups via instrumentation-started interval" +``` + +--- + +### Task 7: Backup API routes + +**Files:** +- Create: `src/app/api/system/backup/route.ts` (GET history, POST run now) +- Create: `src/app/api/system/backup/[file]/route.ts` (GET download, DELETE) +- Test: `src/app/api/system/backup/routes.test.ts` + +**Interfaces:** +- Consumes: Task 5 (`createBackup`, `listBackups`, `pruneBackups`, `backupFilePath`, `isValidBackupName`); Task 1 (`getSystemConfig`); route helpers from Task 2. +- Produces: + - `GET /api/system/backup` → `{ backups: BackupEntry[] }` + - `POST /api/system/backup` → created `BackupEntry` (also prunes to configured keep count) + - `GET /api/system/backup/[file]` → streamed `application/gzip` download (`Content-Disposition: attachment`) + - `DELETE /api/system/backup/[file]` → 204; invalid name → 400, missing → 404 + +- [ ] **Step 1: Write the failing test** + +`src/app/api/system/backup/routes.test.ts`: + +```ts +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-backup-route-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const collection = await import("./route"); +const single = await import("./[file]/route"); +const { backupsDir, listBackups } = await import("@/lib/server/backup"); + +function params(file: string) { + return { params: Promise.resolve({ file }) }; +} + +describe("backup routes", () => { + beforeEach(() => { + fs.rmSync(backupsDir(), { recursive: true, force: true }); + }); + + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("POST creates an archive and GET lists it", async () => { + const created = await collection.POST( + new Request("http://localhost/api/system/backup", { method: "POST" }) as never + ); + expect(created.status).toBe(200); + const entry = await created.json(); + expect(entry.name).toMatch(/^backup-\d{8}T\d{6}-manual\.tar\.gz$/); + + const list = await collection.GET( + new Request("http://localhost/api/system/backup") as never + ); + const body = await list.json(); + expect(body.backups.map((b: { name: string }) => b.name)).toContain(entry.name); + }); + + it("GET [file] streams the archive; DELETE removes it", async () => { + fs.mkdirSync(backupsDir(), { recursive: true }); + const name = "backup-20260811T000000-manual.tar.gz"; + fs.writeFileSync(path.join(backupsDir(), name), "gzip-bytes"); + + const download = await single.GET( + new Request(`http://localhost/api/system/backup/${name}`) as never, + params(name) + ); + expect(download.status).toBe(200); + expect(download.headers.get("Content-Type")).toBe("application/gzip"); + expect(download.headers.get("Content-Disposition")).toContain(name); + expect(await download.text()).toBe("gzip-bytes"); + + const removed = await single.DELETE( + new Request(`http://localhost/api/system/backup/${name}`, { method: "DELETE" }) as never, + params(name) + ); + expect(removed.status).toBe(204); + expect(listBackups()).toHaveLength(0); + }); + + it("rejects invalid names with 400 and missing files with 404", async () => { + const traversal = await single.GET( + new Request("http://localhost/api/system/backup/..%2Fsecret") as never, + params("..%2Fsecret") + ); + expect(traversal.status).toBe(400); + const missing = await single.GET( + new Request("http://localhost/api/system/backup/backup-20260811T000000-auto.tar.gz") as never, + params("backup-20260811T000000-auto.tar.gz") + ); + expect(missing.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/app/api/system/backup/routes.test.ts` +Expected: FAIL — cannot find `./route`. + +- [ ] **Step 3: Implement the routes** + +`src/app/api/system/backup/route.ts`: + +```ts +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import { createBackup, listBackups, pruneBackups } from "@/lib/server/backup"; +import { getSystemConfig } from "@/lib/server/systemConfig"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function forbidden() { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); +} + +export async function GET(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) return forbidden(); + actor = requireActor(request); + requireAdmin(actor); + return NextResponse.json({ backups: listBackups() }, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} + +export async function POST(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) return forbidden(); + actor = requireActor(request); + requireAdmin(actor); + const entry = await createBackup("manual"); + pruneBackups(getSystemConfig().backup.schedule.keepCount); + return NextResponse.json(entry, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} +``` + +`src/app/api/system/backup/[file]/route.ts`: + +```ts +import fs from "node:fs"; +import { Readable } from "node:stream"; +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import { backupFilePath, isValidBackupName } from "@/lib/server/backup"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ file: string }> }; + +async function resolve(request: NextRequest, params: Params) { + if (isCrossOrigin(request)) { + return { + response: NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ), + }; + } + const actor = requireActor(request); + requireAdmin(actor); + const { file } = await params.params; + if (!isValidBackupName(file)) { + return { response: NextResponse.json( + { code: "INVALID_REQUEST", message: "Invalid backup file name." }, + { status: 400, headers: NO_STORE } + ) }; + } + return { actor, file, path: backupFilePath(file) }; +} + +export async function GET(request: NextRequest, params: Params) { + try { + const resolved = await resolve(request, params); + if ("response" in resolved) return resolved.response; + if (!fs.existsSync(resolved.path)) { + return new NextResponse("Not found.", { status: 404, headers: NO_STORE }); + } + const stream = Readable.toWeb(fs.createReadStream(resolved.path)) as ReadableStream; + return new NextResponse(stream, { + headers: { + "Content-Type": "application/gzip", + "Content-Disposition": `attachment; filename="${resolved.file}"`, + }, + }); + } catch (error) { + return routeErrorResponse(error); + } +} + +export async function DELETE(request: NextRequest, params: Params) { + try { + const resolved = await resolve(request, params); + if ("response" in resolved) return resolved.response; + if (!fs.existsSync(resolved.path)) { + return new NextResponse("Not found.", { status: 404, headers: NO_STORE }); + } + fs.rmSync(resolved.path); + return new NextResponse(null, { status: 204, headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/app/api/system/backup/routes.test.ts` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/system/backup/ +git commit -m "feat(webui): backup run/history/download/delete API routes" +``` + +--- + +### Task 8: SystemConfigDialog shell + General + Login terms tabs + menu entry + +**Files:** +- Create: `src/app/hooks/usePublicSystemConfig.ts` +- Create: `src/app/components/system-config/SystemConfigDialog.tsx` +- Create: `src/app/components/system-config/GeneralTab.tsx` +- Create: `src/app/components/system-config/LoginTermsTab.tsx` +- Modify: `src/app/page.tsx` (menu entry + dialog mount + admin gate — **NOT committed**, contains user WIP) + +**Interfaces:** +- Consumes: Task 2 routes; `SystemConfig` type from Task 1 (re-declared client-side in the hook file — server module is `server-only`). +- Produces (Tasks 9-10): + - `PUBLIC_SYSTEM_CONFIG_KEY = "/api/system/config/public"`, `usePublicSystemConfig(): PublicSystemConfig | null`, `logoSrc(data): string` + - `SystemConfigDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void })` + - Tab components take `{ draft, setDraft, onSaved }: TabProps` where `draft: SystemConfig`, `setDraft: (updater: (c: SystemConfig) => SystemConfig) => void`, `onSaved: () => void` (revalidates admin + public SWR keys). + +- [ ] **Step 1: Create the public-config hook** + +`src/app/hooks/usePublicSystemConfig.ts`: + +```ts +import useSWR from "swr"; + +export interface PublicSystemConfig { + wordmark: string; + logoVersion: number; + faviconVersion: number; + loginTerms: { enabled: boolean; markdown: string }; +} + +export const PUBLIC_SYSTEM_CONFIG_KEY = "/api/system/config/public"; +export const DEFAULT_WORDMARK = "AI4Scientist"; + +const fetcher = async (url: string): Promise => { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Config request failed (${response.status}).`); + return (await response.json()) as PublicSystemConfig; +}; + +export function usePublicSystemConfig(): PublicSystemConfig | null { + const { data } = useSWR(PUBLIC_SYSTEM_CONFIG_KEY, fetcher, { + revalidateOnFocus: false, + shouldRetryOnError: false, + }); + return data ?? null; +} + +export function logoSrc(data: PublicSystemConfig | null): string { + return data && data.logoVersion > 0 + ? `/api/system/branding/asset/logo?v=${data.logoVersion}` + : "/evoscientist-logo.png"; +} +``` + +- [ ] **Step 2: Create the dialog shell** + +`src/app/components/system-config/SystemConfigDialog.tsx`: + +```tsx +"use client"; + +import { useEffect, useState } from "react"; +import useSWR, { mutate as globalMutate } from "swr"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { errorToast } from "@/lib/errorReporter"; +import { PUBLIC_SYSTEM_CONFIG_KEY } from "@/app/hooks/usePublicSystemConfig"; +import { GeneralTab } from "./GeneralTab"; +import { LoginTermsTab } from "./LoginTermsTab"; +import { SecurityTab } from "./SecurityTab"; +import { BackupTab } from "./BackupTab"; + +export interface SystemConfig { + branding: { wordmark: string; logoFile: string | null; faviconFile: string | null }; + loginTerms: { enabled: boolean; markdown: string }; + security: { + authEnabled: boolean | null; + sessionTtlHours: number | null; + passwordMinLength: number | null; + captchaEnabled: boolean | null; + captchaThreshold: number | null; + }; + backup: { + backendDataDir: string; + schedule: { enabled: boolean; intervalHours: number; keepCount: number }; + }; +} + +export interface TabProps { + draft: SystemConfig; + setDraft: (updater: (config: SystemConfig) => SystemConfig) => void; + onSaved: () => void; +} + +const CONFIG_KEY = "/api/system/config"; + +const fetcher = async (url: string): Promise => { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Config request failed (${response.status}).`); + return (await response.json()) as SystemConfig; +}; + +type TabId = "general" | "terms" | "security" | "backup"; +const TABS: Array<{ id: TabId; label: string }> = [ + { id: "general", label: "General" }, + { id: "terms", label: "Login terms" }, + { id: "security", label: "Security" }, + { id: "backup", label: "Data backup" }, +]; + +export function SystemConfigDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { data, mutate } = useSWR(open ? CONFIG_KEY : null, fetcher, { + revalidateOnFocus: false, + }); + const [tab, setTab] = useState("general"); + const [draft, setDraftState] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (data) setDraftState(data); + }, [data]); + + const setDraft = (updater: (config: SystemConfig) => SystemConfig) => + setDraftState((current) => (current ? updater(current) : current)); + + const dirty = Boolean(draft && data && JSON.stringify(draft) !== JSON.stringify(data)); + + const close = (next: boolean) => { + if (!next && dirty && !window.confirm("Discard unsaved changes?")) return; + onOpenChange(next); + }; + + const onSaved = () => { + void mutate(); + void globalMutate(PUBLIC_SYSTEM_CONFIG_KEY); + }; + + const save = async () => { + if (!draft) return; + setSaving(true); + try { + const response = await fetch(CONFIG_KEY, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + }); + const body = (await response.json().catch(() => null)) as + | { message?: string } + | null; + if (!response.ok) { + throw new Error(body?.message || `Save failed (${response.status}).`); + } + onSaved(); + } catch (error) { + errorToast("system-config.save", error instanceof Error ? error.message : "Save failed."); + } finally { + setSaving(false); + } + }; + + return ( + + + + System config + + System-wide parameters. Admin only; unset fields fall back to + environment defaults. + + +
+ {TABS.map(({ id, label }) => ( + + ))} +
+
+ {!draft ? ( +

Loading…

+ ) : ( + <> + {tab === "general" && ( + + )} + {tab === "terms" && ( + + )} + {tab === "security" && ( + + )} + {tab === "backup" && ( + + )} + + )} +
+ + + + +
+
+ ); +} +``` + +- [ ] **Step 3: Create GeneralTab** + +`src/app/components/system-config/GeneralTab.tsx`: + +```tsx +"use client"; + +import { useRef, useState } from "react"; +import { mutate as globalMutate } from "swr"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { errorToast } from "@/lib/errorReporter"; +import { + PUBLIC_SYSTEM_CONFIG_KEY, + logoSrc, + usePublicSystemConfig, +} from "@/app/hooks/usePublicSystemConfig"; +import type { TabProps } from "./SystemConfigDialog"; + +function BrandingUpload({ + kind, + label, + currentSrc, +}: { + kind: "logo" | "favicon"; + label: string; + currentSrc: string; +}) { + const inputRef = useRef(null); + const [preview, setPreview] = useState(null); + const [busy, setBusy] = useState(false); + + const call = async (init: RequestInit, fallback: string) => { + setBusy(true); + try { + const response = await fetch("/api/system/branding", init); + const body = (await response.json().catch(() => null)) as + | { message?: string } + | null; + if (!response.ok) throw new Error(body?.message || fallback); + setPreview(null); + if (inputRef.current) inputRef.current.value = ""; + void globalMutate(PUBLIC_SYSTEM_CONFIG_KEY); + void globalMutate("/api/system/config"); + } catch (error) { + errorToast(`branding.${kind}`, error instanceof Error ? error.message : fallback); + } finally { + setBusy(false); + } + }; + + const upload = () => { + const file = inputRef.current?.files?.[0]; + if (!file) return; + const form = new FormData(); + form.set("kind", kind); + form.set("file", file); + void call({ method: "POST", body: form }, "Upload failed."); + }; + + const restore = () => + void call({ method: "DELETE" }, "Restore failed."); + + return ( +
+ {`${label} +
+ + { + const file = event.target.files?.[0]; + setPreview(file ? URL.createObjectURL(file) : null); + }} + /> +
+ + +
+ ); +} + +export function GeneralTab({ draft, setDraft }: TabProps) { + const publicConfig = usePublicSystemConfig(); + return ( +
+
+ + + setDraft((config) => ({ + ...config, + branding: { ...config.branding, wordmark: event.target.value }, + })) + } + /> +
+ + 0 + ? `/api/system/branding/asset/favicon?v=${publicConfig.faviconVersion}` + : "/favicon.ico" + } + /> +

+ PNG, JPEG or SVG, up to 512KB. The favicon also accepts ICO. Changes + apply on save for the wordmark; uploads apply immediately. +

+
+ ); +} +``` + +Note: the DELETE call above goes to `/api/system/branding` without `?kind=` — fix during implementation: `fetch(\`/api/system/branding?kind=${kind}\`, { method: "DELETE" })`. Update `call` to accept the URL: `call(url, init, fallback)` and pass `kind` in the DELETE URL. + +- [ ] **Step 4: Create LoginTermsTab** + +`src/app/components/system-config/LoginTermsTab.tsx`: + +```tsx +"use client"; + +import { Label } from "@/components/ui/label"; +import { MarkdownContent } from "@/app/components/MarkdownContent"; +import type { TabProps } from "./SystemConfigDialog"; + +export function LoginTermsTab({ draft, setDraft }: TabProps) { + return ( +
+ +
+ +