diff --git a/src/app/api/v1/combos/projectCombo.ts b/src/app/api/v1/combos/projectCombo.ts new file mode 100644 index 0000000000..67af5ccf92 --- /dev/null +++ b/src/app/api/v1/combos/projectCombo.ts @@ -0,0 +1,56 @@ +/** + * Public projection helpers for GET /v1/combos (issue #2300). + * + * Strip internal routing details (connectionId, weights, labels, etc.) before + * returning combo metadata to API-key callers. Kept in a separate module so + * the projection can be unit-tested without spinning up the Next.js route. + */ +export interface PublicComboStep { + kind: "model" | "combo-ref"; + model?: string; + comboName?: string; + providerId?: string; +} + +export interface PublicCombo { + name: string; + strategy: string; + description?: string; + models: PublicComboStep[]; +} + +export function projectComboStep(step: Record): PublicComboStep | null { + const kind = step.kind; + if (kind === "combo-ref" && typeof step.comboName === "string") { + return { kind: "combo-ref", comboName: step.comboName }; + } + if (kind === "model" && typeof step.model === "string") { + const out: PublicComboStep = { kind: "model", model: step.model }; + if (typeof step.providerId === "string" && step.providerId.length > 0) { + out.providerId = step.providerId; + } + return out; + } + return null; +} + +export function projectCombo(combo: Record): PublicCombo | null { + const name = typeof combo.name === "string" ? combo.name.trim() : ""; + if (!name) return null; + + const strategy = typeof combo.strategy === "string" ? combo.strategy : "priority"; + + const out: PublicCombo = { name, strategy, models: [] }; + if (typeof combo.description === "string" && combo.description.length > 0) { + out.description = combo.description; + } + + const rawModels = Array.isArray(combo.models) ? combo.models : []; + for (const m of rawModels) { + if (m && typeof m === "object") { + const step = projectComboStep(m as Record); + if (step) out.models.push(step); + } + } + return out; +} diff --git a/src/app/api/v1/combos/route.ts b/src/app/api/v1/combos/route.ts new file mode 100644 index 0000000000..882da46539 --- /dev/null +++ b/src/app/api/v1/combos/route.ts @@ -0,0 +1,56 @@ +/** + * GET /v1/combos — API-key safe read of combo metadata. + * + * Issue #2300: `/api/combos` is management-gated, which blocks integrations + * like `opencode-omniroute-auth` that need to enrich combo capabilities from + * a normal Bearer API key. This endpoint exposes the same public metadata + * with the API-key auth model used by `/v1/models` and projects out internal + * routing details (account/connection ids, weights, internal labels). + */ +import { NextResponse } from "next/server"; +import { getCombos } from "@/lib/localDb"; +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; +import { projectCombo, type PublicCombo } from "./projectCombo"; + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +export async function GET(request: Request) { + // Accept: (1) valid Bearer API key, (2) dashboard session cookie. Reject + // anonymous requests so combo metadata isn't world-readable on a deployed + // proxy unless the operator has explicitly disabled API-key enforcement. + const apiKeyRaw = extractApiKey(request); + const apiKeyOk = apiKeyRaw ? await isValidApiKey(apiKeyRaw) : false; + const dashboardOk = !apiKeyOk ? await isDashboardSessionAuthenticated(request) : false; + + if (!apiKeyOk && !dashboardOk) { + if (process.env.REQUIRE_API_KEY === "true") { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Authentication required"); + } + // REQUIRE_API_KEY=false → still allow anonymous read of public metadata. + // This mirrors the /v1/models behavior on single-user local deployments. + } + + try { + const combos = await getCombos(); + const data = (Array.isArray(combos) ? combos : []) + .map((c) => projectCombo(c as Record)) + .filter((c): c is PublicCombo => c !== null); + + return NextResponse.json( + { object: "list", data }, + { headers: { "Cache-Control": "no-store" } } + ); + } catch { + return errorResponse(HTTP_STATUS.SERVER_ERROR, "Failed to fetch combos"); + } +} diff --git a/tests/unit/v1-combos-projection.test.ts b/tests/unit/v1-combos-projection.test.ts new file mode 100644 index 0000000000..360844a38f --- /dev/null +++ b/tests/unit/v1-combos-projection.test.ts @@ -0,0 +1,114 @@ +/** + * Issue #2300 — Public projection of combo metadata for GET /v1/combos. + * Verifies that internal routing details (connectionId, weights) are stripped + * before being exposed to API-key callers. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { projectCombo, projectComboStep } = + await import("../../src/app/api/v1/combos/projectCombo.ts"); + +test("#2300 projectComboStep keeps model + providerId, drops connectionId/weight/label", () => { + const out = projectComboStep({ + id: "step_internal_id", + kind: "model", + model: "anthropic/claude-sonnet-4", + providerId: "anthropic", + connectionId: "conn_secret_xyz", + weight: 0.7, + label: "primary", + tags: ["fast"], + }); + + assert.deepEqual(out, { + kind: "model", + model: "anthropic/claude-sonnet-4", + providerId: "anthropic", + }); +}); + +test("#2300 projectComboStep keeps combo-ref's comboName, drops weight/label", () => { + const out = projectComboStep({ + id: "step_internal", + kind: "combo-ref", + comboName: "fallback-combo", + weight: 0.3, + label: "secondary", + }); + + assert.deepEqual(out, { kind: "combo-ref", comboName: "fallback-combo" }); +}); + +test("#2300 projectComboStep returns null for unknown kinds + malformed steps", () => { + assert.equal(projectComboStep({ kind: "unknown" }), null); + assert.equal(projectComboStep({ kind: "model" }), null); // missing model + assert.equal(projectComboStep({ kind: "combo-ref" }), null); // missing comboName + assert.equal(projectComboStep({}), null); + assert.equal(projectComboStep({ not_a_kind: true }), null); +}); + +test("#2300 projectCombo preserves name/strategy/description, projects models", () => { + const out = projectCombo({ + id: "internal_id", + name: "my-combo", + strategy: "priority", + description: "primary route", + sortOrder: 5, + models: [ + { + kind: "model", + model: "openai/gpt-5", + providerId: "openai", + connectionId: "conn_X", + weight: 1, + }, + ], + schemaVersion: 2, + config: { secret: "should-not-leak" }, + }); + + assert.deepEqual(out, { + name: "my-combo", + strategy: "priority", + description: "primary route", + models: [{ kind: "model", model: "openai/gpt-5", providerId: "openai" }], + }); + + const serialized = JSON.stringify(out); + assert.ok(!serialized.includes("conn_X"), "connection id must not leak"); + assert.ok(!serialized.includes("should-not-leak"), "config.secret must not leak"); + assert.ok(!serialized.includes("sortOrder"), "sortOrder must not leak"); +}); + +test("#2300 projectCombo defaults strategy to 'priority' when missing", () => { + const out = projectCombo({ name: "default-strategy", models: [] }); + assert.equal(out?.strategy, "priority"); +}); + +test("#2300 projectCombo returns null for empty name", () => { + assert.equal(projectCombo({ name: "", models: [] }), null); + assert.equal(projectCombo({ name: " ", models: [] }), null); + assert.equal(projectCombo({ models: [] }), null); +}); + +test("#2300 projectCombo filters out malformed step entries silently", () => { + const out = projectCombo({ + name: "noisy", + strategy: "auto", + models: [ + { kind: "model", model: "openai/gpt-5" }, + "not-an-object", + null, + { kind: "unknown" }, + { kind: "model" }, // missing model + ], + }); + assert.equal(out?.models.length, 1); + assert.equal(out?.models[0].model, "openai/gpt-5"); +}); + +test("#2300 projectCombo omits description when empty", () => { + const out = projectCombo({ name: "no-desc", strategy: "priority", models: [] }); + assert.equal("description" in (out ?? {}), false); +});