From 46e5aa5522c3db06d5358fa2d1c934e608cd0379 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 13 May 2026 00:59:25 -0300 Subject: [PATCH] refactor(api): consolidate auth routing and provider config handling Centralize optional API key capability checks so dashboard forms and provider schemas share the same rules, including keyless Pollinations support and cloud-agent batch testing mode. Also align auto-combo config on `routerStrategy`, keep legacy combo reads compatible, preserve MCP routes as management APIs, narrow `require-login` public access to readonly/bootstrap flows, and make cloud-agent CORS reflect the request origin for credentialed requests. --- open-sse/mcp-server/tools/advancedTools.ts | 4 +- open-sse/services/autoCombo/virtualFactory.ts | 28 ++++------- open-sse/services/combo.ts | 12 +++-- .../dashboard/providers/[id]/page.tsx | 13 ++--- src/app/api/providers/test-batch/route.ts | 6 ++- src/app/api/v1/agents/tasks/[id]/route.ts | 48 +++++++++---------- src/app/api/v1/agents/tasks/route.ts | 31 ++++++------ src/lib/cloudAgent/api.ts | 12 +++-- src/server/authz/classify.ts | 10 +--- src/shared/constants/providers.ts | 2 + src/shared/constants/publicApiRoutes.ts | 6 ++- src/shared/utils/apiAuth.ts | 11 ++++- src/shared/validation/schemas.ts | 8 ++-- tests/unit/authz/classify.test.ts | 1 + tests/unit/authz/pipeline.test.ts | 4 +- tests/unit/combo-routing-engine.test.ts | 6 +-- tests/unit/provider-route-schemas.test.ts | 27 +++++++++++ tests/unit/public-api-routes.test.ts | 2 +- tests/unit/virtual-auto-combo.test.ts | 2 +- 19 files changed, 126 insertions(+), 107 deletions(-) create mode 100644 tests/unit/provider-route-schemas.test.ts diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index 723a061925..86dc3930ed 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -428,7 +428,7 @@ export async function handleSetRoutingStrategy(args: { ...currentConfig, auto: { ...currentAutoConfig, - routingStrategy: args.autoRoutingStrategy, + routerStrategy: args.autoRoutingStrategy, }, }; } @@ -447,7 +447,7 @@ export async function handleSetRoutingStrategy(args: { const updatedConfig = toRecord(updatedCombo.config); const resolvedAutoStrategy = - toString(toRecord(updatedConfig.auto).routingStrategy) || + toString(toRecord(updatedConfig.auto).routerStrategy) || (normalizedStrategy === "auto" ? (args.autoRoutingStrategy ?? "rules") : ""); const result = { diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 0392ddc64f..820f57868c 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -14,8 +14,6 @@ interface VirtualFactoryConn extends ConnectionFields { defaultModel?: string; expiresAt?: number | string | null; tokenExpiresAt?: number | string | null; - oauthToken?: string | null; - oauthExpiresAt?: number | string | null; // legacy timestamp or ISO string } export interface VirtualAutoComboCandidate { @@ -41,14 +39,14 @@ type VirtualAutoCombo = AutoComboConfig & { candidatePool: string[]; weights: ScoringWeights; explorationRate: number; - routingStrategy: string; + routerStrategy: string; }; config: { auto: { candidatePool: string[]; weights: ScoringWeights; explorationRate: number; - routingStrategy: string; + routerStrategy: string; }; }; }; @@ -64,7 +62,7 @@ function toExpiryMs(value: unknown): number | null { : Number.NaN; if (Number.isFinite(parsed) && parsed > 0) { - return parsed < 1_000_000_000_000 ? parsed * 1000 : parsed; + return parsed < 10_000_000_000 ? parsed * 1000 : parsed; } if (typeof value === "string") { @@ -76,19 +74,9 @@ function toExpiryMs(value: unknown): number | null { } function hasUsableOAuthToken(conn: VirtualFactoryConn): boolean { - const token = - typeof conn.accessToken === "string" && conn.accessToken.trim().length > 0 - ? conn.accessToken - : typeof conn.oauthToken === "string" && conn.oauthToken.trim().length > 0 - ? conn.oauthToken - : null; + if (typeof conn.accessToken !== "string" || conn.accessToken.trim().length === 0) return false; - if (!token) return false; - - const expiryMs = - toExpiryMs(conn.tokenExpiresAt) ?? - toExpiryMs(conn.expiresAt) ?? - toExpiryMs(conn.oauthExpiresAt); + const expiryMs = toExpiryMs(conn.tokenExpiresAt) ?? toExpiryMs(conn.expiresAt); return expiryMs === null || expiryMs > Date.now(); } @@ -114,7 +102,7 @@ export async function createVirtualAutoCombo( candidatePool: emptyPool, weights: { ...DEFAULT_WEIGHTS }, explorationRate: 0.05, - routingStrategy: "lkgp", + routerStrategy: "lkgp", }; return { id: `virtual-auto-${variant || "default"}`, @@ -125,7 +113,7 @@ export async function createVirtualAutoCombo( candidatePool: emptyPool, weights: autoConfig.weights, explorationRate: autoConfig.explorationRate, - routerStrategy: autoConfig.routingStrategy, + routerStrategy: autoConfig.routerStrategy, autoConfig, config: { auto: autoConfig }, }; @@ -196,7 +184,7 @@ export async function createVirtualAutoCombo( candidatePool: providerPool, weights, explorationRate, - routingStrategy: routerStrategy, + routerStrategy, }; return { diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index a81d9e9771..2ee5a166c1 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1713,11 +1713,13 @@ export async function handleComboChat({ const autoConfigSource = combo?.autoConfig || combo?.config?.auto || combo?.config || {}; const routingStrategy = - typeof autoConfigSource.routingStrategy === "string" - ? autoConfigSource.routingStrategy - : typeof autoConfigSource.strategyName === "string" - ? autoConfigSource.strategyName - : "rules"; + typeof autoConfigSource.routerStrategy === "string" + ? autoConfigSource.routerStrategy + : typeof autoConfigSource.routingStrategy === "string" + ? autoConfigSource.routingStrategy + : typeof autoConfigSource.strategyName === "string" + ? autoConfigSource.strategyName + : "rules"; const candidatePool = Array.isArray(autoConfigSource.candidatePool) ? autoConfigSource.candidatePool diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index e4b0a31340..7efdb38ebb 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -27,6 +27,7 @@ import { isAnthropicCompatibleProvider, isClaudeCodeCompatibleProvider, isSelfHostedChatProvider, + providerAllowsOptionalApiKey, supportsApiKeyOnFreeProvider, } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; @@ -6125,15 +6126,13 @@ function AddApiKeyModal({ const isCloudflare = provider === "cloudflare-ai"; const localProviderMetadata = getLocalProviderMetadata(provider); const isLocalSelfHostedProvider = !!localProviderMetadata; - const isSearxng = provider === "searxng-search"; const isGooglePse = provider === "google-pse-search"; const isGrokWeb = provider === "grok-web"; const isPerplexityWeb = provider === "perplexity-web"; const isBlackboxWeb = provider === "blackbox-web"; const isMuseSparkWeb = provider === "muse-spark-web"; const isWebSessionProvider = isGrokWeb || isPerplexityWeb || isBlackboxWeb || isMuseSparkWeb; - const isPetals = provider === "petals"; - const apiKeyOptional = isSearxng || isPetals || isLocalSelfHostedProvider; + const apiKeyOptional = providerAllowsOptionalApiKey(provider); const commandCodeAuthPhaseLabel = commandCodeAuthState ? { idle: "Ready", @@ -6206,7 +6205,7 @@ function AddApiKeyModal({ ? t("localProviderApiKeyOptionalHint", { provider: localProviderMetadata?.name || providerName || provider || "", }) - : isSearxng || isPetals + : apiKeyOptional ? t("apiKeyOptionalHint") : undefined; @@ -6737,17 +6736,15 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const isGeminiCli = connection?.provider === "gemini-cli"; const localProviderMetadata = getLocalProviderMetadata(connection?.provider); const isLocalSelfHostedProvider = !!localProviderMetadata; - const isSearxng = connection?.provider === "searxng-search"; const isGooglePse = connection?.provider === "google-pse-search"; - const isPetals = connection?.provider === "petals"; - const apiKeyOptional = isSearxng || isPetals || isLocalSelfHostedProvider; + const apiKeyOptional = providerAllowsOptionalApiKey(connection?.provider); const isCcCompatible = isClaudeCodeCompatibleProvider(connection?.provider); const defaultRegion = "us-central1"; const apiCredentialHint = isLocalSelfHostedProvider ? t("localProviderApiKeyOptionalHint", { provider: localProviderMetadata?.name || connection?.provider || "", }) - : isSearxng || isPetals + : apiKeyOptional ? t("apiKeyOptionalHint") : t("leaveBlankKeepCurrentApiKey"); diff --git a/src/app/api/providers/test-batch/route.ts b/src/app/api/providers/test-batch/route.ts index ef9f3aa78b..0df170161f 100644 --- a/src/app/api/providers/test-batch/route.ts +++ b/src/app/api/providers/test-batch/route.ts @@ -9,6 +9,7 @@ import { WEB_COOKIE_PROVIDERS, SEARCH_PROVIDERS, AUDIO_ONLY_PROVIDERS, + CLOUD_AGENT_PROVIDERS, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, } from "@/shared/constants/providers"; @@ -26,6 +27,7 @@ function getAuthGroup(providerId) { if (AUDIO_ONLY_PROVIDERS[providerId]) return "audio"; if (LOCAL_PROVIDERS[providerId]) return "local"; if (UPSTREAM_PROXY_PROVIDERS[providerId]) return "upstream-proxy"; + if (CLOUD_AGENT_PROVIDERS[providerId]) return "cloud-agent"; if (APIKEY_PROVIDERS[providerId]) return "apikey"; if ( typeof providerId === "string" && @@ -99,6 +101,8 @@ export async function POST(request) { connectionsToTest = allConnections.filter( (c) => getAuthGroup(c.provider) === "upstream-proxy" ); + } else if (mode === "cloud-agent") { + connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider) === "cloud-agent"); } else if (mode === "compatible") { connectionsToTest = allConnections.filter((c) => isCompatibleProvider(c.provider)); } else if (mode === "all") { @@ -107,7 +111,7 @@ export async function POST(request) { return NextResponse.json( { error: - "Invalid mode. Use: provider, oauth, free, apikey, compatible, all, web-cookie, search, audio, local, upstream-proxy", + "Invalid mode. Use: provider, oauth, free, apikey, compatible, all, web-cookie, search, audio, local, upstream-proxy, cloud-agent", }, { status: 400 } ); diff --git a/src/app/api/v1/agents/tasks/[id]/route.ts b/src/app/api/v1/agents/tasks/[id]/route.ts index d81271254c..16af314bed 100644 --- a/src/app/api/v1/agents/tasks/[id]/route.ts +++ b/src/app/api/v1/agents/tasks/[id]/route.ts @@ -17,8 +17,10 @@ import pino from "pino"; const logger = pino({ name: "cloud-agents-api" }); -export async function OPTIONS() { - return new NextResponse(null, { headers: getCloudAgentCorsHeaders() }); +createCloudAgentTaskTable(); + +export async function OPTIONS(request: NextRequest) { + return new NextResponse(null, { headers: getCloudAgentCorsHeaders(request) }); } const ApproveSchema = z.object({ @@ -40,10 +42,10 @@ const TaskActionSchema = z.discriminatedUnion("action", [ CancelSchema, ]); -function cloudAgentCredentialsRequiredResponse(providerId: string) { +function cloudAgentCredentialsRequiredResponse(providerId: string, request: NextRequest) { return NextResponse.json( { error: `No active credentials configured for cloud agent provider: ${providerId}` }, - { status: 400, headers: getCloudAgentCorsHeaders() } + { status: 400, headers: getCloudAgentCorsHeaders(request) } ); } @@ -52,15 +54,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const authError = await requireCloudAgentManagementAuth(request); if (authError) return authError; - createCloudAgentTaskTable(); - const { id } = await params; const task = getCloudAgentTaskById(id); if (!task) { return NextResponse.json( { error: "Task not found" }, - { status: 404, headers: getCloudAgentCorsHeaders() } + { status: 404, headers: getCloudAgentCorsHeaders(request) } ); } @@ -83,7 +83,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }); } } catch (err) { - console.error("Failed to sync task status:", err); + logger.error({ err }, "Failed to sync task status"); } } @@ -93,12 +93,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ { data: serializeCloudAgentTask(updatedTask!), }, - { headers: getCloudAgentCorsHeaders() } + { headers: getCloudAgentCorsHeaders(request) } ); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500, headers: getCloudAgentCorsHeaders() } + { status: 500, headers: getCloudAgentCorsHeaders(request) } ); } } @@ -114,17 +114,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ if (!validation.success) { return NextResponse.json( { error: "Validation failed", details: validation.error.issues }, - { status: 400, headers: getCloudAgentCorsHeaders() } + { status: 400, headers: getCloudAgentCorsHeaders(request) } ); } - createCloudAgentTaskTable(); - const task = getCloudAgentTaskById(id); if (!task) { return NextResponse.json( { error: "Task not found" }, - { status: 404, headers: getCloudAgentCorsHeaders() } + { status: 404, headers: getCloudAgentCorsHeaders(request) } ); } @@ -134,7 +132,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ if (!agent) { return NextResponse.json( { error: "Agent not found" }, - { status: 500, headers: getCloudAgentCorsHeaders() } + { status: 500, headers: getCloudAgentCorsHeaders(request) } ); } @@ -142,22 +140,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ if (!task.external_id) { return NextResponse.json( { error: "No external task to approve" }, - { status: 400, headers: getCloudAgentCorsHeaders() } + { status: 400, headers: getCloudAgentCorsHeaders(request) } ); } const credentials = await getCloudAgentCredentials(task.provider_id); - if (!credentials) return cloudAgentCredentialsRequiredResponse(task.provider_id); + if (!credentials) return cloudAgentCredentialsRequiredResponse(task.provider_id, request); await agent.approvePlan(task.external_id, credentials); updateCloudAgentTask(id, { status: "running" }); } else if (validated.action === "message") { if (!task.external_id) { return NextResponse.json( { error: "No external task to message" }, - { status: 400, headers: getCloudAgentCorsHeaders() } + { status: 400, headers: getCloudAgentCorsHeaders(request) } ); } const credentials = await getCloudAgentCredentials(task.provider_id); - if (!credentials) return cloudAgentCredentialsRequiredResponse(task.provider_id); + if (!credentials) return cloudAgentCredentialsRequiredResponse(task.provider_id, request); const activity = await agent.sendMessage(task.external_id, validated.message, credentials); const activities: unknown[] = serializeCloudAgentTask(task).activities; activities.push(activity); @@ -169,13 +167,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const updatedTask = getCloudAgentTaskById(id); return NextResponse.json( { success: true, data: updatedTask ? serializeCloudAgentTask(updatedTask) : null }, - { headers: getCloudAgentCorsHeaders() } + { headers: getCloudAgentCorsHeaders(request) } ); } catch (error) { logger.error({ err: error }, "Failed to process task action"); return NextResponse.json( { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500, headers: getCloudAgentCorsHeaders() } + { status: 500, headers: getCloudAgentCorsHeaders(request) } ); } } @@ -188,23 +186,21 @@ export async function DELETE( const authError = await requireCloudAgentManagementAuth(request); if (authError) return authError; - createCloudAgentTaskTable(); - const { id } = await params; const task = getCloudAgentTaskById(id); if (!task) { return NextResponse.json( { error: "Task not found" }, - { status: 404, headers: getCloudAgentCorsHeaders() } + { status: 404, headers: getCloudAgentCorsHeaders(request) } ); } deleteCloudAgentTask(id); - return NextResponse.json({ success: true }, { headers: getCloudAgentCorsHeaders() }); + return NextResponse.json({ success: true }, { headers: getCloudAgentCorsHeaders(request) }); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500, headers: getCloudAgentCorsHeaders() } + { status: 500, headers: getCloudAgentCorsHeaders(request) } ); } } diff --git a/src/app/api/v1/agents/tasks/route.ts b/src/app/api/v1/agents/tasks/route.ts index 614c5b649f..f17d73ba51 100644 --- a/src/app/api/v1/agents/tasks/route.ts +++ b/src/app/api/v1/agents/tasks/route.ts @@ -19,14 +19,16 @@ import pino from "pino"; const logger = pino({ name: "cloud-agents-api" }); +createCloudAgentTaskTable(); + function getLimit(value: string | null): number { const parsed = Number.parseInt(value || "50", 10); if (!Number.isFinite(parsed)) return 50; return Math.max(1, Math.min(parsed, 500)); } -export async function OPTIONS() { - return new NextResponse(null, { headers: getCloudAgentCorsHeaders() }); +export async function OPTIONS(request: NextRequest) { + return new NextResponse(null, { headers: getCloudAgentCorsHeaders(request) }); } export async function GET(request: NextRequest) { @@ -34,8 +36,6 @@ export async function GET(request: NextRequest) { const authError = await requireCloudAgentManagementAuth(request); if (authError) return authError; - createCloudAgentTaskTable(); - const { searchParams } = new URL(request.url); const providerId = searchParams.get("provider"); const status = searchParams.get("status"); @@ -54,12 +54,12 @@ export async function GET(request: NextRequest) { { data: tasks.map(serializeCloudAgentTask), }, - { headers: getCloudAgentCorsHeaders() } + { headers: getCloudAgentCorsHeaders(request) } ); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500, headers: getCloudAgentCorsHeaders() } + { status: 500, headers: getCloudAgentCorsHeaders(request) } ); } } @@ -74,7 +74,7 @@ export async function POST(request: NextRequest) { if (!validation.success) { return NextResponse.json( { error: "Validation failed", details: validation.error.issues }, - { status: 400, headers: getCloudAgentCorsHeaders() } + { status: 400, headers: getCloudAgentCorsHeaders(request) } ); } @@ -84,7 +84,7 @@ export async function POST(request: NextRequest) { if (!agent) { return NextResponse.json( { error: `Unknown provider: ${validated.providerId}` }, - { status: 400, headers: getCloudAgentCorsHeaders() } + { status: 400, headers: getCloudAgentCorsHeaders(request) } ); } @@ -94,7 +94,7 @@ export async function POST(request: NextRequest) { { error: `No active credentials configured for cloud agent provider: ${validated.providerId}`, }, - { status: 400, headers: getCloudAgentCorsHeaders() } + { status: 400, headers: getCloudAgentCorsHeaders(request) } ); } @@ -107,7 +107,6 @@ export async function POST(request: NextRequest) { credentials ); - createCloudAgentTaskTable(); insertCloudAgentTask({ id: task.id, provider_id: task.providerId, @@ -137,13 +136,13 @@ export async function POST(request: NextRequest) { createdAt: task.createdAt, }, }, - { status: 201, headers: getCloudAgentCorsHeaders() } + { status: 201, headers: getCloudAgentCorsHeaders(request) } ); } catch (error) { logger.error({ err: error }, "Failed to create cloud agent task"); return NextResponse.json( { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500, headers: getCloudAgentCorsHeaders() } + { status: 500, headers: getCloudAgentCorsHeaders(request) } ); } } @@ -153,25 +152,23 @@ export async function DELETE(request: NextRequest) { const authError = await requireCloudAgentManagementAuth(request); if (authError) return authError; - createCloudAgentTaskTable(); - const { searchParams } = new URL(request.url); const taskId = searchParams.get("id"); if (!taskId) { return NextResponse.json( { error: "Task ID required" }, - { status: 400, headers: getCloudAgentCorsHeaders() } + { status: 400, headers: getCloudAgentCorsHeaders(request) } ); } deleteCloudAgentTask(taskId); - return NextResponse.json({ success: true }, { headers: getCloudAgentCorsHeaders() }); + return NextResponse.json({ success: true }, { headers: getCloudAgentCorsHeaders(request) }); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500, headers: getCloudAgentCorsHeaders() } + { status: 500, headers: getCloudAgentCorsHeaders(request) } ); } } diff --git a/src/lib/cloudAgent/api.ts b/src/lib/cloudAgent/api.ts index 01ca2cd8d7..90dbcb1690 100644 --- a/src/lib/cloudAgent/api.ts +++ b/src/lib/cloudAgent/api.ts @@ -5,17 +5,19 @@ import type { CloudAgentTaskRow } from "./db.ts"; type JsonRecord = Record; -export function getCloudAgentCorsHeaders() { +export function getCloudAgentCorsHeaders(request?: Request) { + const origin = request?.headers.get("origin"); return { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": origin || "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Credentials": "true", }; } -export function withCloudAgentCors(response: Response): Response { +export function withCloudAgentCors(response: Response, request?: Request): Response { const headers = new Headers(response.headers); - for (const [key, value] of Object.entries(getCloudAgentCorsHeaders())) { + for (const [key, value] of Object.entries(getCloudAgentCorsHeaders(request))) { headers.set(key, value); } @@ -28,7 +30,7 @@ export function withCloudAgentCors(response: Response): Response { export async function requireCloudAgentManagementAuth(request: Request): Promise { const authError = await requireManagementAuth(request); - return authError ? withCloudAgentCors(authError) : null; + return authError ? withCloudAgentCors(authError, request) : null; } function parseJson(value: string | null | undefined, fallback: T): T { diff --git a/src/server/authz/classify.ts b/src/server/authz/classify.ts index 60128281c2..31f84fdcc0 100644 --- a/src/server/authz/classify.ts +++ b/src/server/authz/classify.ts @@ -69,16 +69,10 @@ export function classifyRoute(rawPath: string, method: string = "GET"): RouteCla }; } - if ( - normalizedPath === "/api/v1" || - normalizedPath.startsWith("/api/v1/") || - normalizedPath.startsWith("/api/mcp/") - ) { + if (normalizedPath === "/api/v1" || normalizedPath.startsWith("/api/v1/")) { return { routeClass: "CLIENT_API", - reason: - aliasReason ?? - (normalizedPath.startsWith("/api/mcp/") ? "client_api_mcp" : "client_api_v1"), + reason: aliasReason ?? "client_api_v1", normalizedPath, }; } diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 490f54bc6f..81776cbb52 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1962,6 +1962,8 @@ export function providerAllowsOptionalApiKey(providerId: unknown): boolean { return ( providerId === "searxng-search" || providerId === "petals" || + providerId === "pollinations" || + isLocalProvider(providerId) || isSelfHostedChatProvider(providerId) || isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId) diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index 4a6e41fd35..8e12f6dbd9 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -3,14 +3,16 @@ const PUBLIC_API_ROUTE_PREFIXES = [ "/api/auth/logout", "/api/auth/status", "/api/init", - "/api/settings/require-login", "/api/v1/", "/api/cloud/", "/api/sync/bundle", "/api/oauth/", ]; -const PUBLIC_READONLY_API_ROUTE_PREFIXES = ["/api/monitoring/health"]; +const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ + "/api/monitoring/health", + "/api/settings/require-login", +]; const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); diff --git a/src/shared/utils/apiAuth.ts b/src/shared/utils/apiAuth.ts index f4750750db..e4d4f75343 100644 --- a/src/shared/utils/apiAuth.ts +++ b/src/shared/utils/apiAuth.ts @@ -58,6 +58,10 @@ function isOnboardingBootstrapPath(pathname: string | null): boolean { return pathname === "/dashboard/onboarding"; } +function isRequireLoginBootstrapWritePath(pathname: string | null, method: string): boolean { + return pathname === "/api/settings/require-login" && method.toUpperCase() === "POST"; +} + function getRequestMethod(request: RequestLike | Request | null | undefined): string { if ( request && @@ -277,11 +281,16 @@ export async function isAuthRequired( if (!request) return false; const pathname = getRequestPathname(request); + const method = getRequestMethod(request); if (isOnboardingBootstrapPath(pathname)) { return false; } - if (pathname && isPublicApiRoute(pathname, getRequestMethod(request))) { + if (pathname && isPublicApiRoute(pathname, method)) { + return false; + } + + if (isRequireLoginBootstrapWritePath(pathname, method)) { return false; } diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 0865b35f61..a8e471f917 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -6,7 +6,7 @@ import { import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints"; import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize"; import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode"; -import { isLocalProvider } from "@/shared/constants/providers"; +import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility"; import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders"; @@ -259,10 +259,7 @@ export const createProviderSchema = z }) .superRefine((data, ctx) => { const apiKey = typeof data.apiKey === "string" ? data.apiKey.trim() : ""; - const apiKeyOptional = - data.provider === "searxng-search" || - data.provider === "petals" || - isLocalProvider(data.provider); + const apiKeyOptional = providerAllowsOptionalApiKey(data.provider); if (!apiKeyOptional && apiKey.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -1625,6 +1622,7 @@ export const providersBatchTestSchema = z "audio", "local", "upstream-proxy", + "cloud-agent", ]), // Frontend may send null when mode != 'provider' — accept and treat as missing providerId: z.string().trim().min(1).nullable().optional(), diff --git a/tests/unit/authz/classify.test.ts b/tests/unit/authz/classify.test.ts index 5870d8ed30..c32719cedd 100644 --- a/tests/unit/authz/classify.test.ts +++ b/tests/unit/authz/classify.test.ts @@ -35,6 +35,7 @@ const cases: Case[] = [ { name: "/api/v1/files", path: "/api/v1/files", expectedClass: "CLIENT_API" }, { name: "/api/v1/batches", path: "/api/v1/batches", expectedClass: "CLIENT_API" }, { name: "/api/v1/ws", path: "/api/v1/ws", expectedClass: "CLIENT_API" }, + { name: "/api/mcp/* stays management", path: "/api/mcp/status", expectedClass: "MANAGEMENT" }, { name: "/v1 alias", diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index a0e52d4d60..079dd836f8 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -98,7 +98,7 @@ test("runAuthzPipeline allows onboarding when login is required but no password ); assert.equal(response.status, 200); - assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); + assert.equal(response.headers.get("x-omniroute-route-class"), "PUBLIC"); }); test("runAuthzPipeline allows first password writes when login is required but no password exists", async () => { @@ -115,7 +115,7 @@ test("runAuthzPipeline allows first password writes when login is required but n ); assert.equal(response.status, 200); - assert.equal(response.headers.get("x-omniroute-route-class"), "PUBLIC"); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); }); test("runAuthzPipeline keeps management API rejections as JSON", async () => { diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index e90993bcdb..82f8e758cf 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -1503,7 +1503,7 @@ test("handleComboChat auto strategy honors LKGP after filtering to tool-capable name: "auto-lkgp", strategy: "auto", models: ["openai/gpt-oss-120b", "openai/gpt-4o-mini", "claude/claude-sonnet-4-6"], - autoConfig: { routingStrategy: "lkgp" }, + autoConfig: { routerStrategy: "lkgp" }, }, handleSingleModel: async (_body: any, modelStr: any) => { calls.push(modelStr); @@ -1621,7 +1621,7 @@ test("handleComboChat auto strategy falls back to the full pool when tool filter name: "auto-cost-fallback", strategy: "auto", models: ["openai/gpt-oss-120b", "deepseek/reasoner"], - autoConfig: { routingStrategy: "cost" }, + autoConfig: { routerStrategy: "cost" }, }, handleSingleModel: async (_body: any, modelStr: any) => { calls.push(modelStr); @@ -1658,7 +1658,7 @@ test("handleComboChat auto strategy falls back to rules when a custom router str name: "auto-throwing-strategy", strategy: "auto", models: ["openai/gpt-4o-mini"], - autoConfig: { routingStrategy: "throwing-test" }, + autoConfig: { routerStrategy: "throwing-test" }, }, handleSingleModel: async (_body: any, modelStr: any) => { calls.push(modelStr); diff --git a/tests/unit/provider-route-schemas.test.ts b/tests/unit/provider-route-schemas.test.ts new file mode 100644 index 0000000000..ca61c4f28d --- /dev/null +++ b/tests/unit/provider-route-schemas.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createProviderSchema, providersBatchTestSchema } = + await import("../../src/shared/validation/schemas.ts"); +const { providerAllowsOptionalApiKey } = await import("../../src/shared/constants/providers.ts"); + +test("Pollinations is treated as a keyless-capable provider", () => { + assert.equal(providerAllowsOptionalApiKey("pollinations"), true); +}); + +test("createProviderSchema allows Pollinations without apiKey", () => { + const result = createProviderSchema.safeParse({ + provider: "pollinations", + name: "Pollinations", + }); + + assert.equal(result.success, true); +}); + +test("providersBatchTestSchema accepts cloud-agent batch mode", () => { + const result = providersBatchTestSchema.safeParse({ + mode: "cloud-agent", + }); + + assert.equal(result.success, true); +}); diff --git a/tests/unit/public-api-routes.test.ts b/tests/unit/public-api-routes.test.ts index 755cec0cbb..e30b1f809e 100644 --- a/tests/unit/public-api-routes.test.ts +++ b/tests/unit/public-api-routes.test.ts @@ -18,7 +18,7 @@ test("isPublicApiRoute allows readonly health and require-login bootstrap routes assert.equal(isPublicApiRoute("/api/settings/require-login", "GET"), true); assert.equal(isPublicApiRoute("/api/settings/require-login", "HEAD"), true); assert.equal(isPublicApiRoute("/api/settings/require-login", "OPTIONS"), true); - assert.equal(isPublicApiRoute("/api/settings/require-login", "POST"), true); + assert.equal(isPublicApiRoute("/api/settings/require-login", "POST"), false); }); test("isPublicApiRoute rejects non-public management routes", () => { diff --git a/tests/unit/virtual-auto-combo.test.ts b/tests/unit/virtual-auto-combo.test.ts index e98c392843..a303c1d365 100644 --- a/tests/unit/virtual-auto-combo.test.ts +++ b/tests/unit/virtual-auto-combo.test.ts @@ -52,7 +52,7 @@ test("createVirtualAutoCombo returns an executable auto combo for API-key connec assert.equal(combo.models[0].kind, "model"); assert.equal(combo.models[0].model, "openai/gpt-4o-mini"); assert.equal(combo.models[0].providerId, "openai"); - assert.equal(combo.autoConfig.routingStrategy, "lkgp"); + assert.equal(combo.autoConfig.routerStrategy, "lkgp"); assert.deepEqual(combo.autoConfig.candidatePool, ["openai"]); });