diff --git a/docs/site-map.md b/docs/site-map.md index 6012d1938..5ef5c3ea9 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -559,6 +559,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/api/eval-cases` - Evaluation case data. Source: `src/app/api/eval-cases/route.ts`. - `/api/health` - Health check. Source: `src/app/api/health/route.ts`. - `/api/images/[id]/signed-url` - Private image signed URL. Source: `src/app/api/images/[id]/signed-url/route.ts`. +- `/api/images/signed-urls` - Route discovered from app directory Source: `src/app/api/images/signed-urls/route.ts`. - `/api/ingestion/batches` - Ingestion batch state. Source: `src/app/api/ingestion/batches/route.ts`. - `/api/ingestion/jobs` - Ingestion job collection. Source: `src/app/api/ingestion/jobs/route.ts`. - `/api/ingestion/jobs/[id]/retry` - Retry ingestion job. Source: `src/app/api/ingestion/jobs/[id]/retry/route.ts`. diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index 6689be3fb..082578f79 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -17,7 +17,7 @@ import { import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, seededContentCacheHeaders } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { registryCorpusEmbeddingEnabled } from "@/lib/registry-corpus"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -31,9 +31,11 @@ const differentialDetailQuerySchema = z.object({ }); function differentialResponse(payload: Record, init?: { status?: number }) { + const status = init?.status ?? 200; return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status, + // Seeded catalog content: only 200s are cacheable; 404s/errors stay no-store. + headers: status === 200 ? seededContentCacheHeaders : { "Cache-Control": "private, no-store" }, }); } diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts index 334ae96d7..8eabb8f33 100644 --- a/src/app/api/differentials/presentations/[slug]/route.ts +++ b/src/app/api/differentials/presentations/[slug]/route.ts @@ -16,7 +16,7 @@ import { import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, seededContentCacheHeaders } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { registryCorpusEmbeddingEnabled } from "@/lib/registry-corpus"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -25,9 +25,11 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; function differentialResponse(payload: Record, init?: { status?: number }) { + const status = init?.status ?? 200; return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status, + // Seeded catalog content: only 200s are cacheable; 404s/errors stay no-store. + headers: status === 200 ? seededContentCacheHeaders : { "Cache-Control": "private, no-store" }, }); } diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index d44da8707..d8e720941 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -23,7 +23,7 @@ import { type DifferentialRecordMatch, } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, seededContentCacheHeaders } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { registryCorpusEmbeddingEnabled } from "@/lib/registry-corpus"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -46,7 +46,8 @@ const differentialListQuerySchema = z.object({ }); function differentialResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); + // Seeded catalog content — cacheable per user (see seededContentCacheHeaders). + return NextResponse.json(payload, { headers: seededContentCacheHeaders }); } function recordMatchesPayload(matches: DifferentialRecordMatch[]) { diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index c5fac4d63..12d51466b 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -67,12 +67,23 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: .createSignedUrl(image.storage_path, signedUrlTtlSeconds); if (signed.error) throw new Error(signed.error.message); - return NextResponse.json({ - url: signed.data.signedUrl, - mimeType: image.mime_type, - caption: image.caption, - expiresAt: new Date(Date.now() + signedUrlTtlSeconds * 1000).toISOString(), - }); + return NextResponse.json( + { + url: signed.data.signedUrl, + mimeType: image.mime_type, + caption: image.caption, + expiresAt: new Date(Date.now() + signedUrlTtlSeconds * 1000).toISOString(), + }, + { + headers: { + // The signed URL lives 600s; letting the browser reuse this response for + // 540s spares a refetch on remount/reload while never serving a URL past + // its life. private + Vary keeps it per-user in shared browser profiles. + "Cache-Control": "private, max-age=540", + Vary: "Authorization", + }, + }, + ); } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); diff --git a/src/app/api/images/signed-urls/route.ts b/src/app/api/images/signed-urls/route.ts new file mode 100644 index 000000000..e3defa34a --- /dev/null +++ b/src/app/api/images/signed-urls/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { getDemoImage } from "@/lib/demo-data"; +import { env, isDemoMode } from "@/lib/env"; +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 { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; +import { parseJsonBody } from "@/lib/validation/body"; + +export const runtime = "nodejs"; + +// Batch variant of /api/images/[id]/signed-url: a document view with N inline +// images previously issued N API calls of 3 DB round trips each. This route +// resolves up to 50 images with one rate-limit consume, one document_images +// read, one owner-scoped documents read, and one batch storage signing call. +// +// Authorization is fail-closed PER ITEM: every requested id starts as null and +// only becomes a URL when its image row exists, its parent document passes +// withOwnerReadScope for this caller, and its generation metadata matches the +// document's committed index generation — identical checks to the single route. +// Unknown, unauthorized, and uncommitted ids are indistinguishable (all null). + +const signedUrlTtlSeconds = 60 * 10; + +const batchSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(50), +}); + +type SignedUrlItem = { + url: string; + mimeType: string | null; + caption: string | null; + expiresAt: string; +}; + +export async function POST(request: Request) { + try { + const { ids } = await parseJsonBody(request, batchSchema, "Invalid image batch request."); + const uniqueIds = [...new Set(ids)]; + const expiresAt = new Date(Date.now() + signedUrlTtlSeconds * 1000).toISOString(); + const items: Record = {}; + for (const id of uniqueIds) items[id] = null; + + if (isDemoMode()) { + for (const id of uniqueIds) { + const image = getDemoImage(id); + if (image) { + items[id] = { + url: image.signed_url ?? image.storage_path, + mimeType: image.mime_type ?? null, + caption: image.caption ?? null, + expiresAt, + }; + } + } + return NextResponse.json({ items, demoMode: true }); + } + + const supabase = createAdminClient(); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } + + const { data: images, error } = await supabase + .from("document_images") + .select("id,document_id,storage_path,mime_type,caption,metadata") + .in("id", uniqueIds); + if (error) throw new Error(error.message); + if (!images?.length) return NextResponse.json({ items }); + + const documentIds = [...new Set(images.map((image) => image.document_id))]; + const { data: documents, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").in("id", documentIds), + access.ownerId, + ); + if (documentError) throw new Error(documentError.message); + const documentById = new Map((documents ?? []).map((document) => [document.id, document])); + + const authorized = images.filter((image) => { + const document = documentById.get(image.document_id); + if (!document) return false; + return isCommittedGenerationMetadata({ + rowMetadata: image.metadata, + committedGeneration: committedIndexGeneration(document.metadata), + }); + }); + if (!authorized.length) return NextResponse.json({ items }); + + const paths = [...new Set(authorized.map((image) => image.storage_path))]; + const signed = await supabase.storage.from(env.SUPABASE_IMAGE_BUCKET).createSignedUrls(paths, signedUrlTtlSeconds); + if (signed.error) throw new Error(signed.error.message); + const urlByPath = new Map( + (signed.data ?? []).filter((row) => row.signedUrl && !row.error).map((row) => [row.path ?? "", row.signedUrl]), + ); + + for (const image of authorized) { + const url = urlByPath.get(image.storage_path); + if (url) { + items[image.id] = { + url, + mimeType: image.mime_type ?? null, + caption: image.caption ?? null, + expiresAt, + }; + } + } + return NextResponse.json({ items }); + } catch (error) { + if (error instanceof AuthenticationError) { + return unauthorizedResponse(); + } + if (error instanceof z.ZodError) { + return jsonError(error, 400); + } + if (error instanceof PublicApiError) { + return jsonError(error, error.status); + } + return jsonError(error); + } +} diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index 9db651ed1..406b1edcb 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -6,7 +6,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, seededContentCacheHeaders } from "@/lib/http"; import { getMedicationRecord } from "@/lib/medication-snapshot"; import { ensureMedicationsSeeded } from "@/lib/medication-seed"; import { @@ -24,9 +24,11 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; function medicationResponse(payload: Record, init?: { status?: number }) { + const status = init?.status ?? 200; return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status, + // Seeded catalog content: only 200s are cacheable; 404s/errors stay no-store. + headers: status === 200 ? seededContentCacheHeaders : { "Cache-Control": "private, no-store" }, }); } diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index cb996d8fa..75fc857f6 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -7,7 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, seededContentCacheHeaders } from "@/lib/http"; import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed"; import { medicationSourceStatus, @@ -62,7 +62,8 @@ function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] { } function medicationResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); + // Seeded catalog content — cacheable per user (see seededContentCacheHeaders). + return NextResponse.json(payload, { headers: seededContentCacheHeaders }); } function matchesPayload(matches: MedicationSearchMatch[]) { diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index 0797e19c1..ac42936be 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -7,7 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, seededContentCacheHeaders } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { getFormRecord } from "@/lib/forms"; import { @@ -31,9 +31,11 @@ const registryDetailQuerySchema = z.object({ }); function registryResponse(payload: Record, init?: { status?: number }) { + const status = init?.status ?? 200; return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status, + // Seeded catalog content: only 200s are cacheable; 404s/errors stay no-store. + headers: status === 200 ? seededContentCacheHeaders : { "Cache-Control": "private, no-store" }, }); } diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index 310c3c055..156f06645 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -7,7 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, seededContentCacheHeaders } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { rankFormRecords, formRecords } from "@/lib/forms"; import { @@ -47,7 +47,8 @@ function rankRecords(kind: RegistryRecordKind, records: ServiceRecord[], query: } function registryResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); + // Seeded catalog content — cacheable per user (see seededContentCacheHeaders). + return NextResponse.json(payload, { headers: seededContentCacheHeaders }); } function matchesPayload(matches: ServiceSearchMatch[]) { diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index d7ab5ef23..78955f78f 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -64,7 +64,12 @@ import { textMuted, toolbarButton, } from "@/components/ui-primitives"; -import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; +import { + clearCachedSignedUrl, + fetchImageSignedUrl, + getCachedSignedUrl, + setCachedSignedUrl, +} from "@/lib/signed-url-cache"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { formatClinicalDate } from "@/lib/source-metadata"; import { partitionViewerImages } from "@/lib/image-filtering"; @@ -317,15 +322,12 @@ function DocumentImage({ image }: { image: ImageRow }) { } let active = true; - fetch(endpoint, { headers: authorizationHeader }) - .then((response) => { - if (response.status === 401) markSessionExpired(); - return response.ok ? response.json() : null; - }) - .then((data) => { - if (active && data?.url) { - setCachedSignedUrl(endpoint, data); - setUrl(data.url); + // Coalesces with other images resolving in the same window into one batch + // request (and populates the shared signed-url cache). + fetchImageSignedUrl(image.id, { authorizationHeader, onUnauthorized: markSessionExpired }) + .then((payload) => { + if (active && payload?.url) { + setUrl(payload.url); setFailed(false); } else if (active) { setFailed(true); @@ -337,7 +339,7 @@ function DocumentImage({ image }: { image: ImageRow }) { return () => { active = false; }; - }, [attempt, authorizationHeader, endpoint, markSessionExpired, shouldLoad]); + }, [attempt, authorizationHeader, endpoint, image.id, markSessionExpired, shouldLoad]); function retryImage() { clearCachedSignedUrl(endpoint); diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 539445aa6..788eacf5f 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -39,7 +39,12 @@ import { } from "@/components/clinical-dashboard/display-text"; import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { SourcePreviewPopover } from "@/components/clinical-dashboard/source-preview-popover"; -import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; +import { + clearCachedSignedUrl, + fetchImageSignedUrl, + getCachedSignedUrl, + setCachedSignedUrl, +} from "@/lib/signed-url-cache"; import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; import { clinicalProseUsefulness } from "@/lib/source-text-sanitizer"; import { @@ -58,6 +63,14 @@ import type { VisualEvidenceCard, } from "@/lib/types"; +// Endpoints are built exclusively by evidence.ts as /api/images/{id}/signed-url; +// extracting the id lets SourceImage share the batched signed-url fetch. The +// null fallback keeps any unexpected endpoint shape on the original per-URL GET. +function imageIdFromSignedUrlEndpoint(endpoint: string) { + const match = /^\/api\/images\/([0-9a-fA-F-]{36})\/signed-url$/.exec(endpoint); + return match ? match[1] : null; +} + export const SourceImage = memo(function SourceImage({ endpoint, caption, @@ -78,15 +91,22 @@ export const SourceImage = memo(function SourceImage({ if (cached) return () => undefined; let active = true; - fetch(endpoint, { headers: authorizationHeader }) - .then((response) => { - if (response.status === 401) markSessionExpired(); - return response.ok ? response.json() : null; - }) - .then((data) => { - if (active && data?.url) { - setCachedSignedUrl(endpoint, data); - setUrl(data.url); + const imageId = imageIdFromSignedUrlEndpoint(endpoint); + const load = imageId + ? fetchImageSignedUrl(imageId, { authorizationHeader, onUnauthorized: markSessionExpired }) + : fetch(endpoint, { headers: authorizationHeader }) + .then((response) => { + if (response.status === 401) markSessionExpired(); + return response.ok ? response.json() : null; + }) + .then((data: { url?: string } | null) => { + if (data?.url) setCachedSignedUrl(endpoint, data as Parameters[1]); + return data; + }); + load + .then((payload) => { + if (active && payload?.url) { + setUrl(payload.url); setFailed(false); } else if (active) { setFailed(true); diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index 074812a5c..3b80f8d8c 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; +import { fetchJsonCached } from "@/lib/client-fetch-cache"; import type { MedicationRecord, MedicationSearchResult } from "@/lib/medications"; import { useAuthSession } from "@/lib/supabase/client"; @@ -34,12 +35,14 @@ type AsyncState = { error: string | null; }; -async function fetchJson(url: string, headers?: HeadersInit): Promise { - const response = await fetch(url, { cache: "no-store", headers }); - if (!response.ok) { - throw new Error(`Request failed (${response.status})`); +// Cached + deduped (short TTL): the prescribing workspace, cross-mode links, +// and detail pages request the same catalog concurrently on mount. +async function fetchJson(url: string, headers?: Record): Promise { + const result = await fetchJsonCached(url, { headers }); + if (result.status < 200 || result.status >= 300) { + throw new Error(`Request failed (${result.status})`); } - return (await response.json()) as T; + return result.payload as T; } export function useMedicationCatalog( diff --git a/src/lib/client-fetch-cache.ts b/src/lib/client-fetch-cache.ts new file mode 100644 index 000000000..e3b34a33a Binary files /dev/null and b/src/lib/client-fetch-cache.ts differ diff --git a/src/lib/http.ts b/src/lib/http.ts index 19f4ed2b1..e0d02bae9 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -19,6 +19,17 @@ export class PublicApiError extends Error { } } +// Cache headers for seeded, rarely-changing catalog content (registry records, +// medications, differentials). `private` keeps owner-scoped payloads out of +// shared caches; `Vary: Authorization` keys entries per user within one browser +// profile (requests carry a bearer header); stale-while-revalidate lets repeat +// navigations render the last copy while the browser refreshes it. Apply to +// 200 responses only — errors and 404s must never be cached. +export const seededContentCacheHeaders = { + "Cache-Control": "private, max-age=300, stale-while-revalidate=600", + Vary: "Authorization", +} as const; + function publicErrorMessage(error: unknown, status: number) { if (error instanceof PublicApiError) return error.message; if (error instanceof ZodError) return "Invalid request."; diff --git a/src/lib/signed-url-cache.ts b/src/lib/signed-url-cache.ts index 260e2f141..3a9c6f4af 100644 --- a/src/lib/signed-url-cache.ts +++ b/src/lib/signed-url-cache.ts @@ -63,3 +63,96 @@ export function clearCachedSignedUrl(endpoint: string) { export function clearSignedUrlCache() { signedUrlCache.clear(); } + +// --- Batched image signed-URL fetching ------------------------------------- +// Image components used to issue one GET /api/images/[id]/signed-url each; a +// document view with N inline images meant N API calls. Requests arriving +// within a short window are coalesced into one POST /api/images/signed-urls. +// Queues are keyed by the caller's authorization header so concurrent sessions +// (or an auth change mid-flight) can never mix credentials in one batch. + +export type SignedUrlFetchOptions = { + authorizationHeader?: Record; + onUnauthorized?: () => void; +}; + +type PendingImage = { + resolvers: Array<(payload: SignedUrlPayload | null) => void>; +}; + +type BatchQueue = { + images: Map; + timer: ReturnType | null; + options: SignedUrlFetchOptions; +}; + +// Matches the batch route's max ids per request. +const SIGNED_URL_BATCH_MAX_IDS = 50; +// Long enough to collect a render pass of visible images, short enough to be +// imperceptible next to the image download itself. +const SIGNED_URL_BATCH_WINDOW_MS = 25; + +const signedUrlBatchQueues = new Map(); + +export function imageSignedUrlEndpoint(imageId: string) { + return `/api/images/${imageId}/signed-url`; +} + +export function fetchImageSignedUrl( + imageId: string, + options: SignedUrlFetchOptions = {}, +): Promise { + const cached = getCachedSignedUrl(imageSignedUrlEndpoint(imageId)); + if (cached) return Promise.resolve(cached); + + const queueKey = JSON.stringify(options.authorizationHeader ?? {}); + let queue = signedUrlBatchQueues.get(queueKey); + if (!queue) { + queue = { images: new Map(), timer: null, options }; + signedUrlBatchQueues.set(queueKey, queue); + } + queue.options = options; + + return new Promise((resolve) => { + const pending = queue.images.get(imageId) ?? { resolvers: [] }; + pending.resolvers.push(resolve); + queue.images.set(imageId, pending); + if (queue.images.size >= SIGNED_URL_BATCH_MAX_IDS) { + void flushSignedUrlBatch(queueKey); + return; + } + queue.timer ??= setTimeout(() => void flushSignedUrlBatch(queueKey), SIGNED_URL_BATCH_WINDOW_MS); + }); +} + +async function flushSignedUrlBatch(queueKey: string) { + const queue = signedUrlBatchQueues.get(queueKey); + if (!queue) return; + signedUrlBatchQueues.delete(queueKey); + if (queue.timer) clearTimeout(queue.timer); + + const entries = [...queue.images.entries()]; + let itemsById: Record = {}; + try { + const response = await fetch("/api/images/signed-urls", { + method: "POST", + headers: { "Content-Type": "application/json", ...(queue.options.authorizationHeader ?? {}) }, + body: JSON.stringify({ ids: entries.map(([imageId]) => imageId) }), + }); + if (response.status === 401) queue.options.onUnauthorized?.(); + if (response.ok) { + const body = (await response.json()) as { items?: Record }; + itemsById = body.items ?? {}; + } + } catch { + // Network failure: every waiter resolves null and the components show + // their existing retry affordance. + } + + for (const [imageId, pending] of entries) { + const payload = itemsById[imageId] ?? null; + const resolved = payload?.url ? payload : null; + if (resolved) setCachedSignedUrl(imageSignedUrlEndpoint(imageId), resolved); + for (const resolve of pending.resolvers) resolve(resolved); + } +} diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts index 79599d5af..af3b28209 100644 --- a/src/lib/use-registry-records.ts +++ b/src/lib/use-registry-records.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; +import { fetchJsonCached } from "@/lib/client-fetch-cache"; import type { RegistryRecordKind, RegistrySourceStatus, RegistryValidationStatus } from "@/lib/registry-records"; import type { ServiceRecord } from "@/lib/services"; import { useAuthSession } from "@/lib/supabase/client"; @@ -73,8 +74,10 @@ export function useRegistryRecords( useEffect(() => { if (!enabled) return undefined; let active = true; - fetch(`/api/registry/records?kind=${kind}`, { headers: authorizationHeader }) - .then(async (response) => { + // Cached + deduped: several surfaces mount this hook for the same kind + // concurrently (dashboard cross-mode links, mode homes, favourites). + fetchJsonCached(`/api/registry/records?kind=${kind}`, { headers: authorizationHeader }) + .then((response) => { if (!active) return; if (response.status === 401) { // In real auth deployments the first request can race AuthProvider's @@ -90,11 +93,11 @@ export function useRegistryRecords( setState(recordsState("error", kind)); return; } - if (!response.ok) { + if (response.status < 200 || response.status >= 300) { setState(recordsState("error", kind)); return; } - const payload = (await response.json()) as { + const payload = (response.payload ?? {}) as { records?: ServiceRecord[]; total?: number; demoMode?: boolean; @@ -139,8 +142,10 @@ export function useRegistryRecord(kind: RegistryRecordKind, slug: string): Regis useEffect(() => { let active = true; - fetch(`/api/registry/records/${encodeURIComponent(slug)}?kind=${kind}`, { headers: authorizationHeader }) - .then(async (response) => { + fetchJsonCached(`/api/registry/records/${encodeURIComponent(slug)}?kind=${kind}`, { + headers: authorizationHeader, + }) + .then((response) => { if (!active) return; if (response.status === 401) { if (authStatus === "loading") return; @@ -156,11 +161,11 @@ export function useRegistryRecord(kind: RegistryRecordKind, slug: string): Regis setState({ status: "not_found", record: null, linkedDocuments: [], demoMode: false, governance: null }); return; } - if (!response.ok) { + if (response.status < 200 || response.status >= 300) { setState({ status: "error", record: null, linkedDocuments: [], demoMode: false, governance: null }); return; } - const payload = (await response.json()) as { + const payload = (response.payload ?? {}) as { record?: ServiceRecord; linkedDocuments?: Array<{ id: string; title: string; file_name: string; status: string }>; demoMode?: boolean; diff --git a/tests/client-fetch-cache.test.ts b/tests/client-fetch-cache.test.ts new file mode 100644 index 000000000..23f191d3b --- /dev/null +++ b/tests/client-fetch-cache.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { clearClientFetchCache, fetchJsonCached } from "../src/lib/client-fetch-cache"; + +function jsonResponse(payload: unknown, status = 200) { + return new Response(JSON.stringify(payload), { status }); +} + +afterEach(() => { + clearClientFetchCache(); + vi.unstubAllGlobals(); +}); + +describe("client fetch cache", () => { + it("dedupes concurrent identical requests onto one fetch", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ records: [1] })); + vi.stubGlobal("fetch", fetchMock); + + const [a, b] = await Promise.all([ + fetchJsonCached("/api/registry/records?kind=service"), + fetchJsonCached("/api/registry/records?kind=service"), + ]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(a.status).toBe(200); + expect(b.payload).toEqual({ records: [1] }); + }); + + it("serves repeat requests from cache within the TTL", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ records: [1] })); + vi.stubGlobal("fetch", fetchMock); + + await fetchJsonCached("/api/medications"); + const second = await fetchJsonCached("/api/medications"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(second.payload).toEqual({ records: [1] }); + }); + + it("refetches once the TTL passes", async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.fn(async () => jsonResponse({ records: [1] })); + vi.stubGlobal("fetch", fetchMock); + + await fetchJsonCached("/api/medications", { ttlMs: 1_000 }); + vi.setSystemTime(Date.now() + 2_000); + await fetchJsonCached("/api/medications", { ttlMs: 1_000 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("never caches non-OK responses (401 must re-resolve once auth loads)", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)) + .mockResolvedValueOnce(jsonResponse({ records: [1] })); + vi.stubGlobal("fetch", fetchMock); + + const first = await fetchJsonCached("/api/registry/records?kind=form"); + const second = await fetchJsonCached("/api/registry/records?kind=form"); + + expect(first.status).toBe(401); + expect(second.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("keys entries by authorization header so sessions never share payloads", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ records: [1] })); + vi.stubGlobal("fetch", fetchMock); + + await fetchJsonCached("/api/medications", { headers: { Authorization: "Bearer user-1" } }); + await fetchJsonCached("/api/medications", { headers: { Authorization: "Bearer user-2" } }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("clears entries by URL prefix", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ records: [1] })); + vi.stubGlobal("fetch", fetchMock); + + await fetchJsonCached("/api/medications"); + clearClientFetchCache("/api/medications"); + await fetchJsonCached("/api/medications"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index cee6d1230..8b9baa63e 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -204,6 +204,10 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { data: { signedUrl: `https://signed.local/${path}` }, error: null, })); + const createSignedUrls = vi.fn(async (paths: string[]) => ({ + data: paths.map((path) => ({ path, signedUrl: `https://signed.local/${path}`, error: null })), + error: null, + })); const remove = vi.fn( async ( ...args: [string[]] @@ -215,7 +219,7 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { return { data: [], error: null }; }, ); - const storageFrom = vi.fn(() => ({ upload, createSignedUrl, remove })); + const storageFrom = vi.fn(() => ({ upload, createSignedUrl, createSignedUrls, remove })); const getUser = vi.fn(async (receivedToken?: string) => receivedToken === token ? { data: { user: { id: userId } }, error: null } @@ -267,7 +271,7 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { }), rpc, storage: { from: storageFrom }, - storageMocks: { upload, createSignedUrl, remove, storageFrom }, + storageMocks: { upload, createSignedUrl, createSignedUrls, remove, storageFrom }, }; return client; @@ -861,6 +865,155 @@ describe("private document API access", () => { expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); + it("batch image signed URLs fail closed per item across mixed ownership", async () => { + const ownedImageId = "44444444-4444-4444-8444-444444444444"; + const foreignImageId = "55555555-5555-4555-8555-555555555555"; + const unknownImageId = "66666666-6666-4666-8666-666666666666"; + const client = createSupabaseMock((call) => { + if (call.table === "document_images") { + // The route queries .in("id", ids); return only the rows that exist. + return ok([ + { + id: ownedImageId, + document_id: documentId, + storage_path: `${userId}/images/${ownedImageId}.png`, + mime_type: "image/png", + caption: "Owned image", + metadata: { index_generation_id: "generation-a" }, + }, + { + id: foreignImageId, + document_id: otherDocumentId, + storage_path: `${otherUserId}/images/${foreignImageId}.png`, + mime_type: "image/png", + caption: "Other user's image", + metadata: { index_generation_id: "generation-a" }, + }, + ]); + } + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { + // Owner-scoped read only surfaces the caller's document. + return ok([{ id: documentId, metadata: { index_generation_id: "generation-a" } }]); + } + return ok(null); + }); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST( + authenticatedRequest("/api/images/signed-urls", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: [ownedImageId, foreignImageId, unknownImageId] }), + }), + ); + const body = (await payload(response)) as { items: Record }; + + expect(response.status).toBe(200); + expect(body.items[ownedImageId]?.url).toContain(`${userId}/images/${ownedImageId}.png`); + expect(body.items[foreignImageId]).toBeNull(); + expect(body.items[unknownImageId]).toBeNull(); + // Only the authorized path is ever signed. + expect(client.storageMocks.createSignedUrls).toHaveBeenCalledWith([`${userId}/images/${ownedImageId}.png`], 600); + }); + + it("batch image signed URLs reject uncommitted replacement generations per item", async () => { + const committedImageId = "44444444-4444-4444-8444-444444444444"; + const uncommittedImageId = "55555555-5555-4555-8555-555555555555"; + const client = createSupabaseMock((call) => { + if (call.table === "document_images") { + return ok([ + { + id: committedImageId, + document_id: documentId, + storage_path: `${userId}/images/${committedImageId}.png`, + mime_type: "image/png", + caption: "Committed image", + metadata: { index_generation_id: "generation-a" }, + }, + { + id: uncommittedImageId, + document_id: documentId, + storage_path: `${userId}/images/${uncommittedImageId}.png`, + mime_type: "image/png", + caption: "Replacement image", + metadata: { index_generation_id: "generation-new" }, + }, + ]); + } + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { + return ok([{ id: documentId, metadata: { index_generation_id: "generation-a" } }]); + } + return ok(null); + }); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST( + authenticatedRequest("/api/images/signed-urls", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: [committedImageId, uncommittedImageId] }), + }), + ); + const body = (await payload(response)) as { items: Record }; + + expect(response.status).toBe(200); + expect(body.items[committedImageId]?.url).toContain(committedImageId); + expect(body.items[uncommittedImageId]).toBeNull(); + expect(client.storageMocks.createSignedUrls).toHaveBeenCalledWith( + [`${userId}/images/${committedImageId}.png`], + 600, + ); + }); + + it("anonymous batch image signed URLs resolve only public documents", async () => { + const publicImageId = "44444444-4444-4444-8444-444444444444"; + const privateImageId = "55555555-5555-4555-8555-555555555555"; + const client = createSupabaseMock((call) => { + if (call.table === "document_images") { + return ok([ + { + id: publicImageId, + document_id: documentId, + storage_path: `public/images/${publicImageId}.png`, + mime_type: "image/png", + caption: "Public image", + metadata: { index_generation_id: "generation-a" }, + }, + { + id: privateImageId, + document_id: otherDocumentId, + storage_path: `${otherUserId}/images/${privateImageId}.png`, + mime_type: "image/png", + caption: "Private image", + metadata: { index_generation_id: "generation-a" }, + }, + ]); + } + if (call.table === "documents" && call.filters.some((f) => f.column === "owner_id" && f.value === null)) { + // Anonymous scope only surfaces the public document. + return ok([{ id: documentId, metadata: { index_generation_id: "generation-a" } }]); + } + return ok(null); + }); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST( + request("/api/images/signed-urls", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: [publicImageId, privateImageId] }), + }), + ); + const body = (await payload(response)) as { items: Record }; + + expect(response.status).toBe(200); + expect(body.items[publicImageId]?.url).toContain(`public/images/${publicImageId}.png`); + expect(body.items[privateImageId]).toBeNull(); + }); + it("rejects anonymous upload with setup guidance when public uploads are not configured", async () => { const client = createSupabaseMock(); mockRuntime(client); diff --git a/tests/signed-url-cache.test.ts b/tests/signed-url-cache.test.ts index d598174a0..664005f7b 100644 --- a/tests/signed-url-cache.test.ts +++ b/tests/signed-url-cache.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from "vitest"; import { clearCachedSignedUrl, clearSignedUrlCache, + fetchImageSignedUrl, getCachedSignedUrl, + imageSignedUrlEndpoint, setCachedSignedUrl, } from "../src/lib/signed-url-cache"; @@ -83,3 +85,102 @@ describe("signed URL cache", () => { expect(getCachedSignedUrl("/api/images/overflow/signed-url")).not.toBeNull(); }); }); + +describe("batched image signed-url fetch", () => { + const imageA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const imageB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + + function batchFetchMock(items?: Record) { + return vi.fn(async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { ids: string[] }; + const resolved = + items ?? Object.fromEntries(body.ids.map((id) => [id, { url: `/signed/${id}.png`, caption: null }])); + return new Response(JSON.stringify({ items: resolved }), { status: 200 }); + }); + } + + it("coalesces concurrent requests into one batch POST and populates the cache", async () => { + clearSignedUrlCache(); + vi.useFakeTimers(); + const fetchMock = batchFetchMock(); + vi.stubGlobal("fetch", fetchMock); + try { + const first = fetchImageSignedUrl(imageA); + const second = fetchImageSignedUrl(imageB); + const duplicate = fetchImageSignedUrl(imageA); + await vi.advanceTimersByTimeAsync(30); + const [a, b, aDuplicate] = await Promise.all([first, second, duplicate]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [unknown, RequestInit]; + expect(JSON.parse(String(init.body)).ids).toEqual([imageA, imageB]); + expect(a?.url).toBe(`/signed/${imageA}.png`); + expect(aDuplicate?.url).toBe(`/signed/${imageA}.png`); + expect(b?.url).toBe(`/signed/${imageB}.png`); + expect(getCachedSignedUrl(imageSignedUrlEndpoint(imageA))?.url).toBe(`/signed/${imageA}.png`); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("resolves null for ids the server withheld and does not cache them", async () => { + clearSignedUrlCache(); + vi.useFakeTimers(); + const fetchMock = batchFetchMock({ [imageA]: { url: `/signed/${imageA}.png` }, [imageB]: null }); + vi.stubGlobal("fetch", fetchMock); + try { + const first = fetchImageSignedUrl(imageA); + const second = fetchImageSignedUrl(imageB); + await vi.advanceTimersByTimeAsync(30); + + expect(await first).not.toBeNull(); + expect(await second).toBeNull(); + expect(getCachedSignedUrl(imageSignedUrlEndpoint(imageB))).toBeNull(); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("never mixes requests carrying different authorization headers into one batch", async () => { + clearSignedUrlCache(); + vi.useFakeTimers(); + const fetchMock = batchFetchMock(); + vi.stubGlobal("fetch", fetchMock); + try { + const first = fetchImageSignedUrl(imageA, { authorizationHeader: { Authorization: "Bearer user-1" } }); + const second = fetchImageSignedUrl(imageB, { authorizationHeader: { Authorization: "Bearer user-2" } }); + await vi.advanceTimersByTimeAsync(30); + await Promise.all([first, second]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const sentAuth = fetchMock.mock.calls.map( + ([, init]) => (init as RequestInit & { headers: Record }).headers.Authorization, + ); + expect(sentAuth.sort()).toEqual(["Bearer user-1", "Bearer user-2"]); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("reports 401 batches through onUnauthorized and resolves waiters null", async () => { + clearSignedUrlCache(); + vi.useFakeTimers(); + const onUnauthorized = vi.fn(); + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 })); + vi.stubGlobal("fetch", fetchMock); + try { + const pending = fetchImageSignedUrl(imageA, { onUnauthorized }); + await vi.advanceTimersByTimeAsync(30); + + expect(await pending).toBeNull(); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(getCachedSignedUrl(imageSignedUrlEndpoint(imageA))).toBeNull(); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); +});