Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- **fix(gamification):** the dashboard Profile page no longer hits three 404s — added the missing `GET /api/gamification/{level,badges,badges/earned}` routes (management-scoped). The page is operator-wide (no `apiKeyId`), so `level`/`badges/earned` aggregate across all keys (with an optional `?apiKeyId` for a single key), and `badges` seeds the built-in catalog first (idempotent) so the grid is populated even on installs that never seeded it (see #3472). ([#3484](https://github.com/diegosouzapw/OmniRoute/issues/3484))
- **security(oauth):** migrate the five public OAuth client_ids (Claude, Codex, Qwen, Kimi, GitHub Copilot — 9 server-side call-sites in `providerRegistry.ts` + `oauth.ts`) from string literals to `resolvePublicCred()` (Hard Rule #11), matching the existing Gemini/Antigravity pattern. The values decode byte-for-byte to the same public client_ids (env overrides still win), so OAuth flows are unchanged; the `check-public-creds` allowlist is now empty. The browser-bundled `codexDeviceFlow.ts` copy stays a literal by necessity (it cannot import `open-sse`). ([#3493](https://github.com/diegosouzapw/OmniRoute/issues/3493))
- **fix(mcp):** `omniroute --mcp` no longer crashes on npm installs with `ERR_MODULE_NOT_FOUND` (e.g. `src/lib/combos/steps.ts`) — the MCP server runs from raw TypeScript and imports across `src/` + `open-sse/`, but the published `files` allowlist only shipped a handful of cherry-picked paths, so the transitive closure (~400 files) was absent from the tarball. `files` now ships the backend source the MCP server needs (`open-sse/` + `src/{domain,lib,mitm,server,shared,sse,types}/`, excluding the `src/app` UI), and a new regression test computes the MCP import closure and fails if any reachable source file is not covered by `files`. ([#3578](https://github.com/diegosouzapw/OmniRoute/issues/3578))
- **fix(api):** `API_REFERENCE.md` no longer documents a non-existent `/api/guardrails*` / `/api/shadow*` surface (doc-fiction flagged by `check-docs-symbols`, frozen in `KNOWN_STALE_DOC_REFS`). The guardrail pipeline is real (`src/lib/guardrails`), so the two routes that map to actual behavior are now implemented — `GET /api/guardrails` (list the registered guardrails + status) and `POST /api/guardrails/test` (dry-run the pre-call pipeline over a sample input), both management-scoped — while the fictional `enable`/`disable`/`logs` rows and the entire `/api/shadow*` table (shadow A-B comparison is combo-config + `/api/combos/metrics`) were removed from the doc and dropped from the allowlist. ([#3496](https://github.com/diegosouzapw/OmniRoute/issues/3496))

---

Expand Down
25 changes: 6 additions & 19 deletions docs/reference/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1287,31 +1287,18 @@ See [Plugins Framework](../plugins/PLUGIN_SDK.md) for full details.

## Shadow Routing

Beta-test new providers without affecting production traffic.

| Method | Path | Description |
| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- |
| GET | `/api/shadow` | List all shadow routing rules |
| POST | `/api/shadow` | Create a shadow routing rule — body: `{providerId, shadowProviderId, trafficPct, duration?}` |
| DELETE | `/api/shadow/[id]` | Delete a shadow routing rule |
| GET | `/api/shadow/[id]/results` | Get shadow routing results (comparison metrics between real and shadow traffic) |
| GET | `/api/shadow/metrics` | Aggregate shadow routing metrics across all rules |

**Auth:** Requires management session.
Shadow / A-B comparison of providers is **not a standalone REST surface** — it is configured through combo routing (see [Auto-Combo](../routing/AUTO-COMBO.md)). Per-combo comparison metrics are served by `GET /api/combos/metrics`.

---

## Guardrails

Manage runtime guardrails (PII detection, prompt injection detection, vision bridging).
Inspect the runtime guardrails (PII detection, prompt injection detection, vision bridging). Guardrails run on every request; per-call opt-out is via the `x-omniroute-disabled-guardrails` request header — there is no persisted enable/disable surface.

| Method | Path | Description |
| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- |
| GET | `/api/guardrails` | List all guardrails and their status (enabled/disabled) |
| POST | `/api/guardrails/[id]/enable` | Enable a guardrail |
| POST | `/api/guardrails/[id]/disable` | Disable a guardrail |
| GET | `/api/guardrails/logs` | Get guardrail trigger logs (PII detections, injection attempts, etc.) |
| POST | `/api/guardrails/test` | Test a guardrail against a sample input |
| Method | Path | Description |
| ------ | ---------------------- | ---------------------------------------------------------------------------------------- |
| GET | `/api/guardrails` | List the registered guardrails and their status (name / enabled / priority) |
| POST | `/api/guardrails/test` | Dry-run the pre-call pipeline over a sample input — body: `{input, disabledGuardrails?}` |

**Auth:** Requires management session.

Expand Down
15 changes: 5 additions & 10 deletions scripts/check/check-docs-symbols.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,11 @@ function isFileRef(p) {
// o path na doc, ou remover a menção. NÃO adicione novas aqui sem justificativa — esse
// é o ponto do gate. Issues de tracking devem ser abertas para cada cluster.
export const KNOWN_STALE_DOC_REFS = new Set([
// docs/reference/API_REFERENCE.md — guardrails/shadow entries fixed in separate issues:
"/api/guardrails", // sem dir de API guardrails (feature server-side, sem rota REST) — #3496
"/api/guardrails/[id]/disable",
"/api/guardrails/[id]/enable",
"/api/guardrails/logs",
"/api/guardrails/test",
"/api/shadow", // sem dir de API shadow (shadow routing não tem rota REST) — #3498
"/api/shadow/[id]",
"/api/shadow/[id]/results",
"/api/shadow/metrics",
// docs/reference/API_REFERENCE.md — guardrails/shadow doc-fiction RESOLVED in #3496:
// GET /api/guardrails + POST /api/guardrails/test are now REAL routes (wrapping the
// existing guardrailRegistry); the fictional enable/disable/logs rows and the entire
// shadow table were removed from the doc (shadow A-B comparison is combo-config +
// /api/combos/metrics). No allowlist entries needed for these anymore.
// docs/research/DISCOVERY_TOOL_DESIGN.md — design doc de feature NÃO implementada
// (Phase 2). Refs INTENCIONAIS: o doc agora traz um banner "⚠️ Not yet implemented
// — Phase 2" acima da tabela de endpoints. Mantidos aqui até a feature existir. — #3498
Expand Down
32 changes: 32 additions & 0 deletions src/app/api/guardrails/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* GET /api/guardrails — list the registered runtime guardrails and their status
* (name / enabled / priority). Guardrails run on every request; per-call opt-out
* is done via the `x-omniroute-disabled-guardrails` header, so there is no
* persisted enable/disable surface — see POST /api/guardrails/test to dry-run
* the pipeline. (#3496)
*
* LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth.
*/
import { NextRequest, NextResponse } from "next/server";

import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { registerDefaultGuardrails } from "@/lib/guardrails/registry";

export async function OPTIONS() {
return handleCorsOptions();
}

export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;

const registry = registerDefaultGuardrails();
const guardrails = registry.list().map((guardrail) => ({
name: guardrail.name,
enabled: guardrail.enabled,
priority: guardrail.priority,
}));

return NextResponse.json({ guardrails }, { headers: CORS_HEADERS });
}
51 changes: 51 additions & 0 deletions src/app/api/guardrails/test/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* POST /api/guardrails/test — dry-run the pre-call guardrail pipeline over a
* sample input and return the per-guardrail verdict (blocked / modified /
* skipped / passed) plus the resulting (possibly masked) payload. Lets operators
* preview PII masking, prompt-injection and vision-bridge behavior without
* issuing a real upstream request. (#3496)
*
* LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth.
*/
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { registerDefaultGuardrails } from "@/lib/guardrails/registry";

const TestRequestSchema = z.object({
input: z.union([z.string(), z.record(z.unknown()), z.array(z.unknown())]),
disabledGuardrails: z.array(z.string()).optional(),
});

export async function OPTIONS() {
return handleCorsOptions();
}

export async function POST(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;

let parsed: z.infer<typeof TestRequestSchema>;
try {
parsed = TestRequestSchema.parse(await request.json());
} catch {
return createErrorResponse({
status: 400,
message: "Invalid request body — expected { input: string | object | array, disabledGuardrails?: string[] }",
type: "invalid_request",
});
}

const registry = registerDefaultGuardrails();
const outcome = await registry.runPreCallHooks(parsed.input, {
disabledGuardrails: parsed.disabledGuardrails,
});

return NextResponse.json(
{ blocked: outcome.blocked, results: outcome.results, payload: outcome.payload },
{ headers: CORS_HEADERS }
);
}
126 changes: 126 additions & 0 deletions tests/unit/guardrails-api-3496.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { makeManagementSessionRequest } from "../helpers/managementSession.ts";

// #3496 — docs/reference/API_REFERENCE.md documented a `/api/guardrails*` and
// `/api/shadow*` surface that did not exist (doc-fiction, frozen in the
// check-docs-symbols allowlist). The guardrail pipeline itself is real
// (src/lib/guardrails), so the fix implements the two routes that map to real
// behavior — GET /api/guardrails (list) and POST /api/guardrails/test (dry-run
// the pre-call hooks) — removes the fictional enable/disable/logs + shadow rows
// from the docs, and drops them from KNOWN_STALE_DOC_REFS.

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-guardrails-3496-"));
process.env.DATA_DIR = TEST_DATA_DIR;
if (!process.env.JWT_SECRET) process.env.JWT_SECRET = "test-guardrails-3496-jwt-secret";
if (!process.env.API_KEY_SECRET) process.env.API_KEY_SECRET = "test-guardrails-3496-apikey-secret";

const listRoute = await import("../../src/app/api/guardrails/route.ts");
const testRoute = await import("../../src/app/api/guardrails/test/route.ts");
const core = await import("../../src/lib/db/core.ts");

test.after(() => {
try {
core.getDbInstance().close();
} catch {
/* ignore */
}
try {
core.resetDbInstance();
} catch {
/* ignore */
}
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});

test("#3496 GET /api/guardrails lists the registered guardrails with status", async () => {
const req = await makeManagementSessionRequest("http://localhost/api/guardrails");
const res = await listRoute.GET(req);
assert.equal(res.status, 200);

const body = await res.json();
assert.ok(Array.isArray(body.guardrails), "expected guardrails[] in the body");

const names = body.guardrails.map((g) => g.name);
for (const expected of ["vision-bridge", "pii-masker", "prompt-injection"]) {
assert.ok(names.includes(expected), `expected ${expected} in [${names.join(", ")}]`);
}

for (const g of body.guardrails) {
assert.equal(typeof g.name, "string");
assert.equal(typeof g.enabled, "boolean");
assert.equal(typeof g.priority, "number");
}
});

test("#3496 POST /api/guardrails/test runs the pre-call pipeline over a sample input", async () => {
const req = await makeManagementSessionRequest("http://localhost/api/guardrails/test", {
method: "POST",
body: { input: { messages: [{ role: "user", content: "hello world" }] } },
});
const res = await testRoute.POST(req);
assert.equal(res.status, 200);

const body = await res.json();
assert.equal(typeof body.blocked, "boolean");
assert.ok(Array.isArray(body.results), "expected a per-guardrail results[]");

const evaluated = body.results.map((r) => r.guardrail);
assert.ok(
evaluated.includes("pii-masker"),
`expected pii-masker to be evaluated, got [${evaluated.join(", ")}]`
);
});

test("#3496 POST /api/guardrails/test honors disabledGuardrails", async () => {
const req = await makeManagementSessionRequest("http://localhost/api/guardrails/test", {
method: "POST",
body: { input: "hello", disabledGuardrails: ["pii-masker"] },
});
const res = await testRoute.POST(req);
assert.equal(res.status, 200);

const body = await res.json();
const pii = body.results.find((r) => r.guardrail === "pii-masker");
assert.ok(pii, "pii-masker should still appear in results");
assert.equal(pii.skipped, true, "pii-masker should be skipped when disabled");
});

test("#3496 POST /api/guardrails/test rejects a body without input (400)", async () => {
const req = await makeManagementSessionRequest("http://localhost/api/guardrails/test", {
method: "POST",
body: {},
});
const res = await testRoute.POST(req);
assert.equal(res.status, 400);
});

// Regression guard for the quality gate: the docs no longer reference any
// non-existent guardrails/shadow route, and the allowlist no longer freezes them.
test("#3496 check-docs-symbols no longer freezes guardrails/shadow + API_REFERENCE is clean", async () => {
const { KNOWN_STALE_DOC_REFS, collectRouteFiles, extractDocApiPaths, findStaleDocApiRefs } =
await import("../../scripts/check/check-docs-symbols.mjs");

// (1) allowlist no longer freezes any guardrails/shadow path
for (const frozen of [...KNOWN_STALE_DOC_REFS]) {
assert.ok(
!frozen.startsWith("/api/guardrails") && !frozen.startsWith("/api/shadow"),
`allowlist should not still freeze ${frozen}`
);
}

// (2) API_REFERENCE.md no longer references a non-existent guardrails/shadow route
const routeFiles = collectRouteFiles();
const apiRefRel = "docs/reference/API_REFERENCE.md";
const src = fs.readFileSync(path.join(process.cwd(), apiRefRel), "utf8");
const docPathsByFile = [{ file: apiRefRel, paths: extractDocApiPaths(src) }];
const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS);
const ghosts = misses.filter(
(m) => m.includes("/api/guardrails") || m.includes("/api/shadow")
);
assert.deepEqual(ghosts, [], `stale guardrails/shadow refs remain: ${ghosts.join("; ")}`);
});