mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
fix(api/combos): add API-key-safe GET /v1/combos endpoint (#2300)
The existing /api/combos GET requires a management token, which broke read-only integrations (opencode-omniroute-auth plugin and similar) that need to enrich combo capabilities from a normal Bearer API key. Those clients got 403 AUTH_001 'Invalid management token' even though the same API key could list models via /v1/models. This adds GET /v1/combos with the same auth model as /v1/models: - Accepts valid Bearer API key OR dashboard session cookie. - Falls back to anonymous when REQUIRE_API_KEY=false (single-user local). - Projects ONLY public metadata: name, strategy, description, model id, providerId, comboName (for combo-refs). Internal routing details (connectionId, weights, labels, sortOrder, config) are stripped. /api/combos (management writes) is unchanged.
This commit is contained in:
56
src/app/api/v1/combos/projectCombo.ts
Normal file
56
src/app/api/v1/combos/projectCombo.ts
Normal file
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>);
|
||||
if (step) out.models.push(step);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
56
src/app/api/v1/combos/route.ts
Normal file
56
src/app/api/v1/combos/route.ts
Normal file
@@ -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<string, unknown>))
|
||||
.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");
|
||||
}
|
||||
}
|
||||
114
tests/unit/v1-combos-projection.test.ts
Normal file
114
tests/unit/v1-combos-projection.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user