fix(ai): apply a per-user request-rate ceiling to the Coach chat endpoint

`enforceBudget()` catches the cost dimension — once a user has burned
the day's token allowance the route returns 429 with
`coach.budget.exceeded`. It does not catch the request-rate
dimension: a tight loop or a stolen session can issue 60 requests in
a second, all accepted up to the moment the budget arithmetic
finally rejects, while pinning Prisma connections and provider-side
SSE slots in the meantime.

A `checkRateLimit("coach-chat:${userId}", 20, 60_000)` call lands in
front of the budget gate. 20 requests per minute is well outside any
realistic interactive pace — a human doesn't type that fast and the
iOS client paces from user gestures — while keeping the budget logic
as the cost ceiling it was designed to be.

The 429 returns through `apiError()` for envelope parity with the
other rate-limited routes; an `insights.coach.rate-limited`
wide-event lands so operators can see when the limit fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-27 00:12:42 +02:00
1 parent 7dc9dbaa44
commit 53e7389854
1 file changed
+18 -1
+18 -1
View File
@@ -29,10 +29,11 @@
import { NextResponse, type NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -154,6 +155,22 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
}
const { conversationId, message, locale: bodyLocale, scope } = parsed.data;
// Per-user request-rate ceiling layered in front of the daily budget
// gate. The budget catches the cost dimension; this catches the
// request-rate dimension (a tight loop or a stolen session can burn
// the budget in seconds while pinning Prisma + provider slots before
// the budget arithmetic catches up). 20 / minute is well outside any
// realistic interactive use — a human can't type that fast, the iOS
// client paces from user gestures.
const rl = await checkRateLimit(`coach-chat:${userId}`, 20, 60 * 1000);
if (!rl.allowed) {
annotate({
action: { name: "insights.coach.rate-limited" },
meta: { userId, resetAt: rl.resetAt },
});
return apiError("Too many Coach requests, please wait a moment", 429);
}
await enforceBudget(userId);
const locale = await resolveServerLocale({