diff --git a/src/app/api/system/config/public/route.ts b/src/app/api/system/config/public/route.ts new file mode 100644 index 0000000..c0cf139 --- /dev/null +++ b/src/app/api/system/config/public/route.ts @@ -0,0 +1,24 @@ +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); + } +} diff --git a/src/app/api/system/config/route.ts b/src/app/api/system/config/route.ts new file mode 100644 index 0000000..aaf1ace --- /dev/null +++ b/src/app/api/system/config/route.ts @@ -0,0 +1,49 @@ +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); + } +} diff --git a/src/app/api/system/config/routes.test.ts b/src/app/api/system/config/routes.test.ts new file mode 100644 index 0000000..94282da --- /dev/null +++ b/src/app/api/system/config/routes.test.ts @@ -0,0 +1,91 @@ +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: "" }, + }); + }); +});