From 78c98afb520c922e8551db47462ba34bf6bc8369 Mon Sep 17 00:00:00 2001 From: m4 Date: Wed, 12 Aug 2026 22:36:11 +0800 Subject: [PATCH] feat: public setup-status probe, bootstrap admin no longer throws on empty store --- src/app/api/auth/setup-status/route.test.ts | 58 +++++++++++++++++++++ src/app/api/auth/setup-status/route.ts | 15 ++++++ src/lib/server/userStore.ts | 10 ++-- 3 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 src/app/api/auth/setup-status/route.test.ts create mode 100644 src/app/api/auth/setup-status/route.ts diff --git a/src/app/api/auth/setup-status/route.test.ts b/src/app/api/auth/setup-status/route.test.ts new file mode 100644 index 0000000..8ff00ab --- /dev/null +++ b/src/app/api/auth/setup-status/route.test.ts @@ -0,0 +1,58 @@ +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-setup-status-")); +const ORIGINAL_ENV = { + EVOSCIENTIST_DATA_DIR: process.env.EVOSCIENTIST_DATA_DIR, + WEBUI_AUTH_ENABLED: process.env.WEBUI_AUTH_ENABLED, + WEBUI_AUTH_SECRET: process.env.WEBUI_AUTH_SECRET, + WEBUI_AUTH_USERNAME: process.env.WEBUI_AUTH_USERNAME, + WEBUI_AUTH_PASSWORD: process.env.WEBUI_AUTH_PASSWORD, +}; + +process.env.EVOSCIENTIST_DATA_DIR = dataDir; +process.env.WEBUI_AUTH_ENABLED = "true"; +process.env.WEBUI_AUTH_SECRET = "test-secret-with-at-least-32-characters"; +delete process.env.WEBUI_AUTH_USERNAME; +delete process.env.WEBUI_AUTH_PASSWORD; + +const { GET } = await import("./route"); +const { closeUserStoreForTests, countUsers } = await import( + "@/lib/server/userStore" +); +const { verifyCredentials } = await import("@/lib/server/auth"); + +afterAll(() => { + closeUserStoreForTests(); + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +describe("GET /api/auth/setup-status", () => { + it("reports needsSetup when the store is empty and no env seeding is set", async () => { + const res = await GET(); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await res.json()).toEqual({ needsSetup: true }); + }); + + it("no longer throws on the auth path with an empty store and no env", () => { + expect(verifyCredentials("nobody", "wrong-password")).toBeNull(); + }); + + it("seeds from env on the first probe and then reports needsSetup false", async () => { + process.env.WEBUI_AUTH_USERNAME = "env-admin"; + process.env.WEBUI_AUTH_PASSWORD = "env-password-123"; + const res = await GET(); + expect(await res.json()).toEqual({ needsSetup: false }); + expect(countUsers()).toBe(1); + expect(verifyCredentials("env-admin", "env-password-123")).not.toBeNull(); + }); +}); diff --git a/src/app/api/auth/setup-status/route.ts b/src/app/api/auth/setup-status/route.ts new file mode 100644 index 0000000..8b23a09 --- /dev/null +++ b/src/app/api/auth/setup-status/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { countUsers, ensureBootstrapAdmin } from "@/lib/server/userStore"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE = { "Cache-Control": "no-store" }; + +export async function GET() { + ensureBootstrapAdmin(); + return NextResponse.json( + { needsSetup: countUsers() === 0 }, + { headers: NO_STORE } + ); +} diff --git a/src/lib/server/userStore.ts b/src/lib/server/userStore.ts index 5245dc5..6a79745 100644 --- a/src/lib/server/userStore.ts +++ b/src/lib/server/userStore.ts @@ -210,17 +210,13 @@ export function verifyUserPassword( /** * Seed the first admin from the deployment environment when the store is - * empty (design doc 7.2: the first admin comes from env or an init command; - * later users are managed by admins through the API). + * empty. Without env seeding the store stays empty and the first-start + * setup flow (/api/auth/setup-status, /api/auth/setup) creates the admin. */ 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) { - throw new UserStoreError( - "No WebUI users exist yet. Set WEBUI_AUTH_USERNAME and WEBUI_AUTH_PASSWORD to create the first admin." - ); - } + if (!username || !password) return; createUser(username, password, "admin"); }