Skip to content
34 changes: 34 additions & 0 deletions src/app/api/documents/[id]/labels/route.ts
Original file line number Diff line number Diff line change
@@ -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, PublicApiError } from "@/lib/http";
import { normalizeDocumentLabelForStorage } from "@/lib/document-tags";
Expand Down Expand Up @@ -110,6 +111,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

const rateLimit = await consumeApiRateLimit({
supabase,
ownerId: user.id,
bucket: "document_admin",
allowInMemoryFallbackOnUnavailable: true,
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many document administration requests. Retry shortly.", rateLimit);
}

await requireOwnedDocument(supabase, id, user.id);

const { data: existing, error: existingError } = await supabase
Expand Down Expand Up @@ -167,6 +179,17 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

const rateLimit = await consumeApiRateLimit({
supabase,
ownerId: user.id,
bucket: "document_admin",
allowInMemoryFallbackOnUnavailable: true,
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many document administration requests. Retry shortly.", rateLimit);
}

await requireOwnedDocument(supabase, id, user.id);

if ("action" in parsed) {
Expand Down Expand Up @@ -265,6 +288,17 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

const rateLimit = await consumeApiRateLimit({
supabase,
ownerId: user.id,
bucket: "document_admin",
allowInMemoryFallbackOnUnavailable: true,
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many document administration requests. Retry shortly.", rateLimit);
}

await requireOwnedDocument(supabase, id, user.id);

const { data: existing, error: existingError } = await supabase
Expand Down
23 changes: 23 additions & 0 deletions src/app/api/documents/[id]/table-facts/route.ts
Original file line number Diff line number Diff line change
@@ -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, PublicApiError } from "@/lib/http";
import { invalidateRagCachesForOwner } from "@/lib/rag";
Expand Down Expand Up @@ -44,6 +45,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

const rateLimit = await consumeApiRateLimit({
supabase,
ownerId: user.id,
bucket: "document_admin",
allowInMemoryFallbackOnUnavailable: true,
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many document administration requests. Retry shortly.", rateLimit);
}

const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id });
if (!document) {
return NextResponse.json({ error: "Document not found." }, { status: 404 });
Expand Down Expand Up @@ -78,6 +90,17 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

const rateLimit = await consumeApiRateLimit({
supabase,
ownerId: user.id,
bucket: "document_admin",
allowInMemoryFallbackOnUnavailable: true,
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many document administration requests. Retry shortly.", rateLimit);
}

const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id });
if (!document) {
return NextResponse.json({ error: "Document not found." }, { status: 404 });
Expand Down
12 changes: 12 additions & 0 deletions src/app/api/documents/bulk/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { normalizeDocumentLabelForStorage } from "@/lib/document-tags";
import { isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
Expand Down Expand Up @@ -129,6 +130,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: "document_admin",
allowInMemoryFallbackOnUnavailable: true,
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many document administration requests. Retry shortly.", rateLimit);
}

const ids = Array.from(new Set(parsed.documentIds));

const { data: documents, error: documentsError } = await supabase
Expand Down
4 changes: 4 additions & 0 deletions src/lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type ApiRateLimitBucket =
| "source_review"
| "answer_feedback"
| "registry"
| "document_admin"
| "ingestion_admin";

export type ApiRateLimitResult = {
Expand All @@ -61,6 +62,9 @@ const apiRateLimitDefaults = {
source_review: { limit: 30, windowSeconds: 60 },
answer_feedback: { limit: 30, windowSeconds: 60 },
registry: { limit: 120, windowSeconds: 60 },
// Authenticated owner document-admin writes (bulk metadata, label edits, table-fact review).
// Generous for interactive single-owner admin use, bounded against an abusive/compromised client.
document_admin: { limit: 60, 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 },
Expand Down
92 changes: 92 additions & 0 deletions tests/document-admin-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// Proves the `document_admin` bucket enforces a 429 on the authenticated admin
// write/read 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";
const documentId = "11111111-1111-4111-8111-111111111111";

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: 42,
reset_at: new Date(Date.now() + 42_000).toISOString(),
},
],
error: null,
};
}
return { data: [], error: null };
}),
};
}

function mockRuntime(client: ReturnType<typeof rateLimitedClient>) {
vi.resetModules();
vi.doMock("@/lib/env", () => ({
env: {
MAX_UPLOAD_MB: 150,
SUPABASE_DOCUMENT_BUCKET: "clinical-documents",
SUPABASE_IMAGE_BUCKET: "clinical-images",
RAG_SEARCH_CACHE_TTL_MS: 0,
RAG_SEARCH_CACHE_SIZE: 0,
RAG_ANSWER_CACHE_TTL_MS: 0,
RAG_ANSWER_CACHE_SIZE: 0,
RAG_AWAIT_QUERY_LOGS: false,
WORKER_STALE_AFTER_MINUTES: 10,
WORKER_MAX_ATTEMPTS: 3,
},
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 }),
}));
vi.doMock("@/lib/rag", () => ({
invalidateRagCachesForOwner: vi.fn(),
invalidateRagCachesForDocumentMutation: vi.fn(),
}));
}

afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});

describe("document-admin rate limiting", () => {
it("returns 429 + Retry-After when the admin bucket is exhausted, before any table access", async () => {
const client = rateLimitedClient();
mockRuntime(client);
const { GET } = await import("../src/app/api/documents/[id]/table-facts/route");

const response = await GET(
new Request(`http://localhost/api/documents/${documentId}/table-facts`, {
headers: { authorization: "Bearer valid-token" },
}),
{ params: Promise.resolve({ id: documentId }) },
);
const body = (await response.json()) as Record<string, unknown>;

expect(response.status).toBe(429);
expect(response.headers.get("retry-after")).toBe("42");
expect(body).toMatchObject({ code: "rate_limited" });
expect(client.from).not.toHaveBeenCalled();
});
});
19 changes: 19 additions & 0 deletions tests/document-mutation-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,25 @@ function createSupabaseMock(resolveCall: QueryResolver) {
calls.push(call);
return new QueryBuilder(call, resolveCall);
}),
rpc: vi.fn(async (name: string) => {
// The document-admin routes consult the rate limiter before touching tables.
// Return a not-limited row so these functional tests exercise the happy path.
if (name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit") {
return {
data: [
{
limited: false,
limit_value: 60,
remaining: 59,
retry_after_seconds: 60,
reset_at: new Date(Date.now() + 60_000).toISOString(),
},
],
error: null,
};
}
return { data: [], error: null };
}),
},
};
}
Expand Down