Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/site-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
8 changes: 5 additions & 3 deletions src/app/api/differentials/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,9 +31,11 @@ const differentialDetailQuerySchema = z.object({
});

function differentialResponse(payload: Record<string, unknown>, 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" },
});
}

Expand Down
8 changes: 5 additions & 3 deletions src/app/api/differentials/presentations/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,9 +25,11 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
export const runtime = "nodejs";

function differentialResponse(payload: Record<string, unknown>, 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" },
});
}

Expand Down
5 changes: 3 additions & 2 deletions src/app/api/differentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -46,7 +46,8 @@ const differentialListQuerySchema = z.object({
});

function differentialResponse(payload: Record<string, unknown>) {
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[]) {
Expand Down
23 changes: 17 additions & 6 deletions src/app/api/images/[id]/signed-url/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
124 changes: 124 additions & 0 deletions src/app/api/images/signed-urls/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, SignedUrlItem | null> = {};
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);
}
}
8 changes: 5 additions & 3 deletions src/app/api/medications/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -24,9 +24,11 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
export const runtime = "nodejs";

function medicationResponse(payload: Record<string, unknown>, 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" },
});
}

Expand Down
5 changes: 3 additions & 2 deletions src/app/api/medications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,7 +62,8 @@ function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] {
}

function medicationResponse(payload: Record<string, unknown>) {
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[]) {
Expand Down
8 changes: 5 additions & 3 deletions src/app/api/registry/records/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,9 +31,11 @@ const registryDetailQuerySchema = z.object({
});

function registryResponse(payload: Record<string, unknown>, 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" },
});
}

Expand Down
5 changes: 3 additions & 2 deletions src/app/api/registry/records/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -47,7 +47,8 @@ function rankRecords(kind: RegistryRecordKind, records: ServiceRecord[], query:
}

function registryResponse(payload: Record<string, unknown>) {
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[]) {
Expand Down
24 changes: 13 additions & 11 deletions src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading