feat(documents): make the document-AI rate limit an operator setting with honest accounting

The document-AI actions (read/index, extract, suggest, summarise) shared a
hard-coded 6/hour bucket that lied twice: the 429 said "try again in a few
minutes" while the window is an hour, and several failure paths burned a
slot before any provider was dispatched.

- DOCUMENT_AI_LIMIT_PER_HOUR configures the shared per-user ceiling
  (default 6, clamped 1-1000), wired end to end: compose environment
  whitelist, .env.production.example, env manifest, operator docs.
- INSIGHTS_RATE_LIMIT_PER_HOUR joins the compose whitelist and manifest;
  it was documented but never listed, so an operator value could not reach
  the container.
- The 429 mirrors the bucket's reset instant into meta.retryAt (plus the
  existing X-RateLimit-Reset header); the client derives "try again in
  about N minutes" from it in all six locales, with a plural-aware key.
- A slot is consumed only when a request actually reaches the provider (or
  the local index/echo work): malformed bodies, unreadable originals,
  refused consent, and exhausted daily budgets refund the charged slot via
  a window-scoped refundRateLimit primitive. Consistent across all four
  routes sharing the bucket.
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 00:33:14 +02:00
1 parent 592d1e9aa8
commit 8e16df6894
26 files changed
+560 -160

No files matched your search

@@ -55,21 +55,21 @@ import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import {
checkDocumentAiRateLimit,
documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
refundDocumentAiSlot,
} from "@/lib/documents/ai-route-support";
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */
const EXTRACT_LIMIT_PER_HOUR = 6;
const EXTRACT_WINDOW_MS = 60 * 60 * 1000;
const TEXT_BODY_MAX_BYTES = 512 * 1024;
/** A loaded, owner-scoped, still-extractable document row. */
type LoadedDocument = {
id: string;
@@ -213,28 +213,20 @@ async function handleTextExtract(
surface: "insights",
});
const rl = await checkRateLimit(
`documents-inbound:${userId}`,
EXTRACT_LIMIT_PER_HOUR,
EXTRACT_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many extractions. Try again later.", 429, {
errorCode: "documents.inbound.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: TEXT_BODY_MAX_BYTES,
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
});
if (jsonError) return jsonError;
if (jsonError) {
await refundDocumentAiSlot(userId);
return jsonError;
}
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -248,6 +240,7 @@ async function handleTextExtract(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
@@ -315,23 +308,12 @@ async function handleVisionExtract(
surface: "insights",
});
const rl = await checkRateLimit(
`documents-inbound:${userId}`,
EXTRACT_LIMIT_PER_HOUR,
EXTRACT_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many extractions. Try again later.", 429, {
errorCode: "documents.inbound.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
// Decrypt the stored original and re-derive its MIME from the bytes (never
// trust a stored label for the provider call).
// trust a stored label for the provider call). Every miss below happens
// before the provider dispatch, so the charged slot goes back.
let buffer: Buffer;
try {
buffer = decryptDocumentContent(
@@ -339,6 +321,7 @@ async function handleVisionExtract(
document.contentCodec,
);
} catch {
await refundDocumentAiSlot(userId);
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -346,6 +329,7 @@ async function handleVisionExtract(
const mime = detectOcrMimeType(buffer);
if (!mime) {
await refundDocumentAiSlot(userId);
return apiError(
"This document can't be scanned. Use local OCR (text mode).",
422,
@@ -354,6 +338,7 @@ async function handleVisionExtract(
}
if (mime === "application/pdf" && !pick.pdfSupported) {
await refundDocumentAiSlot(userId);
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
422,
@@ -369,6 +354,7 @@ async function handleVisionExtract(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
@@ -5,6 +5,12 @@ import { NextRequest } from "next/server";
* Content-index route (Document vault P2). Pins: vision path transcribes then
* upserts the index; text path indexes posted OCR text with no provider;
* provider-gated on vision; only the index sibling is written (never facts).
*
* Bucket honesty: the route charges the shared document-AI bucket up front
* (so a 429 stays cheap) but a slot must reflect work actually performed —
* a 429 names the reset instant in `meta.retryAt`, a preparation failure
* before any provider dispatch refunds the charged slot, and a successful
* dispatch keeps it.
*/
vi.mock("@/lib/db", () => ({
@@ -47,7 +53,11 @@ vi.mock("@/lib/modules/gate", () => ({
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
rateLimitHeaders: () => ({}),
refundRateLimit: vi.fn().mockResolvedValue(undefined),
rateLimitHeaders: (result: { remaining?: number; resetAt?: number }) => ({
"X-RateLimit-Remaining": String(result.remaining ?? 0),
"X-RateLimit-Reset": new Date(result.resetAt ?? 0).toISOString(),
}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -69,7 +79,10 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { reserveBudget } from "@/lib/ai/coach/budget";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { decryptDocumentContent } from "@/lib/documents/store";
import { transcribeDocument } from "@/lib/documents/describe";
import { upsertContentIndex } from "@/lib/documents/content-index";
@@ -95,9 +108,30 @@ const textReq = (id: string, text: string) =>
},
);
const PICK = {
chain: [{ providerType: "anthropic", instance: {} }],
pick: {
entry: { providerType: "anthropic", instance: {} },
providerType: "anthropic",
pdfSupported: true,
},
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60 * 60 * 1000,
});
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
reserved: 1,
} as never);
vi.mocked(decryptDocumentContent).mockReturnValue(
Buffer.from([1, 2, 3]) as never,
);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
kind: "OTHER",
@@ -110,14 +144,7 @@ beforeEach(() => {
describe("POST /api/documents/inbound/[id]/index", () => {
it("vision: transcribes then upserts the index, never facts", async () => {
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
chain: [{ providerType: "anthropic", instance: {} }],
pick: {
entry: { providerType: "anthropic", instance: {} },
providerType: "anthropic",
pdfSupported: true,
},
} as never);
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(transcribeDocument).mockResolvedValue({
text: "hemoglobin 14.2 leukocytes normal",
} as never);
@@ -176,3 +203,60 @@ describe("POST /api/documents/inbound/[id]/index", () => {
expect(upsertContentIndex).not.toHaveBeenCalled();
});
});
describe("POST /api/documents/inbound/[id]/index — bucket honesty", () => {
beforeEach(() => {
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(transcribeDocument).mockResolvedValue({
text: "hemoglobin 14.2 leukocytes normal",
} as never);
});
it("returns 429 with the reset instant in meta.retryAt when the bucket is exhausted", async () => {
const resetAt = Date.now() + 47 * 60_000;
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt,
});
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(429);
const body = (await res.json()) as {
meta?: { errorCode?: string; retryAt?: string };
};
expect(body.meta?.errorCode).toBe("documents.inbound.rateLimited");
expect(body.meta?.retryAt).toBe(new Date(resetAt).toISOString());
expect(refundRateLimit).not.toHaveBeenCalled();
});
it("refunds the charged slot when input preparation fails before dispatch", async () => {
vi.mocked(decryptDocumentContent).mockImplementation(() => {
throw new Error("bad codec");
});
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(422);
expect(refundRateLimit).toHaveBeenCalledTimes(1);
expect(refundRateLimit).toHaveBeenCalledWith("documents-inbound:user-1");
expect(transcribeDocument).not.toHaveBeenCalled();
});
it("refunds the charged slot when the daily budget refuses before dispatch", async () => {
vi.mocked(reserveBudget).mockResolvedValue({
allowed: false,
reserved: 0,
} as never);
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(429);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("documents.inbound.budgetExceeded");
expect(refundRateLimit).toHaveBeenCalledTimes(1);
expect(transcribeDocument).not.toHaveBeenCalled();
});
it("keeps the slot once the provider was actually dispatched", async () => {
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(200);
expect(transcribeDocument).toHaveBeenCalledTimes(1);
expect(refundRateLimit).not.toHaveBeenCalled();
});
});
@@ -39,12 +39,12 @@ import {
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
import {
DOCUMENT_AI_BUCKET,
DOCUMENT_AI_LIMIT_PER_HOUR,
checkDocumentAiRateLimit,
documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
DOCUMENT_AI_WINDOW_MS,
loadOwnedDocument,
prepareVisionInput,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import { upsertContentIndex } from "@/lib/documents/content-index";
@@ -56,23 +56,12 @@ import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
function rateLimited(rl: Awaited<ReturnType<typeof checkRateLimit>>): Response {
const response = apiError("Too many requests. Try again later.", 429, {
errorCode: "documents.inbound.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -131,19 +120,19 @@ async function handleTextIndex(
});
}
const rl = await checkRateLimit(
`${DOCUMENT_AI_BUCKET}:${userId}`,
DOCUMENT_AI_LIMIT_PER_HOUR,
DOCUMENT_AI_WINDOW_MS,
);
if (!rl.allowed) return rateLimited(rl);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
});
if (jsonError) return jsonError;
if (jsonError) {
await refundDocumentAiSlot(userId);
return jsonError;
}
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -177,15 +166,13 @@ async function handleVisionIndex(
surface: "insights",
});
const rl = await checkRateLimit(
`${DOCUMENT_AI_BUCKET}:${userId}`,
DOCUMENT_AI_LIMIT_PER_HOUR,
DOCUMENT_AI_WINDOW_MS,
);
if (!rl.allowed) return rateLimited(rl);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
// Preparation failed before any provider dispatch — the slot goes back.
await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
@@ -213,6 +200,7 @@ async function handleVisionIndex(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
@@ -36,12 +36,12 @@ import {
type DocumentSuggestion,
} from "@/lib/documents/assist";
import {
DOCUMENT_AI_BUCKET,
DOCUMENT_AI_LIMIT_PER_HOUR,
checkDocumentAiRateLimit,
documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
DOCUMENT_AI_WINDOW_MS,
loadOwnedDocument,
prepareVisionInput,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
@@ -51,23 +51,12 @@ import {
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
function rateLimited(rl: Awaited<ReturnType<typeof checkRateLimit>>): Response {
const response = apiError("Too many requests. Try again later.", 429, {
errorCode: "documents.inbound.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -146,19 +135,19 @@ async function handleTextSuggest(
surface: "insights",
});
const rl = await checkRateLimit(
`${DOCUMENT_AI_BUCKET}:${userId}`,
DOCUMENT_AI_LIMIT_PER_HOUR,
DOCUMENT_AI_WINDOW_MS,
);
if (!rl.allowed) return rateLimited(rl);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
});
if (jsonError) return jsonError;
if (jsonError) {
await refundDocumentAiSlot(userId);
return jsonError;
}
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -172,6 +161,7 @@ async function handleTextSuggest(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
@@ -226,15 +216,13 @@ async function handleVisionSuggest(
surface: "insights",
});
const rl = await checkRateLimit(
`${DOCUMENT_AI_BUCKET}:${userId}`,
DOCUMENT_AI_LIMIT_PER_HOUR,
DOCUMENT_AI_WINDOW_MS,
);
if (!rl.allowed) return rateLimited(rl);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
// Preparation failed before any provider dispatch — the slot goes back.
await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
@@ -262,6 +250,7 @@ async function handleVisionSuggest(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
@@ -38,12 +38,12 @@ import {
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
import {
DOCUMENT_AI_BUCKET,
DOCUMENT_AI_LIMIT_PER_HOUR,
checkDocumentAiRateLimit,
documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
DOCUMENT_AI_WINDOW_MS,
loadOwnedDocument,
prepareVisionInput,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
@@ -62,7 +62,6 @@ import {
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
DOCUMENT_SUMMARY_MODES,
inboundTextExtractSchema,
@@ -76,16 +75,6 @@ export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
function rateLimited(rl: Awaited<ReturnType<typeof checkRateLimit>>): Response {
const response = apiError("Too many requests. Try again later.", 429, {
errorCode: "documents.inbound.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
type SummaryPersistenceTarget = {
userId: string;
documentId: string;
@@ -295,19 +284,19 @@ async function handleTextSummary(
});
}
const rl = await checkRateLimit(
`${DOCUMENT_AI_BUCKET}:${userId}`,
DOCUMENT_AI_LIMIT_PER_HOUR,
DOCUMENT_AI_WINDOW_MS,
);
if (!rl.allowed) return rateLimited(rl);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
});
if (jsonError) return jsonError;
if (jsonError) {
await refundDocumentAiSlot(userId);
return jsonError;
}
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -326,15 +315,22 @@ async function handleTextSummary(
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
await refundDocumentAiSlot(userId);
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
try {
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
} catch (err) {
// Refused before any dispatch — the slot goes back with the refusal.
await refundDocumentAiSlot(userId);
throw err;
}
const dateKey = buildDateKey();
const reservation = await reserveBudget(
@@ -344,6 +340,7 @@ async function handleTextSummary(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
@@ -394,15 +391,13 @@ async function handleVisionSummary(
surface: "insights",
});
const rl = await checkRateLimit(
`${DOCUMENT_AI_BUCKET}:${userId}`,
DOCUMENT_AI_LIMIT_PER_HOUR,
DOCUMENT_AI_WINDOW_MS,
);
if (!rl.allowed) return rateLimited(rl);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
// Preparation failed before any provider dispatch — the slot goes back.
await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
@@ -430,6 +425,7 @@ async function handleVisionSummary(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});