mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
d78361596d
commit
0062e70d97
2 files changed
+39
No files matched your search
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user