diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 792a568f..1e16a40a 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -26,7 +26,6 @@ export default function APIPageClient({ machineId, isAdmin }) { const [confirmState, setConfirmState] = useState(null); const [requireApiKey, setRequireApiKey] = useState(false); - const [requireLogin, setRequireLogin] = useState(true); const [hasPassword, setHasPassword] = useState(true); const [tunnelDashboardAccess, setTunnelDashboardAccess] = useState(false); @@ -86,11 +85,9 @@ export default function APIPageClient({ machineId, isAdmin }) { const { copied, copy } = useCopyToClipboard(); - // Security gate: block remote exposure while dashboard uses default password or login is off. - const isLoginUnsafe = !requireLogin || !hasPassword; - const unsafeReason = !requireLogin - ? "Enable \"Require login\" and set a custom password before activating the tunnel." - : "Change the default dashboard password before activating the tunnel."; + // Security gate: block remote exposure while dashboard uses the default password. + const isLoginUnsafe = !hasPassword; + const unsafeReason = "Change the default dashboard password before activating the tunnel."; // Auto-scroll install log useEffect(() => { @@ -201,7 +198,6 @@ export default function APIPageClient({ machineId, isAdmin }) { if (settingsRes.ok) { const data = await settingsRes.json(); setRequireApiKey(data.requireApiKey || false); - setRequireLogin(data.requireLogin !== false); setHasPassword(data.hasPassword || false); setTunnelDashboardAccess(data.tunnelDashboardAccess || false); } @@ -925,15 +921,11 @@ export default function APIPageClient({ machineId, isAdmin }) { action={{ label: "Enable", href: "#require-api-key" }} /> )} - {(!requireLogin || !hasPassword) && ( + {!hasPassword && ( diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 57e0ea46..e1d770ae 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { Card, Button, Input } from "@/shared/components"; +import { Card, Button, Toggle, Input } from "@/shared/components"; import Modal, { ConfirmModal } from "@/shared/components/Modal"; import LanguageSwitcher from "@/shared/components/LanguageSwitcher"; import { useTheme } from "@/shared/hooks/useTheme"; @@ -23,6 +23,9 @@ function getLocaleFromCookie() { export default function ProfilePage() { const { theme, setTheme } = useTheme(); const user = useUserStore((state) => state.user); + // Only standard user accounts are restricted. Local mode has no dashboard + // session, so it must retain administrator-level settings controls. + const isAdmin = user?.role !== "user"; const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser); const [locale, setLocale] = useState("en"); const [langOpen, setLangOpen] = useState(false); @@ -37,7 +40,18 @@ export default function ProfilePage() { const [dbStatus, setDbStatus] = useState({ type: "", message: "" }); const [dbAuth, setDbAuth] = useState({ open: false, mode: "", password: "" }); const pendingImportRef = useRef(null); + const [oidcForm, setOidcForm] = useState({ authMode: "password", oidcIssuerUrl: "", oidcClientId: "", oidcScopes: "openid profile email", oidcLoginLabel: "Sign in with OIDC" }); + const [oidcClientSecret, setOidcClientSecret] = useState(""); + const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" }); + const [oidcLoading, setOidcLoading] = useState(false); + const [oidcTestLoading, setOidcTestLoading] = useState(false); + const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" }); + const [oidcExpanded, setOidcExpanded] = useState(false); const importFileRef = useRef(null); + const [proxyForm, setProxyForm] = useState({ outboundProxyUrl: "", outboundNoProxy: "" }); + const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" }); + const [proxyLoading, setProxyLoading] = useState(false); + const [proxyTestLoading, setProxyTestLoading] = useState(false); useEffect(() => { if (!user) fetchCurrentUser(); @@ -52,6 +66,8 @@ export default function ProfilePage() { .then((res) => res.json()) .then((data) => { setSettings(data); + setOidcForm({ authMode: data?.authMode || "password", oidcIssuerUrl: data?.oidcIssuerUrl || "", oidcClientId: data?.oidcClientId || "", oidcScopes: data?.oidcScopes || "openid profile email", oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC" }); + setProxyForm({ outboundProxyUrl: data?.outboundProxyUrl || "", outboundNoProxy: data?.outboundNoProxy || "" }); setLoading(false); }) .catch((err) => { @@ -60,6 +76,55 @@ export default function ProfilePage() { }); }, []); + const updateAdminSettings = async (changes) => { + const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to update settings"); + setSettings((previous) => ({ ...previous, ...data })); + return data; + }; + + const saveOidcSettings = async () => { + setOidcLoading(true); + setOidcStatus({ type: "", message: "" }); + try { + const payload = { ...oidcForm, oidcIssuerUrl: oidcForm.oidcIssuerUrl.trim(), oidcClientId: oidcForm.oidcClientId.trim(), oidcScopes: oidcForm.oidcScopes.trim() || "openid profile email", oidcLoginLabel: oidcForm.oidcLoginLabel.trim() || "Sign in with OIDC" }; + if (oidcClientSecret.trim()) payload.oidcClientSecret = oidcClientSecret.trim(); + const data = await updateAdminSettings(payload); + setOidcForm((previous) => ({ ...previous, authMode: data.authMode || previous.authMode })); + setOidcClientSecret(""); + setOidcStatus({ type: "success", message: "OIDC settings saved" }); + } catch (error) { setOidcStatus({ type: "error", message: error.message }); } finally { setOidcLoading(false); } + }; + + const testOidcConnection = async () => { + setOidcTestLoading(true); + setOidcTestStatus({ type: "", message: "" }); + try { + const res = await fetch("/api/auth/oidc/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ issuerUrl: oidcForm.oidcIssuerUrl, clientId: oidcForm.oidcClientId, scopes: oidcForm.oidcScopes }) }); + const data = await res.json(); + if (!res.ok || !data.ok) throw new Error(data.error || "OIDC connection test failed"); + setOidcTestStatus({ type: "success", message: "OIDC connection successful" }); + } catch (error) { setOidcTestStatus({ type: "error", message: error.message }); } finally { setOidcTestLoading(false); } + }; + + const saveProxySettings = async (event) => { + event.preventDefault(); + setProxyLoading(true); + try { await updateAdminSettings(proxyForm); setProxyStatus({ type: "success", message: "Proxy settings applied" }); } + catch (error) { setProxyStatus({ type: "error", message: error.message }); } finally { setProxyLoading(false); } + }; + + const testOutboundProxy = async () => { + setProxyTestLoading(true); + try { + const res = await fetch("/api/settings/proxy-test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ proxyUrl: proxyForm.outboundProxyUrl }) }); + const data = await res.json(); + if (!res.ok || !data.ok) throw new Error(data.error || "Proxy test failed"); + setProxyStatus({ type: "success", message: "Proxy test successful" }); + } catch (error) { setProxyStatus({ type: "error", message: error.message }); } finally { setProxyTestLoading(false); } + }; + const handlePasswordChange = async (e) => { e.preventDefault(); if (passwords.new !== passwords.confirm) { @@ -315,6 +380,51 @@ export default function ProfilePage() { + {isAdmin && ( + <> + + + {oidcExpanded && ( +
+
+ {["password", "oidc", "both"].map((mode) => )} +
+ setOidcForm((previous) => ({ ...previous, oidcIssuerUrl: event.target.value }))} /> + setOidcForm((previous) => ({ ...previous, oidcClientId: event.target.value }))} /> + setOidcClientSecret(event.target.value)} /> + setOidcForm((previous) => ({ ...previous, oidcScopes: event.target.value }))} /> + setOidcForm((previous) => ({ ...previous, oidcLoginLabel: event.target.value }))} /> +
+ {[oidcStatus, oidcTestStatus].filter((status) => status.message).map((status, index) =>

{status.message}

)} +
+ )} +
+ + +
route

Routing Strategy

+
+

Round Robin

Cycle through accounts to distribute load.

updateAdminSettings({ fallbackStrategy: settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin" })} />
+

Combo Round Robin

Cycle through providers in combos.

updateAdminSettings({ comboStrategy: settings.comboStrategy === "round-robin" ? "fallback" : "round-robin" })} />
+
+
+ + +
wifi

Network

+

Outbound Proxy

Proxy OAuth and provider outbound requests.

updateAdminSettings({ outboundProxyEnabled: !settings.outboundProxyEnabled })} />
+ {settings.outboundProxyEnabled === true &&
setProxyForm((previous) => ({ ...previous, outboundProxyUrl: event.target.value }))} /> setProxyForm((previous) => ({ ...previous, outboundNoProxy: event.target.value }))} />
{proxyStatus.message &&

{proxyStatus.message}

}
} +
+ + +
monitoring

Observability

+

Enable Observability

Record request details for the logs view.

updateAdminSettings({ enableObservability: !settings.enableObservability })} />
+
+ + )} + {/* Password */}
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();