From e6ae671f8d04fc7e1fe778004aeea97050b05221 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:36:13 +0800 Subject: [PATCH 1/2] Auto-hide answer support chips when content sits below on mobile --- src/components/ClinicalDashboard.tsx | 13 +- .../answer-result-surface.tsx | 27 ++++- .../clinical-dashboard/evidence-panels.tsx | 114 +++++++++++------- .../use-collapse-when-content-below.ts | 96 +++++++++++++++ 4 files changed, 195 insertions(+), 55 deletions(-) create mode 100644 src/components/clinical-dashboard/use-collapse-when-content-below.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..51b71177d 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -1355,8 +1355,8 @@ function SettingsClinicalContextStrip() { Private workspace{" "} - · WA{" "} - · No PHI + ┬╖ WA{" "} + ┬╖ No PHI ); @@ -3252,7 +3252,7 @@ export function ClinicalDashboard({ throw new Error("Search did not return usable results."); } - // M10: discard a stale response — a newer search owns the UI state. + // M10: discard a stale response ΓÇö a newer search owns the UI state. if (requestId === searchRequestSeqRef.current) { applySearchResult(successfulPayload, trimmedQuery); if (successfulPayload.kind === "answer") { @@ -3320,10 +3320,10 @@ export function ClinicalDashboard({ if (!autoRunSearch || !trimmedQuery || !canAutoRunMode || loading) return; if (searchMode === "answer" && !answerThreadBootstrapped) return; // Once an answer is on screen, composer edits are follow-up drafts and must - // only run on explicit submit — not on every query keystroke while run=1 + // only run on explicit submit ΓÇö not on every query keystroke while run=1 // keeps autoRunSearch enabled from the URL. if (searchMode === "answer" && answer) return; - // After reload, the URL query matches the restored latest turn — do not + // After reload, the URL query matches the restored latest turn ΓÇö do not // archive it again into a duplicate prior turn. if (searchMode === "answer" && latestAnswerQuery?.trim() === trimmedQuery) { autoRunSearchSignatureRef.current = `${searchMode}:${trimmedQuery}`; @@ -4489,6 +4489,7 @@ export function ClinicalDashboard({ followUpSuggestions={answerFollowUpSuggestions} onPickFollowUpSuggestion={handlePickFollowUpSuggestion} followUpSuggestionsDisabled={loading} + scrollContainerRef={mainRef} /> ) : null diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 581e10c6d..7eee483bf 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import Link from "next/link"; import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; @@ -6,6 +6,7 @@ import { ClipboardCheck, ExternalLink, Layers, ShieldAlert } from "lucide-react" import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; +import { useCollapseWhenContentBelow } from "@/components/clinical-dashboard/use-collapse-when-content-below"; import { NaturalLanguageAnswer, UserQuestionBubble } from "@/components/clinical-dashboard/answer-content"; import { AnswerSupportSummaryCard, @@ -61,6 +62,7 @@ export function StagedAnswerResultSurface({ followUpSuggestions, onPickFollowUpSuggestion, followUpSuggestionsDisabled = false, + scrollContainerRef, }: { answer: RagAnswer; query: string; @@ -86,6 +88,7 @@ export function StagedAnswerResultSurface({ followUpSuggestions?: string[]; onPickFollowUpSuggestion?: (suggestion: string) => void; followUpSuggestionsDisabled?: boolean; + scrollContainerRef?: RefObject; }) { const noteCount = clinicalNotesCount(answer); const showClinicalNotes = @@ -116,6 +119,8 @@ export function StagedAnswerResultSurface({ const clinicalNotesTriggerRef = useRef(null); const evidenceTriggerRef = useRef(null); const safetyTriggerRef = useRef(null); + const supportActionRowRef = useRef(null); + const belowContentSentinelRef = useRef(null); const copyQuotesTimerRef = useRef(null); useEffect(() => { return () => { @@ -180,8 +185,15 @@ export function StagedAnswerResultSurface({ weakEvidence, }); const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); - const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; + const evidenceTrustLabel = inlineEvidenceSummary.split(" ┬╖ ")[0] || "Review support"; const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); + const showSupportActionRow = showClinicalNotes || showEvidenceDrawer; + const collapseActionRow = useCollapseWhenContentBelow({ + containerRef: scrollContainerRef, + anchorRef: supportActionRowRef, + belowSentinelRef: belowContentSentinelRef, + disabled: !showSupportActionRow || !scrollContainerRef, + }); const showLayoutAside = Boolean(centralTable); return ( @@ -222,6 +234,8 @@ export function StagedAnswerResultSurface({ clinicalTriggerRef={clinicalNotesTriggerRef} evidenceTriggerRef={evidenceTriggerRef} safetyTriggerRef={safetyTriggerRef} + actionRowRef={supportActionRowRef} + collapseActionRow={collapseActionRow} safetyFindingsCount={safetyFindings.length} onOpenClinicalNotes={openClinicalNotes} onOpenEvidence={() => openEvidence(null)} @@ -236,6 +250,12 @@ export function StagedAnswerResultSurface({ disabled={followUpSuggestionsDisabled} /> ) : null} + {centralTable ? ( @@ -309,8 +329,9 @@ export function StagedAnswerResultSurface({ } - contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl" + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-3xl" bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + desktopBackdropClassName="sm:bg-black/50" returnFocusRef={evidenceTriggerRef} portal > diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index f15cad21f..d411f8503 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import Link from "next/link"; import { type RefObject, useState } from "react"; @@ -151,6 +151,8 @@ export function AnswerSupportSummaryCard({ clinicalTriggerRef, evidenceTriggerRef, safetyTriggerRef, + actionRowRef, + collapseActionRow = false, safetyFindingsCount = 0, onOpenClinicalNotes, onOpenEvidence, @@ -164,6 +166,8 @@ export function AnswerSupportSummaryCard({ clinicalTriggerRef?: RefObject; evidenceTriggerRef?: RefObject; safetyTriggerRef?: RefObject; + actionRowRef?: RefObject; + collapseActionRow?: boolean; safetyFindingsCount?: number; onOpenClinicalNotes: () => void; onOpenEvidence: () => void; @@ -238,48 +242,66 @@ export function AnswerSupportSummaryCard({ {supportRowCount > 0 ? (
- {clinicalAvailable ? ( - - ) : null} - {evidenceAvailable ? ( - - ) : null} + {clinicalAvailable ? ( + + ) : null} + {evidenceAvailable ? ( + + ) : null} +
+ ) : null} @@ -505,7 +527,7 @@ function clinicalNoteHeuristicTitle(value: string) { ) { return "Escalation triggers"; } - if (/\blithium levels?\b/.test(lower) && /\b(5\s*(?:to|-|–)\s*7|dose change|stable|days?)\b/.test(lower)) { + if (/\blithium levels?\b/.test(lower) && /\b(5\s*(?:to|-|ΓÇô)\s*7|dose change|stable|days?)\b/.test(lower)) { return "Lithium level timing"; } if (/\b(lithium level|serum lithium|trough level)\b/.test(lower)) return "Lithium level check"; @@ -540,7 +562,7 @@ function clinicalNoteTitleFromItem(item: string, section: ClinicalDetailSection, } return title; } - const dashIndex = text.search(/\s[-–]\s/); + const dashIndex = text.search(/\s[-ΓÇô]\s/); if (dashIndex > 8 && dashIndex < 54) return text.slice(0, dashIndex).trim(); if (section.items.length === 1 && section.title.length <= 42) return section.title; const words = text @@ -565,7 +587,7 @@ function clinicalNoteDetailFromItem(item: string, title: string) { if (lowerText.startsWith(`${normalizedTitle}:`)) { return sentenceCaseClinicalNoteDetail(text.slice(title.length + 1).trim()); } - if (lowerText.startsWith(`${normalizedTitle} -`) || lowerText.startsWith(`${normalizedTitle} –`)) { + if (lowerText.startsWith(`${normalizedTitle} -`) || lowerText.startsWith(`${normalizedTitle} ΓÇô`)) { return sentenceCaseClinicalNoteDetail(text.slice(title.length + 2).trim()); } if (text === title) return "Review linked source context before using this note."; @@ -1018,7 +1040,7 @@ export function compactEvidenceSummary( countParts.push(`${sourceCount} source${sourceCount === 1 ? "" : "s"}`); } - return [support, ...countParts].join(" · "); + return [support, ...countParts].join(" ┬╖ "); } export type EvidenceTabName = "Claims" | "Quotes" | "Tables" | "Images" | "Gaps"; @@ -1198,7 +1220,7 @@ function RenderModelSourceList({ {cleanDisplayTitle(source.title)}

- p.{source.page_number ?? "n/a"} · {sourceStatusLabel(metadata)} · {source.sourceStrength} support + p.{source.page_number ?? "n/a"} ┬╖ {sourceStatusLabel(metadata)} ┬╖ {source.sourceStrength} support

@@ -1319,7 +1341,7 @@ export const simpleClinicalTableProps = { function compactEvidenceCell(value: string | null | undefined, max = 140) { const text = value ? value.replace(/\s+/g, " ").trim() : ""; - return text.length > max ? `${text.slice(0, max - 1).trim()}…` : text; + return text.length > max ? `${text.slice(0, max - 1).trim()}ΓǪ` : text; } export function evidenceMapRowsFromRenderModel(renderModel: AnswerRenderModel): AnswerEvidenceMapRow[] { diff --git a/src/components/clinical-dashboard/use-collapse-when-content-below.ts b/src/components/clinical-dashboard/use-collapse-when-content-below.ts new file mode 100644 index 000000000..96d830117 --- /dev/null +++ b/src/components/clinical-dashboard/use-collapse-when-content-below.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore, type RefObject } from "react"; + +// Matches phoneSearchLayoutMediaQuery in master-search-header.tsx — the repo's +// phone/tablet seam. Collapse only ever runs below the sm breakpoint. +const phoneMediaQuery = "(max-width: 639px)"; + +// Reserved height for the fixed answer footer composer (pill + chips + safe area). +const defaultComposerInsetPx = 132; +// Small tolerance so the row hides just before it visually overlaps the composer. +const dockBandThresholdPx = 12; + +function subscribeToPhoneMedia(onChange: () => void) { + const media = window.matchMedia(phoneMediaQuery); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); +} + +function readPhoneMedia() { + return window.matchMedia(phoneMediaQuery).matches; +} + +function readPhoneMediaServer() { + return false; +} + +interface UseCollapseWhenContentBelowOptions { + /** Scroll container (`#main-content`). */ + containerRef?: RefObject; + /** The Clinical notes / Evidence row wrapper. */ + anchorRef?: RefObject; + /** Sentinel placed after all content below the action row. */ + belowSentinelRef?: RefObject; + /** Viewport inset reserved for the fixed bottom composer. */ + composerInsetPx?: number; + /** Disables the behavior entirely (state resets to expanded). */ + disabled?: boolean; +} + +/** + * On phones, collapses the answer support action row when it sits in the + * composer dock band while unscrolled content still lives below it (e.g. + * follow-up suggestions). Expands again at the true scroll bottom or when + * the row leaves the dock band. + */ +export function useCollapseWhenContentBelow({ + containerRef, + anchorRef, + belowSentinelRef, + composerInsetPx = defaultComposerInsetPx, + disabled = false, +}: UseCollapseWhenContentBelowOptions): boolean { + const [collapsed, setCollapsed] = useState(false); + const isPhone = useSyncExternalStore(subscribeToPhoneMedia, readPhoneMedia, readPhoneMediaServer); + const active = isPhone && !disabled; + + useEffect(() => { + if (!active) return; + + const container = containerRef?.current ?? null; + const anchor = anchorRef?.current ?? null; + const sentinel = belowSentinelRef?.current ?? null; + if (!container || !anchor || !sentinel) return; + + let frame = 0; + + const evaluate = () => { + frame = 0; + const dockLine = window.innerHeight - composerInsetPx; + const anchorRect = anchor.getBoundingClientRect(); + const sentinelRect = sentinel.getBoundingClientRect(); + const inDockBand = anchorRect.bottom >= dockLine - dockBandThresholdPx; + const contentBelow = sentinelRect.top > dockLine; + setCollapsed(inDockBand && contentBelow); + }; + + const schedule = () => { + if (frame) return; + frame = window.requestAnimationFrame(evaluate); + }; + + container.addEventListener("scroll", schedule, { passive: true }); + window.addEventListener("resize", schedule, { passive: true }); + schedule(); + + return () => { + container.removeEventListener("scroll", schedule); + window.removeEventListener("resize", schedule); + if (frame) window.cancelAnimationFrame(frame); + setCollapsed(false); + }; + }, [active, containerRef, anchorRef, belowSentinelRef, composerInsetPx]); + + return active && collapsed; +} From c7f2d68c5ac2efabd7d07644b5d422742d3c92a0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:11:36 +0800 Subject: [PATCH 2/2] fix(access): add document_read and document_upload rate-limit buckets --- src/app/api/documents/[id]/route.ts | 8 ++- src/app/api/documents/[id]/search/route.ts | 8 ++- .../api/documents/[id]/signed-url/route.ts | 8 ++- src/app/api/documents/route.ts | 8 ++- src/app/api/images/[id]/signed-url/route.ts | 8 ++- src/app/api/upload/route.ts | 19 +++++ src/lib/api-rate-limit.ts | 19 ++++- src/lib/public-api-access.ts | 19 +++++ tests/private-access-routes.test.ts | 69 +++++++++++++++++++ 9 files changed, 155 insertions(+), 11 deletions(-) diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 4f404a004..4ec7010ed 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import type { Json } from "@/lib/supabase/database.types"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { getDemoDocumentPayload } from "@/lib/demo-data"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; @@ -8,7 +9,7 @@ import { invalidateRagCachesForDocumentMutation } from "@/lib/rag"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { writeAuditLog } from "@/lib/audit"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; @@ -281,7 +282,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const access = await publicAccessContext(request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } const { data: document, error } = await withOwnerReadScope( supabase.from("documents").select("*").eq("id", id), access.ownerId, diff --git a/src/app/api/documents/[id]/search/route.ts b/src/app/api/documents/[id]/search/route.ts index d153f9859..80deb18ae 100644 --- a/src/app/api/documents/[id]/search/route.ts +++ b/src/app/api/documents/[id]/search/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { demoChunks, getDemoDocument } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRouteParams } from "@/lib/validation/params"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -182,7 +183,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentSearchParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const access = await publicAccessContext(request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } const { data: document, error: documentError } = await withOwnerReadScope( supabase.from("documents").select("id,metadata").eq("id", id), access.ownerId, diff --git a/src/app/api/documents/[id]/signed-url/route.ts b/src/app/api/documents/[id]/signed-url/route.ts index 5dd6dc8ea..7ecb96f47 100644 --- a/src/app/api/documents/[id]/signed-url/route.ts +++ b/src/app/api/documents/[id]/signed-url/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { getDemoDocument } from "@/lib/demo-data"; import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -32,7 +33,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid document id."); const supabase = createAdminClient(); - const access = await publicAccessContext(_request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(_request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } const { data: document, error } = await withOwnerReadScope( supabase.from("documents").select("storage_path,file_type").eq("id", id), access.ownerId, diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 8fa0958be..a9a31ce9d 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,11 +1,12 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { demoDocuments } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRequestQuery, queryBoolean, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; @@ -131,7 +132,10 @@ export async function GET(request: Request) { } = parseRequestQuery(request, documentListQuerySchema, "Invalid document list query."); const supabase = createAdminClient(); - const access = await publicAccessContext(request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } let query = withOwnerReadScope( supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" }), access.ownerId, diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index 9ca925d49..c5fac4d63 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { getDemoImage } from "@/lib/demo-data"; import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; @@ -7,7 +8,7 @@ import { jsonError, PublicApiError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -32,7 +33,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid image id."); const supabase = createAdminClient(); - const access = await publicAccessContext(_request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(_request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } const { data: image, error } = await supabase .from("document_images") .select("document_id,storage_path,mime_type,caption,metadata") diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 72ddb6023..893f36ba3 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -4,6 +4,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { env } from "@/lib/env"; import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; @@ -85,6 +90,20 @@ export async function POST(request: Request) { supabase = createAdminClient(); const adminSupabase = supabase; const user = await requireAuthenticatedUser(request, adminSupabase); + + const rateLimit = await consumeSubjectApiRateLimit({ + supabase: adminSupabase, + subject: { kind: "owner", ownerId: user.id }, + bucket: "document_upload", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + if (rateLimit.limited) { + return rateLimitJsonResponse( + "Upload is temporarily rate limited because too many requests were received. Retry shortly.", + rateLimit, + ); + } + const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 2a19a21ba..593cd9cfb 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -1,10 +1,23 @@ import { NextResponse } from "next/server"; +import { isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError } from "@/lib/http"; import type { RateLimitSubject } from "@/lib/public-api-access"; import type { createAdminClient } from "@/lib/supabase/admin"; +/** Prefer durable RPC rate limits; fall back to per-instance memory when the DB function is unavailable. */ +export function allowRateLimitInMemoryFallbackOnUnavailable() { + return isLocalNoAuthMode() || process.env.NODE_ENV === "production"; +} + export type ApiRateLimitBucket = - "answer" | "search" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry"; + | "answer" + | "search" + | "document_read" + | "document_upload" + | "document_summarize" + | "document_reindex" + | "bulk_reindex" + | "registry"; export type ApiRateLimitResult = { limited: boolean; @@ -17,6 +30,8 @@ export type ApiRateLimitResult = { const apiRateLimitDefaults = { answer: { limit: 30, windowSeconds: 60 }, search: { limit: 240, windowSeconds: 60 }, + document_read: { limit: 180, windowSeconds: 60 }, + document_upload: { limit: 12, windowSeconds: 60 }, document_summarize: { limit: 12, windowSeconds: 60 }, document_reindex: { limit: 6, windowSeconds: 60 }, bulk_reindex: { limit: 2, windowSeconds: 60 }, @@ -26,6 +41,8 @@ const apiRateLimitDefaults = { const anonymousApiRateLimitDefaults: Partial> = { answer: { limit: 6, windowSeconds: 60 }, search: { limit: 60, windowSeconds: 60 }, + document_read: { limit: 45, windowSeconds: 60 }, + document_upload: { limit: 3, windowSeconds: 60 }, }; type SupabaseAdmin = ReturnType; diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 8ae3473e1..0444aa5a2 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -1,5 +1,10 @@ import { createHash } from "node:crypto"; import type { createAdminClient } from "@/lib/supabase/admin"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + type ApiRateLimitResult, +} from "@/lib/api-rate-limit"; import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth"; type AdminClient = ReturnType; @@ -60,3 +65,17 @@ export async function publicAccessContext(request: Request, supabase: AdminClien rateLimitSubject: { kind: "anonymous", subjectKey: anonymousApiSubjectKey(request) } satisfies RateLimitSubject, }; } + +export async function enforceDocumentReadRateLimit( + request: Request, + supabase: AdminClient, +): Promise<{ access: Awaited>; rateLimit: ApiRateLimitResult }> { + const access = await publicAccessContext(request, supabase); + const rateLimit = await consumeSubjectApiRateLimit({ + supabase, + subject: access.rateLimitSubject, + bucket: "document_read", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + return { access, rateLimit }; +} diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 9d91205f7..709268ca8 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -459,6 +459,36 @@ describe("private document API access", () => { expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId }); }); + it("rate limits anonymous document read bursts", async () => { + const client = createSupabaseMock((call) => (call.table === "documents" ? ok([]) : ok([]))); + mockRuntime(client); + client.rpc.mockImplementation(async (name: string, args?: Record) => { + if (name === "consume_api_subject_rate_limit" && args?.p_bucket === "document_read") { + return { + data: [rateLimitRow({ limited: true, remaining: 0, retry_after_seconds: 30 })], + error: null, + }; + } + if (name === "consume_api_rate_limit") { + return { data: [rateLimitRow()], error: null }; + } + return ok([]); + }); + const { GET } = await import("../src/app/api/documents/route"); + + const response = await GET(request("/api/documents")); + + expect(response.status).toBe(429); + expect(await payload(response)).toMatchObject({ + error: "Document requests are rate limited. Try again shortly.", + retryAfterSeconds: 30, + }); + expect(client.rpc).toHaveBeenCalledWith( + "consume_api_subject_rate_limit", + expect.objectContaining({ p_bucket: "document_read" }), + ); + }); + it("filters authenticated document listing by owner", async () => { const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); @@ -722,6 +752,45 @@ describe("private document API access", () => { expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); + it("rate limits authenticated uploads before storage or database work", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + client.rpc.mockImplementation(async (name: string, args?: Record) => { + if (name === "consume_api_rate_limit" && args?.p_bucket === "document_upload") { + return { + data: [rateLimitRow({ limited: true, limit_value: 12, remaining: 0, retry_after_seconds: 45 })], + error: null, + }; + } + if (name === "consume_api_rate_limit") { + return { data: [rateLimitRow()], error: null }; + } + return ok([]); + }); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); + + const response = await POST( + authenticatedRequest("/api/upload", { + method: "POST", + body: formData, + }), + ); + + expect(response.status).toBe(429); + expect(await payload(response)).toMatchObject({ + error: "Upload is temporarily rate limited because too many requests were received. Retry shortly.", + retryAfterSeconds: 45, + }); + expect(client.rpc).toHaveBeenCalledWith( + "consume_api_rate_limit", + expect.objectContaining({ p_bucket: "document_upload", p_owner_id: userId }), + ); + expect(client.storageMocks.upload).not.toHaveBeenCalled(); + expect(client.from).not.toHaveBeenCalled(); + }); + it("stores uploaded documents with owner_id and a user-scoped storage path", async () => { const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "insert") {