diff --git a/src/app/api/eval-cases/route.ts b/src/app/api/eval-cases/route.ts index e75455861..c6bf7cbb5 100644 --- a/src/app/api/eval-cases/route.ts +++ b/src/app/api/eval-cases/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { clinicalQueryModeSchema } from "@/lib/clinical-query-mode"; import { env, isDemoMode } from "@/lib/env"; @@ -124,6 +125,17 @@ export async function POST(request: Request) { const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); + + const rateLimit = await consumeApiRateLimit({ + supabase, + ownerId: user.id, + bucket: "ingestion_admin", + allowInMemoryFallbackOnUnavailable: true, + }); + if (rateLimit.limited) { + return rateLimitJsonResponse("Too many ingestion administration requests. Retry shortly.", rateLimit); + } + const normalizedQuery = normalizedQueryTextForStorage(parsed.query); const sourceChunkIds = uniqueUuidValues(parsed.sourceChunkIds); const citedChunkIds = uniqueUuidValues(parsed.citedChunkIds); diff --git a/src/app/api/ingestion/quality/route.ts b/src/app/api/ingestion/quality/route.ts index 7a888ccbd..a83a54f22 100644 --- a/src/app/api/ingestion/quality/route.ts +++ b/src/app/api/ingestion/quality/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -321,6 +322,17 @@ export async function GET(request: Request) { const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); + + const rateLimit = await consumeApiRateLimit({ + supabase, + ownerId: user.id, + bucket: "ingestion_admin", + allowInMemoryFallbackOnUnavailable: true, + }); + if (rateLimit.limited) { + return rateLimitJsonResponse("Too many ingestion administration requests. Retry shortly.", rateLimit); + } + const { limit } = parseRequestQuery(request, ingestionQualityQuerySchema, "Invalid ingestion quality query."); const { data: documentsData, error: documentsError } = await supabase diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index fb8b5a68a..e34debc24 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -39,7 +39,8 @@ export type ApiRateLimitBucket = | "bulk_reindex" | "source_review" | "answer_feedback" - | "registry"; + | "registry" + | "ingestion_admin"; export type ApiRateLimitResult = { limited: boolean; @@ -60,6 +61,9 @@ const apiRateLimitDefaults = { source_review: { limit: 30, windowSeconds: 60 }, answer_feedback: { limit: 30, windowSeconds: 60 }, registry: { limit: 120, windowSeconds: 60 }, + // Authenticated owner ingestion/eval admin tooling (ingestion-quality dashboard, eval-case capture). + // Generous for interactive/polling admin use, bounded against an abusive/compromised client. + ingestion_admin: { limit: 60, windowSeconds: 60 }, } as const satisfies Record; const anonymousApiRateLimitDefaults: Partial> = { diff --git a/tests/eval-cases-route.test.ts b/tests/eval-cases-route.test.ts index fe30e6ab1..e8cc8d892 100644 --- a/tests/eval-cases-route.test.ts +++ b/tests/eval-cases-route.test.ts @@ -60,6 +60,23 @@ function createInsertMock( expect(table).toBe("rag_query_misses"); return { insert }; }), + // The route consults the ingestion_admin rate limiter before touching tables. + rpc: vi.fn(async (name: string) => + name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit" + ? { + data: [ + { + limited: false, + limit_value: 60, + remaining: 59, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + ], + error: null, + } + : { data: [], error: null }, + ), }, }; } diff --git a/tests/ingestion-admin-rate-limit.test.ts b/tests/ingestion-admin-rate-limit.test.ts new file mode 100644 index 000000000..10f4279ec --- /dev/null +++ b/tests/ingestion-admin-rate-limit.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Proves the `ingestion_admin` bucket enforces a 429 on the authenticated +// ingestion/eval admin routes when the durable limiter reports the bucket +// exhausted. The routes pass `allowInMemoryFallbackOnUnavailable: true`, so a +// valid `limited: true` row is honoured directly (the fallback only engages when +// the limiter errors). + +const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +function rateLimitedClient() { + return { + from: vi.fn(() => { + throw new Error("no table access should occur once the request is rate limited"); + }), + rpc: vi.fn(async (name: string) => { + if (name === "consume_api_rate_limit") { + return { + data: [ + { + limited: true, + limit_value: 60, + remaining: 0, + retry_after_seconds: 37, + reset_at: new Date(Date.now() + 37_000).toISOString(), + }, + ], + error: null, + }; + } + return { data: [], error: null }; + }), + }; +} + +function mockRuntime(client: ReturnType) { + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ + env: { + RAG_PERSIST_RAW_QUERY_TEXT: false, + RAG_PERSIST_ANSWER_TEXT: false, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requireOpenAIEnv: () => undefined, + requireServerEnv: () => undefined, + })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client })); + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => ({ id: userId })), + unauthorizedResponse: () => new Response(JSON.stringify({ error: "Authentication required." }), { status: 401 }), + })); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("ingestion-admin rate limiting", () => { + it("returns 429 + Retry-After on the ingestion-quality route when the bucket is exhausted, before any table access", async () => { + const client = rateLimitedClient(); + mockRuntime(client); + const { GET } = await import("../src/app/api/ingestion/quality/route"); + + const response = await GET( + new Request("http://localhost/api/ingestion/quality", { + headers: { authorization: "Bearer valid-token" }, + }), + ); + const body = (await response.json()) as Record; + + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe("37"); + expect(body).toMatchObject({ code: "rate_limited" }); + expect(client.from).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ingestion-quality-route.test.ts b/tests/ingestion-quality-route.test.ts index 9398b06c0..0d774f6da 100644 --- a/tests/ingestion-quality-route.test.ts +++ b/tests/ingestion-quality-route.test.ts @@ -21,6 +21,23 @@ function chainResult(data: unknown[] | null, error: { message: string } | null = function clientWithTables(tables: Record) { return { from: vi.fn((table: string) => chainResult(tables[table] ?? [])), + // The route consults the ingestion_admin rate limiter before touching tables. + rpc: vi.fn(async (name: string) => + name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit" + ? { + data: [ + { + limited: false, + limit_value: 60, + remaining: 59, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + ], + error: null, + } + : { data: [], error: null }, + ), }; }