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}
@@ -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") {