feat(webui): admin + public system config API routes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: "" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user