diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts index 462c63f09..ceacd6df3 100644 --- a/src/app/api/coach/plans/[id]/route.ts +++ b/src/app/api/coach/plans/[id]/route.ts @@ -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 { + 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 / diff --git a/src/app/api/coach/plans/__tests__/route.test.ts b/src/app/api/coach/plans/__tests__/route.test.ts index 6cfc6337d..53bfc41c1 100644 --- a/src/app/api/coach/plans/__tests__/route.test.ts +++ b/src/app/api/coach/plans/__tests__/route.test.ts @@ -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");