diff --git a/src/app/api/auth/oidc/test/route.js b/src/app/api/auth/oidc/test/route.js
index a56a8bce..27b35443 100644
--- a/src/app/api/auth/oidc/test/route.js
+++ b/src/app/api/auth/oidc/test/route.js
@@ -1,4 +1,30 @@
import { NextResponse } from "next/server";
-export async function POST() {
- return NextResponse.json({ error: "OIDC settings are not available" }, { status: 403 });
+import { getSettings } from "@/lib/localDb";
+import { fetchOidcDiscovery, getPublicOrigin, probeOidcClientSecret } from "@/lib/auth/oidc";
+import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
+
+export async function POST(request) {
+ try {
+ const user = await requireUsageDashboardUser();
+ if (user.role !== "admin") throw new Error("Forbidden");
+ const body = await request.json().catch(() => ({}));
+ const settings = await getSettings();
+ const issuerUrl = String(body.issuerUrl || settings.oidcIssuerUrl || "").trim();
+ const clientId = String(body.clientId || settings.oidcClientId || "").trim();
+ const scopes = String(body.scopes || settings.oidcScopes || "openid profile email").trim() || "openid profile email";
+ const clientSecret = String(body.clientSecret || settings.oidcClientSecret || "").trim();
+ if (!issuerUrl || !clientId) return NextResponse.json({ error: "Issuer URL and client ID are required" }, { status: 400 });
+ const discovery = await fetchOidcDiscovery(issuerUrl);
+ const redirectUri = `${getPublicOrigin(request)}/api/auth/oidc/callback`;
+ const secretProbe = await probeOidcClientSecret({
+ tokenEndpoint: discovery.token_endpoint,
+ clientId,
+ clientSecret,
+ redirectUri,
+ });
+ return NextResponse.json({ ok: secretProbe.valid !== false, discoveryOk: true, clientSecretTested: secretProbe.tested, clientSecretValid: secretProbe.valid, issuerUrl, clientId, scopes, message: secretProbe.message });
+ } catch (error) {
+ const status = error.message === "Unauthorized" ? 401 : error.message === "Forbidden" ? 403 : 500;
+ return NextResponse.json({ error: error.message || "OIDC test failed" }, { status });
+ }
}
diff --git a/src/app/api/auth/status/route.js b/src/app/api/auth/status/route.js
index 350b487f..19593c82 100644
--- a/src/app/api/auth/status/route.js
+++ b/src/app/api/auth/status/route.js
@@ -9,7 +9,6 @@ export async function GET() {
const settings = await getSettings();
const cookieStore = await cookies();
const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
- const requireLogin = settings.requireLogin !== false;
const authMode = settings.authMode || "password";
const oidcName = String(session?.oidcName || "").trim();
const oidcEmail = String(session?.oidcEmail || "").trim();
@@ -20,7 +19,6 @@ export async function GET() {
const loginMethod = session?.oidc ? "OIDC" : "Password";
return NextResponse.json({
- requireLogin,
authMode,
oidcConfigured: isOidcConfigured(settings),
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
@@ -36,7 +34,6 @@ export async function GET() {
});
} catch {
return NextResponse.json({
- requireLogin: true,
authMode: "password",
oidcConfigured: false,
oidcLoginLabel: "Sign in with OIDC",
diff --git a/src/app/api/combos/[id]/strategy/route.js b/src/app/api/combos/[id]/strategy/route.js
index 9d820bc8..a61da44b 100644
--- a/src/app/api/combos/[id]/strategy/route.js
+++ b/src/app/api/combos/[id]/strategy/route.js
@@ -1,6 +1,31 @@
import { NextResponse } from "next/server";
+import { getComboById, updateComboStrategy } from "@/lib/localDb";
+import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
+import { resetComboRotation } from "open-sse/services/combo.js";
+
export const dynamic = "force-dynamic";
-export async function PATCH() {
- return NextResponse.json({ error: "Routing strategy settings are not available" }, { status: 403 });
+const STRATEGIES = new Set(["fallback", "round-robin", "fusion"]);
+
+export async function PATCH(request, { params }) {
+ try {
+ const user = await requireUsageDashboardUser();
+ if (user.role !== "admin") throw new Error("Forbidden");
+ const { id } = await params;
+ const combo = await getComboById(id);
+ if (!combo) return NextResponse.json({ error: "Combo not found" }, { status: 404 });
+ const { strategy } = await request.json();
+ if (!strategy || typeof strategy !== "object" || Array.isArray(strategy)) return NextResponse.json({ error: "Strategy must be an object" }, { status: 400 });
+ if (strategy.fallbackStrategy !== undefined && !STRATEGIES.has(strategy.fallbackStrategy)) return NextResponse.json({ error: "Invalid combo strategy" }, { status: 400 });
+ if (strategy.judgeModel !== undefined && (typeof strategy.judgeModel !== "string" || strategy.judgeModel.length > 256)) return NextResponse.json({ error: "Invalid combo strategy" }, { status: 400 });
+ const normalizedStrategy = {};
+ if (strategy.fallbackStrategy !== undefined) normalizedStrategy.fallbackStrategy = strategy.fallbackStrategy;
+ if (strategy.judgeModel !== undefined) normalizedStrategy.judgeModel = strategy.judgeModel.trim();
+ const settings = await updateComboStrategy(combo.id, normalizedStrategy);
+ resetComboRotation(combo.id);
+ return NextResponse.json({ strategy: settings.comboStrategies[combo.id] || {} });
+ } catch (error) {
+ const status = error.message === "Unauthorized" ? 401 : error.message === "Forbidden" ? 403 : 500;
+ return NextResponse.json({ error: error.message || "Failed to update combo strategy" }, { status });
+ }
}
\ No newline at end of file
diff --git a/src/app/api/settings/proxy-test/route.js b/src/app/api/settings/proxy-test/route.js
index a7aaa7de..780d0a34 100644
--- a/src/app/api/settings/proxy-test/route.js
+++ b/src/app/api/settings/proxy-test/route.js
@@ -1,4 +1,17 @@
import { NextResponse } from "next/server";
-export async function POST() {
- return NextResponse.json({ error: "Network settings are not available" }, { status: 403 });
+import { testProxyUrl } from "@/lib/network/proxyTest";
+import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
+
+export async function POST(request) {
+ try {
+ const user = await requireUsageDashboardUser();
+ if (user.role !== "admin") throw new Error("Forbidden");
+ const body = await request.json();
+ const result = await testProxyUrl({ proxyUrl: body?.proxyUrl, testUrl: body?.testUrl, timeoutMs: body?.timeoutMs });
+ if (result?.ok) return NextResponse.json(result);
+ return NextResponse.json({ ok: false, error: result?.error || "Proxy test failed" }, { status: result?.status || 500 });
+ } catch (error) {
+ const status = error.message === "Unauthorized" ? 401 : error.message === "Forbidden" ? 403 : 500;
+ return NextResponse.json({ ok: false, error: error.message || "Proxy test failed" }, { status });
+ }
}
diff --git a/src/app/api/settings/require-login/route.js b/src/app/api/settings/require-login/route.js
deleted file mode 100644
index 2e3ec692..00000000
--- a/src/app/api/settings/require-login/route.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import { NextResponse } from "next/server";
-export async function GET() {
- return NextResponse.json({ error: "Login settings are not available" }, { status: 403 });
-}
diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js
index 6c774963..0c07bd83 100644
--- a/src/app/api/settings/route.js
+++ b/src/app/api/settings/route.js
@@ -16,17 +16,13 @@ const SETTINGS_RESPONSE_HEADERS = {
// Secrets must never be mass-assigned from request body (CWE-915)
const PROTECTED_SETTING_KEYS = ["password", "mitmSudoEncrypted"];
-// These capabilities are intentionally not configurable through the dashboard.
-// Keep the server-side policy here so callers cannot bypass the hidden UI.
-const RESTRICTED_SETTING_KEYS = [
- "requireLogin",
+const USER_RESTRICTED_SETTING_KEYS = [
"authMode",
"oidcIssuerUrl",
"oidcClientId",
"oidcClientSecret",
"oidcScopes",
"oidcLoginLabel",
- "oidcConfigured",
"fallbackStrategy",
"stickyRoundRobinLimit",
"comboStrategy",
@@ -60,9 +56,9 @@ export async function GET() {
try {
const settings = await getSettings();
const { password, oidcClientSecret, ...safeSettings } = settings;
- for (const key of RESTRICTED_SETTING_KEYS) delete safeSettings[key];
const user = await requireUsageDashboardUser();
if (user.role !== "admin") {
+ for (const key of USER_RESTRICTED_SETTING_KEYS) delete safeSettings[key];
const ownedComboIds = new Set((await getCombos(user.id)).map((combo) => combo.id));
safeSettings.comboStrategies = Object.fromEntries(
Object.entries(safeSettings.comboStrategies || {}).filter(([comboId]) => ownedComboIds.has(comboId))
@@ -88,8 +84,16 @@ export async function PATCH(request) {
try {
const body = await request.json();
- if (RESTRICTED_SETTING_KEYS.some((key) => Object.prototype.hasOwnProperty.call(body, key))) {
- return NextResponse.json({ error: "This setting is not available" }, { status: 403 });
+ if (USER_RESTRICTED_SETTING_KEYS.some((key) => Object.prototype.hasOwnProperty.call(body, key))) {
+ let user;
+ try {
+ user = await requireUsageDashboardUser();
+ } catch {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (user.role !== "admin") {
+ return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
+ }
}
if (
@@ -168,7 +172,11 @@ export async function PATCH(request) {
}
const { password, oidcClientSecret, ...safeSettings } = settings;
- for (const key of RESTRICTED_SETTING_KEYS) delete safeSettings[key];
+ const user = await requireUsageDashboardUser();
+ if (user.role !== "admin") {
+ for (const key of USER_RESTRICTED_SETTING_KEYS) delete safeSettings[key];
+ }
+ safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret);
return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS });
} catch (error) {
console.log("Error updating settings:", error);
diff --git a/src/app/login/page.js b/src/app/login/page.js
index ed97da06..7f931cb3 100644
--- a/src/app/login/page.js
+++ b/src/app/login/page.js
@@ -38,10 +38,6 @@ export default function LoginPage() {
if (res.ok) {
const data = await res.json();
- if (data.requireLogin === false) {
- window.location.assign("/dashboard");
- return;
- }
setHasPassword(!!data.hasPassword);
setAuthMode(data.authMode || "password");
setOidcConfigured(data.oidcConfigured === true);
diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js
index 0016828c..1a2a39dc 100644
--- a/src/dashboardGuard.js
+++ b/src/dashboardGuard.js
@@ -33,7 +33,7 @@ const PUBLIC_API_PATHS = [
// Public top-level prefixes (LLM API endpoints with their own API key auth).
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex"];
-// Always require JWT token regardless of requireLogin setting
+// Always require a JWT token.
const ALWAYS_PROTECTED = [
"/api/shutdown",
"/api/settings/database",
@@ -69,7 +69,7 @@ const ADMIN_ONLY_DASHBOARD_PATHS = [
"/dashboard/console-log",
];
-// Require auth, but allow through if requireLogin is disabled
+// Require authenticated access.
const PROTECTED_API_PATHS = [
"/api/settings",
"/api/keys",
@@ -164,7 +164,7 @@ async function canAccessPublicLlmApi(request) {
async function canAccessLocalOnlyRoute(request) {
if (await hasValidCliToken(request)) return true;
- // Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + auth (JWT or requireLogin=false)
+ // Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + JWT auth.
if (isLocalRequest(request) && await isAuthenticated(request)) return true;
return false;
}
@@ -202,10 +202,7 @@ async function loadSettings() {
}
async function isAuthenticated(request) {
- if (await hasValidToken(request)) return true;
- const settings = await loadSettings();
- if (settings && settings.requireLogin === false) return true;
- return false;
+ return hasValidToken(request);
}
function isPublicApi(pathname) {
@@ -277,13 +274,11 @@ export async function proxy(request) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
- let requireLogin = true;
let tunnelDashboardAccess = true;
try {
const settings = await loadSettings();
if (settings) {
- requireLogin = settings.requireLogin !== false;
tunnelDashboardAccess = settings.tunnelDashboardAccess === true;
// Block tunnel/tailscale access if disabled (redirect to login)
@@ -297,12 +292,9 @@ export async function proxy(request) {
}
}
} catch {
- // On error, keep defaults (require login, block tunnel)
+ // On error, keep the secure default and block tunnel dashboard access.
}
- // If login not required, allow through
- if (!requireLogin) return NextResponse.next();
-
// Verify JWT token
const token = request.cookies.get("auth_token")?.value;
if (token) {
diff --git a/src/lib/auth/currentUser.js b/src/lib/auth/currentUser.js
index a25f79ce..5a43d1eb 100644
--- a/src/lib/auth/currentUser.js
+++ b/src/lib/auth/currentUser.js
@@ -1,6 +1,6 @@
import { cookies } from "next/headers";
import { getDashboardAuthSession } from "./dashboardSession.js";
-import { getSettings, getUserById, verifyUserPassword } from "@/lib/db";
+import { getUserById, verifyUserPassword } from "@/lib/db";
export async function getCurrentDashboardUser() {
const cookieStore = await cookies();
@@ -23,16 +23,12 @@ export async function requireCurrentDashboardUser() {
}
/**
- * Resolve the user for dashboard data that is also available in the explicit
- * single-user (`requireLogin=false`) deployment mode. That mode has no account
- * boundary, so it intentionally uses the system-wide administrator scope.
+ * Resolve the currently authenticated dashboard user.
*/
export async function requireUsageDashboardUser() {
const user = await getCurrentDashboardUser();
if (user) return user;
- const settings = await getSettings();
- if (settings?.requireLogin === false) return { id: null, username: "local", role: "admin" };
throw new Error("Unauthorized");
}
diff --git a/src/lib/db/repos/requestDetailsRepo.js b/src/lib/db/repos/requestDetailsRepo.js
index 8a5cef5f..41af44b4 100644
--- a/src/lib/db/repos/requestDetailsRepo.js
+++ b/src/lib/db/repos/requestDetailsRepo.js
@@ -6,21 +6,16 @@ const DEFAULT_MAX_RECORDS = 200;
const DEFAULT_BATCH_SIZE = 20;
const DEFAULT_FLUSH_INTERVAL_MS = 5000;
const DEFAULT_MAX_JSON_SIZE = 5 * 1024;
-const CONFIG_CACHE_TTL_MS = 5000;
-
-let cachedConfig = null;
-let cachedConfigTs = 0;
async function getObservabilityConfig() {
- if (cachedConfig && (Date.now() - cachedConfigTs) < CONFIG_CACHE_TTL_MS) return cachedConfig;
try {
const { getSettings } = await import("./settingsRepo.js");
const settings = await getSettings();
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
- const enabled = typeof settings.enableObservability2 === "boolean"
- ? settings.enableObservability2
+ const enabled = typeof settings.enableObservability === "boolean"
+ ? settings.enableObservability
: envEnabled;
- cachedConfig = {
+ return {
enabled,
maxRecords: settings.observabilityMaxRecords || parseInt(process.env.OBSERVABILITY_MAX_RECORDS || String(DEFAULT_MAX_RECORDS), 10),
batchSize: settings.observabilityBatchSize || parseInt(process.env.OBSERVABILITY_BATCH_SIZE || String(DEFAULT_BATCH_SIZE), 10),
@@ -28,7 +23,7 @@ async function getObservabilityConfig() {
maxJsonSize: (settings.observabilityMaxJsonSize || parseInt(process.env.OBSERVABILITY_MAX_JSON_SIZE || "5", 10)) * 1024,
};
} catch {
- cachedConfig = {
+ return {
enabled: false,
maxRecords: DEFAULT_MAX_RECORDS,
batchSize: DEFAULT_BATCH_SIZE,
@@ -36,8 +31,6 @@ async function getObservabilityConfig() {
maxJsonSize: DEFAULT_MAX_JSON_SIZE,
};
}
- cachedConfigTs = Date.now();
- return cachedConfig;
}
let writeBuffer = [];
diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js
index 72cec376..62be033d 100644
--- a/src/lib/db/repos/settingsRepo.js
+++ b/src/lib/db/repos/settingsRepo.js
@@ -16,7 +16,6 @@ const DEFAULT_SETTINGS = {
comboStrategy: "fallback",
comboStickyRoundRobinLimit: 1,
comboStrategies: {},
- requireLogin: true,
tunnelDashboardAccess: true,
authMode: "password",
oidcIssuerUrl: "",
diff --git a/src/lib/db/repos/usageAccessScope.js b/src/lib/db/repos/usageAccessScope.js
index c077aac0..36c7cc94 100644
--- a/src/lib/db/repos/usageAccessScope.js
+++ b/src/lib/db/repos/usageAccessScope.js
@@ -13,6 +13,13 @@ export async function getUsageAccessScope(user) {
return { isAdmin: true, userId: null, connectionIds: [], apiKeys: [] };
}
+ // Repository calls without a user are trusted server-side operations (for
+ // example logging, cleanup, and DB-level callers). HTTP routes must resolve
+ // and pass the dashboard user explicitly before querying these repositories.
+ if (!user) {
+ return { isAdmin: true, userId: null, connectionIds: [], apiKeys: [] };
+ }
+
if (!user?.id) {
return { isAdmin: false, userId: null, connectionIds: [], apiKeys: [] };
}
diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js
index 3fbcbb61..aa9b5e92 100644
--- a/tests/unit/dashboard-guard.test.js
+++ b/tests/unit/dashboard-guard.test.js
@@ -52,7 +52,7 @@ function request(pathname, headers = {}, authToken) {
describe("dashboard guard public LLM API access", () => {
beforeEach(() => {
vi.clearAllMocks();
- mocks.getSettings.mockResolvedValue({ requireLogin: true });
+ mocks.getSettings.mockResolvedValue({});
mocks.getUserById.mockResolvedValue(null);
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -197,7 +197,7 @@ describe("dashboard guard public LLM API access", () => {
describe("dashboard guard local-only access", () => {
beforeEach(() => {
vi.clearAllMocks();
- mocks.getSettings.mockResolvedValue({ requireLogin: true });
+ mocks.getSettings.mockResolvedValue({});
mocks.getUserById.mockResolvedValue(null);
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -214,7 +214,7 @@ describe("dashboard guard local-only access", () => {
expect(response.body.error).toBe("Local only: CLI token required");
});
- it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
+ it("rejects local-only route on loopback without a JWT", async () => {
const response = await proxy(request("/api/mcp/filesystem/sse", {
host: "localhost:20128",
origin: "http://localhost:20128",
@@ -224,9 +224,7 @@ describe("dashboard guard local-only access", () => {
expect(response.body.error).toBe("Local only: CLI token required");
});
- it("requires an administrator for CLI Tools even when dashboard login is disabled", async () => {
- mocks.getSettings.mockResolvedValue({ requireLogin: false });
-
+ it("requires an administrator for CLI Tools", async () => {
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://localhost:20128",
@@ -236,9 +234,7 @@ describe("dashboard guard local-only access", () => {
expect(response.body.error).toBe("Administrator access required");
});
- it("rejects local-only route from tunnel host even when requireLogin=false", async () => {
- mocks.getSettings.mockResolvedValue({ requireLogin: false });
-
+ it("rejects local-only route from a tunnel host", async () => {
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
host: "router.example.com",
}));
@@ -247,8 +243,6 @@ describe("dashboard guard local-only access", () => {
});
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
- mocks.getSettings.mockResolvedValue({ requireLogin: false });
-
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://evil.example.com",
@@ -270,7 +264,7 @@ describe("dashboard guard local-only access", () => {
describe("dashboard guard CLI Tools administration access", () => {
beforeEach(() => {
vi.clearAllMocks();
- mocks.getSettings.mockResolvedValue({ requireLogin: true });
+ mocks.getSettings.mockResolvedValue({});
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "user" });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -315,7 +309,7 @@ describe("dashboard guard CLI Tools administration access", () => {
describe("dashboard guard token saver administration access", () => {
beforeEach(() => {
vi.clearAllMocks();
- mocks.getSettings.mockResolvedValue({ requireLogin: true });
+ mocks.getSettings.mockResolvedValue({});
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "user" });
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
mocks.getDashboardAuthSession.mockResolvedValue({ userId: "user-1" });
diff --git a/tests/unit/db-sqlite-vs-lowdb.test.js b/tests/unit/db-sqlite-vs-lowdb.test.js
index fcb65928..9153fe83 100644
--- a/tests/unit/db-sqlite-vs-lowdb.test.js
+++ b/tests/unit/db-sqlite-vs-lowdb.test.js
@@ -28,12 +28,10 @@ describe("DB SQLite layer — public API parity", () => {
const s = await sqliteDb.getSettings();
expect(s).toBeDefined();
expect(s.cloudEnabled).toBe(false);
- expect(s.requireLogin).toBe(true);
const updated = await sqliteDb.updateSettings({ cloudEnabled: true, customField: "x" });
expect(updated.cloudEnabled).toBe(true);
expect(updated.customField).toBe("x");
- expect(updated.requireLogin).toBe(true); // default preserved
const re = await sqliteDb.getSettings();
expect(re.cloudEnabled).toBe(true);
diff --git a/tests/unit/request-details-tab.test.js b/tests/unit/request-details-tab.test.js
index 6b056503..c2ef1fc1 100644
--- a/tests/unit/request-details-tab.test.js
+++ b/tests/unit/request-details-tab.test.js
@@ -6,6 +6,10 @@ import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
+vi.mock("@/lib/auth/currentUser", () => ({
+ requireUsageDashboardUser: vi.fn(async () => ({ id: "test-admin", role: "admin" })),
+}));
+
const originalDataDir = process.env.DATA_DIR;
let tempDir;
let db;
@@ -22,7 +26,7 @@ beforeAll(async () => {
vi.resetModules();
db = await import("@/lib/db/index.js");
await db.initDb();
- await db.updateSettings({ enableObservability2: true, observabilityBatchSize: 1 });
+ await db.updateSettings({ enableObservability: true, observabilityBatchSize: 1 });
const { getAdapter } = await import("@/lib/db/driver.js");
adapter = await getAdapter();