fix(coach): rate-limit the plan lifecycle mutations

Mirror the about-me management routes: the PATCH (confirm/update) and DELETE
on /api/coach/plans/[id] now apply a per-user mutation limit, closing the
consistency gap with the rest of the Coach management stack. Owner-scoped and
cheap, so the limit only caps a runaway client loop.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 11:07:11 +02:00
1 parent d78361596d
commit 0062e70d97
2 files changed
+39

No files matched your search

+30
View File
@@ -32,6 +32,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
@@ -39,11 +40,37 @@ interface RouteCtx {
params: Promise<{ id: string }>;
}
// Lifecycle mutations are cheap + owner-scoped; the limit only caps a runaway
// client loop, mirroring the about-me management routes.
const MUTATE_RATE_LIMIT = 40;
const MUTATE_WINDOW_MS = 60_000;
/** Apply the shared per-user mutation limit; returns a 429 response or null. */
async function enforceMutateLimit(
op: string,
userId: string,
): Promise<Response | null> {
const rl = await checkRateLimit(
`coach-plans:${op}:${userId}`,
MUTATE_RATE_LIMIT,
MUTATE_WINDOW_MS,
);
if (rl.allowed) return null;
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const limited = await enforceMutateLimit("patch", user.id);
if (limited) return limited;
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(req, {
@@ -162,6 +189,9 @@ export const DELETE = apiHandler(
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const limited = await enforceMutateLimit("delete", user.id);
if (limited) return limited;
const { id } = await ctx.params;
// `updateMany` scoped by BOTH id and userId, active-only: an unknown /
@@ -33,6 +33,15 @@ vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 39,
resetAt: Date.now() + 60_000,
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn((buf: Uint8Array) => {
const tag = Buffer.from(buf).toString("utf8");