From 6863ecb87d82776a6d1d6f97cf69f4af026fd7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 03:11:34 +0200 Subject: [PATCH] fix(api): rate-limit feedback endpoint + drop overly broad cache invalidation (H5/H6 code-review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H5 — `POST /api/insights/feedback` had no rate limit. The unique index `(userId, recommendationId, recommendationText)` blocks exact replays but a malicious client could distort the daily aggregator's bucket slice by varying recommendationId/text per request. Added `checkRateLimit("insights-feedback:", 60, 60 * 60 * 1000)`. 60/h is generous given a comprehensive insight rarely has >10 recs. H6 — `` invalidated the entire `["insights"]` prefix on every thumbs click, which matches all 8 insights queries (comprehensive + 7 per-status). None of them depend on the feedback row. The 24h server cache absorbs the actual LLM round-trip but the network thrash + stale-cache refetch was waste. Dropped the invalidation; user-visible verdict state lives in localStorage + local React state. v1.4.17's prompt-version ratchet will scope its own invalidation when feedback feeds the next generation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/insights/feedback/route.ts | 20 +++++++++++++++++++ .../insights/recommendation-feedback.tsx | 11 +++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts index 9da640866..b0ba423f5 100644 --- a/src/app/api/insights/feedback/route.ts +++ b/src/app/api/insights/feedback/route.ts @@ -8,6 +8,7 @@ import { Prisma } from "@/generated/prisma/client"; import { resolveFeedbackAttribution } from "@/lib/ai/feedback-attribution"; import { withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommendation-feedback"; /** @@ -30,6 +31,25 @@ import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommend async function handlePost(request: NextRequest) { const { user } = await requireAuth(); + // v1.4.16 phase D reconcile (code-review H5 / security M1) — bound + // the per-user write rate. Without this, a single client can + // distort the daily aggregator's bucket slice by varying + // recommendationId/text per request (the unique index only catches + // exact replays). 60/h is generous: a comprehensive insight rarely + // has >10 recs, so ≥6 regenerations/h before throttle hits. + const rl = await checkRateLimit( + `insights-feedback:${user.id}`, + 60, + 60 * 60 * 1000, + ); + if (!rl.allowed) { + annotate({ + action: { name: "insights.recommendation.feedback" }, + meta: { outcome: "rate_limited" }, + }); + return apiError("Too many feedback submissions, try again later", 429); + } + const { data: rawBody, error } = await safeJson(request); if (error) return error; diff --git a/src/components/insights/recommendation-feedback.tsx b/src/components/insights/recommendation-feedback.tsx index 43a6dc4f4..fdf3dfa83 100644 --- a/src/components/insights/recommendation-feedback.tsx +++ b/src/components/insights/recommendation-feedback.tsx @@ -27,7 +27,7 @@ */ import { useMemo, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { ThumbsUp, ThumbsDown, Loader2, Check } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; @@ -132,7 +132,6 @@ export function RecommendationFeedback({ }: RecommendationFeedbackProps) { const { t } = useTranslations(); const { user } = useAuth(); - const queryClient = useQueryClient(); const [state, setState] = useState(() => { if (initialState) return initialState; @@ -189,7 +188,13 @@ export function RecommendationFeedback({ const verdict = result.helpful ? "up" : "down"; writeLocalState(user.id, recId, recText, verdict); setState(`submitted-${verdict}` as const); - queryClient.invalidateQueries({ queryKey: ["insights"] }); + // v1.4.16 phase D reconcile (code-review H6) — feedback doesn't + // affect the displayed insight content, so invalidating the entire + // ["insights"] tree (8 queries: comprehensive + 7 per-status) + // would force expensive refetches on a stale cache. The user- + // visible state lives in localStorage + the local React state + // above. The v1.4.17 ratchet that consumes feedback into the + // next generation will scope its own invalidation. }, onError: () => { // Optimistic-rollback: drop back to default so the user can