{!isOnline
? "Reconnect before uploading documents, refreshing source URLs, or generating answers."
- : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}
+ : isDeployedApp
+ ? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly."
+ : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}
);
diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts
index 170aac7f5..f952b2ef8 100644
--- a/tests/setup-status-route.test.ts
+++ b/tests/setup-status-route.test.ts
@@ -7,10 +7,13 @@ afterEach(() => {
});
describe("/api/setup-status", () => {
- it("requires auth for non-local production requests before returning setup posture", async () => {
+ it("returns setup posture for anonymous production requests without exposing secret values", async () => {
vi.stubEnv("NODE_ENV", "production");
- const getUser = vi.fn();
- const createAdminClient = vi.fn(() => ({ auth: { getUser } }));
+ const from = vi.fn(async () => ({ error: null, data: [], count: 0 }));
+ const createAdminClient = vi.fn(() => ({
+ from,
+ rpc: vi.fn(),
+ }));
vi.doMock("@/lib/env", () => ({
env: {
NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co",
@@ -24,16 +27,29 @@ describe("/api/setup-status", () => {
isLocalNoAuthMode: () => false,
}));
vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient }));
+ vi.doMock("@/lib/supabase/health", () => ({
+ probeSupabaseHealth: vi.fn(async () => ({ ok: true })),
+ isSupabaseUnavailableError: () => false,
+ formatSupabaseUnavailableError: (error: unknown) => String(error),
+ }));
+ vi.doMock("@/lib/supabase/project", () => ({
+ checkSupabaseProjectConfig: () => ({ status: "ready", detail: "Clinical KB Database target is configured." }),
+ formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.",
+ }));
const { GET } = await import("../src/app/api/setup-status/route");
const response = await GET(new Request("https://clinical.example/api/setup-status"));
const body = await response.json();
- expect(response.status).toBe(401);
- expect(body).toEqual({ error: "Authentication required." });
- expect(JSON.stringify(body)).not.toContain("OPENAI");
- expect(JSON.stringify(body)).not.toContain("Supabase");
- expect(createAdminClient).toHaveBeenCalledTimes(1);
- expect(getUser).not.toHaveBeenCalled();
+ expect(response.status).toBe(200);
+ expect(body).toMatchObject({
+ demoMode: false,
+ checks: expect.arrayContaining([
+ expect.objectContaining({ id: "env" }),
+ expect.objectContaining({ id: "openai" }),
+ ]),
+ });
+ expect(JSON.stringify(body)).not.toContain("service-role-key");
+ expect(JSON.stringify(body)).not.toContain("openai-key");
});
});
From a93b5b978f725b9aafd218e13548f15fb10e8801 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 5 Jul 2026 21:58:57 +0800
Subject: [PATCH 06/16] fix: harden public production access across API, UI,
and rate limits
---
src/app/api/answer/route.ts | 4 +-
src/app/api/answer/stream/route.ts | 4 +-
src/app/api/differentials/[slug]/route.ts | 8 +-
.../presentations/[slug]/route.ts | 8 +-
src/app/api/differentials/route.ts | 8 +-
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 | 58 ++++-
src/app/api/images/[id]/signed-url/route.ts | 8 +-
src/app/api/medications/[slug]/route.ts | 8 +-
src/app/api/medications/route.ts | 8 +-
src/app/api/registry/records/[slug]/route.ts | 8 +-
src/app/api/registry/records/route.ts | 8 +-
src/app/api/search/route.ts | 4 +-
src/app/api/upload/route.ts | 29 ++-
src/components/ClinicalDashboard.tsx | 45 ++--
src/components/DocumentViewer.tsx | 44 +++-
.../DocumentManagerPanel.tsx | 12 +-
.../document-search-results.tsx | 9 +-
.../medication-prescribing-workspace.tsx | 9 +-
src/components/forms/forms-home-page.tsx | 6 +-
.../forms/forms-search-results-page.tsx | 4 +-
src/components/registry-record-loader.tsx | 6 +-
.../services/services-home-page.tsx | 6 +-
src/lib/api-rate-limit.ts | 16 +-
src/lib/deployed-app.ts | 3 +
src/lib/env.ts | 10 +
src/lib/public-api-access.ts | 40 ++-
src/lib/supabase/auth.ts | 65 +++--
src/lib/use-registry-records.ts | 16 +-
...ten_search_document_chunks_owner_scope.sql | 72 ++++++
tests/api-rate-limit-fallback.test.ts | 23 ++
tests/api-validation-contract.test.ts | 18 +-
tests/private-access-routes.test.ts | 232 ++++++++++++++++--
tests/ui-smoke.spec.ts | 31 +++
tests/ui-tools.spec.ts | 16 +-
37 files changed, 704 insertions(+), 166 deletions(-)
create mode 100644 src/lib/deployed-app.ts
create mode 100644 supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql
create mode 100644 tests/api-rate-limit-fallback.test.ts
diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts
index 8d4f3b2a9..0084e60cd 100644
--- a/src/app/api/answer/route.ts
+++ b/src/app/api/answer/route.ts
@@ -4,7 +4,7 @@ import { demoAnswer } from "@/lib/demo-data";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { answerQuestionWithScope } from "@/lib/rag";
import { jsonError, PublicApiError } from "@/lib/http";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { publicAccessContext } from "@/lib/public-api-access";
import { classifyRagQuery } from "@/lib/clinical-search";
import { buildSmartRagApiPlan } from "@/lib/smart-rag-api";
@@ -70,7 +70,7 @@ export async function POST(request: Request) {
supabase,
subject: access.rateLimitSubject,
bucket: "answer",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit);
diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts
index 4741c2287..ef4fb0e54 100644
--- a/src/app/api/answer/stream/route.ts
+++ b/src/app/api/answer/stream/route.ts
@@ -2,7 +2,7 @@ import { z } from "zod";
import { demoAnswer } from "@/lib/demo-data";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { PublicApiError, jsonError } from "@/lib/http";
-import { consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit";
import { publicAccessContext } from "@/lib/public-api-access";
import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag";
import { classifyRagQuery } from "@/lib/clinical-search";
@@ -232,7 +232,7 @@ export async function POST(request: Request) {
supabase,
subject: access.rateLimitSubject,
bucket: "answer",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) return rateLimitStream(rateLimit);
diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts
index eaed5d200..08b67744e 100644
--- a/src/app/api/differentials/[slug]/route.ts
+++ b/src/app/api/differentials/[slug]/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import {
deriveGovernanceFromSnapshot,
normalizeDifferentialSlug,
@@ -14,7 +14,7 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe
import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
-import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access";
+import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRequestQuery } from "@/lib/validation/query";
@@ -63,7 +63,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
});
}
- if (!hasPublicApiAuthSignal(request)) {
+ if (!shouldResolvePublicCatalogAccess(request)) {
const snapshot = loadDifferentialSnapshot();
const governance = deriveGovernanceFromSnapshot(snapshot);
if (kind === "presentation") {
@@ -91,7 +91,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Differential requests are rate limited. Try again shortly.", rateLimit);
diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts
index 239579032..2c991a6c7 100644
--- a/src/app/api/differentials/presentations/[slug]/route.ts
+++ b/src/app/api/differentials/presentations/[slug]/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import {
deriveGovernanceFromSnapshot,
normalizeDifferentialSlug,
@@ -13,7 +13,7 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe
import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
-import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access";
+import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
@@ -53,7 +53,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
});
}
- if (!hasPublicApiAuthSignal(request)) {
+ if (!shouldResolvePublicCatalogAccess(request)) {
const snapshot = loadDifferentialSnapshot();
const workflow = getPresentationWorkflow(normalizedSlug);
if (!workflow) return notFoundResponse(normalizedSlug);
@@ -78,7 +78,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Differential requests are rate limited. Try again shortly.", rateLimit);
diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts
index 260e2a90a..b64d4e395 100644
--- a/src/app/api/differentials/route.ts
+++ b/src/app/api/differentials/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import {
deriveGovernanceFromSnapshot,
rowGovernance,
@@ -14,7 +14,7 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe
import { differentialRecords, searchDifferentialRecords, searchPresentationWorkflows } from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
-import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access";
+import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRequestQuery, queryInteger } from "@/lib/validation/query";
@@ -68,7 +68,7 @@ export async function GET(request: Request) {
});
}
- if (!hasPublicApiAuthSignal(request)) {
+ if (!shouldResolvePublicCatalogAccess(request)) {
return differentialResponse({
...publicDifferentialPayload(kind, q, limit),
publicAccess: true,
@@ -82,7 +82,7 @@ export async function GET(request: Request) {
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Differential requests are rate limited. Try again shortly.", rateLimit);
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..a9abec8cf 100644
--- a/src/app/api/documents/route.ts
+++ b/src/app/api/documents/route.ts
@@ -1,15 +1,33 @@
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";
+const PUBLIC_DOCUMENT_LIST_COLUMNS = [
+ "id",
+ "owner_id",
+ "title",
+ "description",
+ "file_name",
+ "file_type",
+ "file_size",
+ "status",
+ "page_count",
+ "chunk_count",
+ "image_count",
+ "metadata",
+ "created_at",
+ "updated_at",
+].join(",");
+
const DOCUMENT_LIST_COLUMNS = [
"id",
"owner_id",
@@ -32,6 +50,18 @@ const DOCUMENT_LIST_COLUMNS = [
"updated_at",
].join(",");
+const PUBLIC_LABEL_LIST_COLUMNS = [
+ "id",
+ "document_id",
+ "label",
+ "label_type",
+ "source",
+ "confidence",
+ "metadata",
+ "created_at",
+ "updated_at",
+].join(",");
+
const LABEL_LIST_COLUMNS = [
"id",
"document_id",
@@ -45,6 +75,14 @@ const LABEL_LIST_COLUMNS = [
"updated_at",
].join(",");
+const PUBLIC_SUMMARY_LIST_COLUMNS = [
+ "id",
+ "document_id",
+ "summary",
+ "clinical_specifics",
+ "generated_at",
+].join(",");
+
const SUMMARY_LIST_COLUMNS = [
"id",
"document_id",
@@ -131,9 +169,15 @@ 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);
+ }
+
+ const effectiveIncludeMeta = access.authenticated ? includeMeta : false;
+ const listColumns = access.authenticated ? DOCUMENT_LIST_COLUMNS : PUBLIC_DOCUMENT_LIST_COLUMNS;
let query = withOwnerReadScope(
- supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" }),
+ supabase.from("documents").select(listColumns, { count: "exact" }),
access.ownerId,
)
.order("created_at", { ascending: false })
@@ -162,13 +206,15 @@ export async function GET(request: Request) {
hasMore: count === null ? documents.length === limit : offset + documents.length < count,
};
- if (documentIds.length === 0 || !includeMeta) {
+ if (documentIds.length === 0 || !effectiveIncludeMeta) {
return documentsResponse({ documents, pagination }, indexing);
}
+ const labelColumns = access.authenticated ? LABEL_LIST_COLUMNS : PUBLIC_LABEL_LIST_COLUMNS;
+ const summaryColumns = access.authenticated ? SUMMARY_LIST_COLUMNS : PUBLIC_SUMMARY_LIST_COLUMNS;
const [labelsResult, summariesResult] = await Promise.all([
- supabase.from("document_labels").select(LABEL_LIST_COLUMNS).in("document_id", documentIds),
- supabase.from("document_summaries").select(SUMMARY_LIST_COLUMNS).in("document_id", documentIds),
+ supabase.from("document_labels").select(labelColumns).in("document_id", documentIds),
+ supabase.from("document_summaries").select(summaryColumns).in("document_id", documentIds),
]);
if (labelsResult.error) throw new Error(labelsResult.error.message);
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/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts
index a6853c1a9..80294619b 100644
--- a/src/app/api/medications/[slug]/route.ts
+++ b/src/app/api/medications/[slug]/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { getMedicationRecord } from "@/lib/medication-snapshot";
@@ -12,7 +12,7 @@ import {
rowToMedicationRecord,
type MedicationRecordRow,
} from "@/lib/medication-records";
-import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access";
+import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
@@ -56,7 +56,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
});
}
- if (!hasPublicApiAuthSignal(request)) {
+ if (!shouldResolvePublicCatalogAccess(request)) {
const payload = publicMedicationDetailPayload(normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
return medicationResponse({
@@ -72,7 +72,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Medication requests are rate limited. Try again shortly.", rateLimit);
diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts
index 7fc2cb999..32985604d 100644
--- a/src/app/api/medications/route.ts
+++ b/src/app/api/medications/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { defaultMedicationRecords, ensureMedicationsSeeded } from "@/lib/medication-seed";
@@ -13,7 +13,7 @@ import {
type MedicationRecordRow,
} from "@/lib/medication-records";
import { medicationToSearchResult, rankMedicationRecords, type MedicationSearchMatch } from "@/lib/medications";
-import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access";
+import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRequestQuery, queryInteger } from "@/lib/validation/query";
@@ -76,7 +76,7 @@ export async function GET(request: Request) {
});
}
- if (!hasPublicApiAuthSignal(request)) {
+ if (!shouldResolvePublicCatalogAccess(request)) {
return medicationResponse({
...publicMedicationPayload(q, limit),
publicAccess: true,
@@ -90,7 +90,7 @@ export async function GET(request: Request) {
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Medication requests are rate limited. Try again shortly.", rateLimit);
diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts
index a966ab37a..c32ac3684 100644
--- a/src/app/api/registry/records/[slug]/route.ts
+++ b/src/app/api/registry/records/[slug]/route.ts
@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import { z } from "zod";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
-import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access";
+import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { getFormRecord } from "@/lib/forms";
import {
deriveGovernanceColumns,
@@ -62,7 +62,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
});
}
- if (!hasPublicApiAuthSignal(request)) {
+ if (!shouldResolvePublicCatalogAccess(request)) {
const payload = publicRegistryDetailPayload(kind, normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
return registryResponse({
@@ -78,7 +78,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit);
diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts
index ab07badfb..e29d7c15d 100644
--- a/src/app/api/registry/records/route.ts
+++ b/src/app/api/registry/records/route.ts
@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import { z } from "zod";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
-import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access";
+import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { rankFormRecords, formRecords } from "@/lib/forms";
import {
deriveGovernanceColumns,
@@ -78,7 +78,7 @@ export async function GET(request: Request) {
});
}
- if (!hasPublicApiAuthSignal(request)) {
+ if (!shouldResolvePublicCatalogAccess(request)) {
return registryResponse({
...publicRegistryPayload(kind, q, limit),
publicAccess: true,
@@ -92,7 +92,7 @@ export async function GET(request: Request) {
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit);
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
index a0474e14d..ea99d7aa3 100644
--- a/src/app/api/search/route.ts
+++ b/src/app/api/search/route.ts
@@ -14,7 +14,7 @@ import { buildSmartRagApiPlan } from "@/lib/smart-rag-api";
import { SOURCE_ONLY_EMBEDDING_SKIP_REASON } from "@/lib/rag-provider";
import { createAdminClient } from "@/lib/supabase/admin";
import * as serverAuth from "@/lib/supabase/auth";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { publicAccessContext } from "@/lib/public-api-access";
import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode";
import { parseJsonBody } from "@/lib/validation/body";
@@ -893,7 +893,7 @@ export async function POST(request: Request) {
supabase,
subject: access.rateLimitSubject,
bucket: "search",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse(
diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts
index 72ddb6023..a2df002f9 100644
--- a/src/app/api/upload/route.ts
+++ b/src/app/api/upload/route.ts
@@ -2,13 +2,14 @@ import { randomUUID } from "node:crypto";
import { createHash } from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
-import { env } from "@/lib/env";
+import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env";
import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http";
import { logger } from "@/lib/logger";
import { writeAuditLog } from "@/lib/audit";
import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming";
import { createAdminClient } from "@/lib/supabase/admin";
-import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
+import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
+import { publicAccessContext } from "@/lib/public-api-access";
import { probeSupabaseHealth } from "@/lib/supabase/health";
import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data";
@@ -84,7 +85,11 @@ export async function POST(request: Request) {
try {
supabase = createAdminClient();
const adminSupabase = supabase;
- const user = await requireAuthenticatedUser(request, adminSupabase);
+ const access = await publicAccessContext(request, adminSupabase);
+ const uploadOwnerId = access.ownerId ?? (publicUploadsEnabled() ? publicWorkspaceOwnerId() : null);
+ if (!uploadOwnerId) {
+ return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 });
+ }
const formData = await request.formData().catch((cause) => {
throw new PublicApiError("Invalid upload form data.", 400, {
code: "invalid_form_data",
@@ -107,7 +112,7 @@ export async function POST(request: Request) {
const documentId = randomUUID();
const safeName = file.name.replace(/[^\w.\-() ]+/g, "_");
- const storagePath = `${user.id}/documents/${documentId}/${safeName}`;
+ const storagePath = `${uploadOwnerId}/documents/${documentId}/${safeName}`;
const buffer = Buffer.from(await file.arrayBuffer());
// The declared MIME type is client-supplied; verify the real byte signature
// before persisting a clinical document.
@@ -117,7 +122,7 @@ export async function POST(request: Request) {
const { data: duplicate, error: duplicateError } = await adminSupabase
.from("documents")
.select("id,title,file_name,status,page_count,chunk_count,image_count,created_at")
- .eq("owner_id", user.id)
+ .eq("owner_id", uploadOwnerId)
.eq("content_hash", contentHash)
.maybeSingle();
@@ -147,7 +152,7 @@ export async function POST(request: Request) {
};
const namePlan = await planDocumentName({
supabase: namingSupabase,
- ownerId: user.id,
+ ownerId: uploadOwnerId,
fileName: file.name,
requestedTitle: uploadMetadata.title,
contentHash,
@@ -160,7 +165,7 @@ export async function POST(request: Request) {
.from("documents")
.insert({
id: documentId,
- owner_id: user.id,
+ owner_id: uploadOwnerId,
title,
description,
file_name: file.name,
@@ -178,7 +183,7 @@ export async function POST(request: Request) {
review_date: null,
uploaded_at: uploadedAt,
indexed_at: null,
- uploaded_by: user.id,
+ uploaded_by: uploadOwnerId,
original_file_name: namePlan.originalFileName,
original_title: namePlan.originalTitle,
smart_title_base: namePlan.baseTitle,
@@ -202,7 +207,7 @@ export async function POST(request: Request) {
insertedDocumentOwnerId = null;
return duplicateUploadResponse({
supabase,
- ownerId: user.id,
+ ownerId: uploadOwnerId,
contentHash,
storagePath: uploadedPath,
});
@@ -210,7 +215,7 @@ export async function POST(request: Request) {
throw new Error(documentError.message);
}
insertedDocumentId = documentId;
- insertedDocumentOwnerId = user.id;
+ insertedDocumentOwnerId = uploadOwnerId;
const { data: job, error: jobError } = await supabase
.from("ingestion_jobs")
@@ -230,7 +235,7 @@ export async function POST(request: Request) {
.from("documents")
.delete()
.eq("id", documentId)
- .eq("owner_id", user.id);
+ .eq("owner_id", uploadOwnerId);
if (rollbackDocumentError) {
throw new Error(
`Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`,
@@ -242,7 +247,7 @@ export async function POST(request: Request) {
}
await writeAuditLog(supabase, {
- ownerId: user.id,
+ ownerId: uploadOwnerId,
action: "document_upload",
resourceType: "document",
resourceId: documentId,
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index f73ad11e2..aa6c1cb2d 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -53,7 +53,8 @@ import { type DocumentDeleteResult } from "@/components/DocumentManagementAction
import { useDismissableLayer } from "@/components/use-dismissable-layer";
import { extractSafetyFindings } from "@/lib/clinical-safety";
import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity";
-import { isLocalNoAuthMode } from "@/lib/env";
+import { isDeployedClinicalKb } from "@/lib/deployed-app";
+import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/env";
import {
appBackdrop,
answerSurface,
@@ -1751,7 +1752,11 @@ export function ClinicalDashboard({
process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks);
const canUsePrivateApis =
localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated");
- const canRunSearch = explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis;
+ const canUploadDocuments =
+ canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis);
+ const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady;
+ const canRunSearch =
+ explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch;
const closeDashboardTransientSurfaces = useCallback(
(except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => {
if (except !== "guide") setGuideOpen(false);
@@ -1932,20 +1937,25 @@ export function ClinicalDashboard({
const setupResponse = await fetch("/api/setup-status", { cache: "no-store" }).catch(() => null);
if (!setupResponse) {
- setApiUnavailable(true);
- setSetupWarning("The local API is unavailable.");
- return;
- }
-
- if (setupResponse.ok) {
+ if (isDeployedClinicalKb()) {
+ setSetupWarning("Setup status could not be loaded. You can still try search.");
+ } else {
+ setApiUnavailable(true);
+ setSetupWarning("The local API is unavailable.");
+ return;
+ }
+ } else if (setupResponse.ok) {
const payload = (await setupResponse.json()) as SetupStatusPayload;
setSetupChecks(payload.checks ?? fallbackSetupChecks);
nextDemoMode = Boolean(payload.demoMode);
routeIndexingActive = Boolean(payload.indexingActive);
routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.pollAfterMs);
if (nextDemoMode) setDemoMode(true);
+ } else if (isDeployedClinicalKb()) {
+ setSetupWarning("Setup status could not be loaded. You can still try search.");
} else {
setApiUnavailable(true);
+ return;
}
}
@@ -2499,11 +2509,13 @@ export function ClinicalDashboard({
function searchNetworkFailure(label: string) {
const offline = typeof navigator !== "undefined" && !navigator.onLine;
- const localOrigin = typeof window !== "undefined" ? window.location.origin : "the local Clinical KB server";
+ const origin = typeof window !== "undefined" ? window.location.origin : "Clinical KB";
return makeSearchError(
offline
? `${label} could not run because the browser is offline.`
- : `${label} could not reach Clinical KB at ${localOrigin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`,
+ : isDeployedClinicalKb()
+ ? `${label} could not reach Clinical KB at ${origin}. Check your connection and try again shortly.`
+ : `${label} could not reach Clinical KB at ${origin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`,
undefined,
true,
);
@@ -3582,8 +3594,8 @@ export function ClinicalDashboard({
);
- const showAuthPanel = !clientDemoMode && !canUsePrivateApis;
- const showDegradedNotice = !isOnline || apiUnavailable;
+ const showAuthPanel = false;
+ const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch);
const hasMobileBottomSearch = searchMode !== "answer";
const showDesktopHomeComposer =
!error &&
@@ -3614,7 +3626,6 @@ export function ClinicalDashboard({
const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted;
const differentialsCompareAddonActive =
searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim());
- const isDeployedApp = process.env.NODE_ENV === "production";
const renderDegradedNotice = () => (
{!isOnline
? "Reconnect before uploading documents, refreshing source URLs, or generating answers."
- : isDeployedApp
+ : isDeployedClinicalKb()
? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly."
: "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}
@@ -3661,7 +3672,7 @@ export function ClinicalDashboard({
{
id: "upload",
label: "Upload",
- summary: uploadReadOnlyMode || !canUsePrivateApis ? "Locked" : "Ready",
+ summary: uploadReadOnlyMode || !canUploadDocuments ? "Locked" : "Ready",
panelId: "dashboard-upload-section",
icon: UploadCloud,
},
@@ -4241,7 +4252,7 @@ export function ClinicalDashboard({
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index 8d875334f..10533849e 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -1923,12 +1923,22 @@ export function DocumentViewer({
const [viewerModeInitialized] = useState(true);
const generatedSummaryRef = useRef(null);
const { status: authStatus, isConfigured, authorizationHeader, markSessionExpired } = useAuthSession();
+ const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false);
const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true");
const localNoAuthMode = isLocalNoAuthMode();
const clientDemoMode = localNoAuthMode || serverDemoMode;
const canViewSourceDocuments = localProjectReady;
const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated");
+ useEffect(() => {
+ if (authStatus !== "loading") {
+ const resetId = window.setTimeout(() => setAuthLoadingTimedOut(false), 0);
+ return () => window.clearTimeout(resetId);
+ }
+ const timeoutId = window.setTimeout(() => setAuthLoadingTimedOut(true), 4_000);
+ return () => window.clearTimeout(timeoutId);
+ }, [authStatus]);
+
useEffect(() => {
if (typeof window === "undefined" || !viewerModeInitialized || hasExplicitPdfViewerMode) return;
@@ -2090,9 +2100,17 @@ export function DocumentViewer({
setTableFacts([]);
setChunks([]);
setIndexHealth(null);
- setViewerError(
- detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded.",
- );
+ const message =
+ detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded.";
+ if (!canUsePrivateApis && !clientDemoMode && message === "Document not found.") {
+ setViewerError(
+ isConfigured
+ ? "Sign in to open private source documents."
+ : "Supabase browser authentication is not configured for private source documents.",
+ );
+ } else {
+ setViewerError(message);
+ }
}
if (signedUrlResult.status === "fulfilled") {
@@ -2153,7 +2171,7 @@ export function DocumentViewer({
useEffect(() => {
const query = sourceSearch.trim();
- if (!canUsePrivateApis || query.length < 2) {
+ if (!canViewSourceDocuments || query.length < 2) {
const reset = window.setTimeout(() => {
setDocumentSearchResults([]);
setSearchingDocument(false);
@@ -2195,7 +2213,7 @@ export function DocumentViewer({
window.clearTimeout(timeout);
controller.abort();
};
- }, [authorizationHeader, canUsePrivateApis, clientDemoMode, documentId, markSessionExpired, sourceSearch]);
+ }, [authorizationHeader, canViewSourceDocuments, clientDemoMode, documentId, markSessionExpired, sourceSearch]);
useEffect(() => {
const updateOnline = () => setIsOnline(navigator.onLine);
@@ -2238,8 +2256,20 @@ export function DocumentViewer({
}
}
- const authViewerError = null;
- const effectiveLoadingDocument = loadingDocument;
+ const authViewerError =
+ !canUsePrivateApis &&
+ !clientDemoMode &&
+ !loadingDocument &&
+ !document &&
+ (authStatus !== "loading" || authLoadingTimedOut) &&
+ (viewerError === "Sign in to open private source documents." ||
+ viewerError === "Supabase browser authentication is not configured for private source documents.")
+ ? viewerError
+ : null;
+ const effectiveLoadingDocument =
+ !canUsePrivateApis && authStatus === "loading" && !authLoadingTimedOut && loadingDocument
+ ? true
+ : loadingDocument;
const effectiveViewerError = authViewerError ?? viewerError;
const viewerState = effectiveLoadingDocument
? "loading"
diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx
index b25fae026..35062b110 100644
--- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx
+++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx
@@ -188,7 +188,11 @@ export function UploadPanel({
return;
}
if (!canUpload) {
- changeStatus("Sign in before uploading private guideline files.");
+ changeStatus(
+ demoMode
+ ? demoUploadReadOnlyMessage
+ : "Uploads are unavailable until this public workspace is configured.",
+ );
return;
}
@@ -202,7 +206,7 @@ export function UploadPanel({
setUploading(true);
changeStatus(
files.length === 1
- ? "Uploading private document to Supabase Storage..."
+ ? "Uploading document to Supabase Storage..."
: `Uploading 1 of ${files.length}: ${files[0].name}`,
);
@@ -226,8 +230,8 @@ export function UploadPanel({
if (failures.length === 0) {
changeStatus(
files.length === 1
- ? "Successfully uploaded private document to storage queue."
- : `Successfully uploaded ${files.length} private documents.`,
+ ? "Successfully uploaded document to storage queue."
+ : `Successfully uploaded ${files.length} documents.`,
);
if (input) input.value = "";
onUploaded();
diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx
index 5efffa0ad..d14f25ff1 100644
--- a/src/components/clinical-dashboard/document-search-results.tsx
+++ b/src/components/clinical-dashboard/document-search-results.tsx
@@ -24,6 +24,7 @@ import {
import { DocumentTagCloud } from "@/components/DocumentTagCloud";
import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges";
+import { isDeployedClinicalKb } from "@/lib/deployed-app";
import { ModeHomeTemplate } from "@/components/mode-home-template";
import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band";
import { SafeBoldText } from "@/components/SafeBoldText";
@@ -726,7 +727,7 @@ function RecordRegistryNotice({ status, mode }: { status: RegistryRequestStatus;
status === "loading"
? { Icon: Loader2, spin: true, tone: "info" as const, text: `Loading your ${noun} registry...` }
: status === "unauthorized"
- ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Sign in to search your ${noun} registry.` }
+ ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Your session expired. Sign in again to search your private ${noun} registry.` }
: {
Icon: ShieldAlert,
spin: false,
@@ -834,9 +835,11 @@ export function DocumentSearchResultsPanel({
}
const unavailableMessage = apiUnavailable
- ? "The local API is unavailable. Check the app server before searching documents."
+ ? isDeployedClinicalKb()
+ ? "Clinical KB could not be reached. Check your connection and try again shortly."
+ : "The local API is unavailable. Check the app server before searching documents."
: authUnavailable
- ? "Sign in or enable local no-auth mode before listing private indexed documents."
+ ? "Your session expired. Sign in again to view private indexed documents."
: !realDataReady
? setupWarning || "Complete the search setup before using Documents mode."
: null;
diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
index 05596db09..597d4d459 100644
--- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
+++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
@@ -31,6 +31,7 @@ import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-
import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context";
import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog";
import { medicationMatchesCommandScopes } from "@/lib/search-command-surface";
+import { isDeployedClinicalKb } from "@/lib/deployed-app";
import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives";
type MedicationPrescribingWorkspaceProps = {
@@ -414,9 +415,13 @@ function StatusNotice({
}: Pick) {
if (realDataReady && !authUnavailable && !apiUnavailable && !setupWarning) return null;
const message = authUnavailable
- ? "Private medication search is waiting for sign-in."
+ ? isDeployedClinicalKb()
+ ? "Sign in to search your private medication library."
+ : "Private medication search is waiting for sign-in."
: apiUnavailable
- ? "Medication search is using the local mockup while the API is unavailable."
+ ? isDeployedClinicalKb()
+ ? "Medication search is temporarily unavailable. Try again shortly."
+ : "Medication search is using the local mockup while the API is unavailable."
: setupWarning || "Medication search setup is still warming up.";
return (
diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx
index eb3e040de..03a6495c1 100644
--- a/src/components/forms/forms-home-page.tsx
+++ b/src/components/forms/forms-home-page.tsx
@@ -90,10 +90,10 @@ export function FormsHomePage() {
) : registry.status === "unauthorized" ? (
) : registry.status === "error" ? (
}
- title="Sign in required"
- body={`Sign in to view this ${copy.noun}. Registry records are private to your workspace.`}
- action={{ href: "/", label: "Go to sign in" }}
+ title="Session expired"
+ body={`Your session expired. Sign in again to view your private ${copy.noun}. Public ${copy.noun} records remain available from search.`}
+ action={{ href: "/", label: "Open account setup" }}
/>
);
}
diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx
index 132432f57..6624fc2e6 100644
--- a/src/components/services/services-home-page.tsx
+++ b/src/components/services/services-home-page.tsx
@@ -92,10 +92,10 @@ export function ServicesHomePage() {
) : registry.status === "unauthorized" ? (
) : registry.status === "error" ? (
> = {
answer: { limit: 6, windowSeconds: 60 },
search: { limit: 60, windowSeconds: 60 },
+ document_read: { limit: 45, windowSeconds: 60 },
};
type SupabaseAdmin = ReturnType;
diff --git a/src/lib/deployed-app.ts b/src/lib/deployed-app.ts
new file mode 100644
index 000000000..82bb8f3a7
--- /dev/null
+++ b/src/lib/deployed-app.ts
@@ -0,0 +1,3 @@
+export function isDeployedClinicalKb() {
+ return process.env.NODE_ENV === "production";
+}
diff --git a/src/lib/env.ts b/src/lib/env.ts
index ac5d8dbf5..f6acba54f 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -11,6 +11,8 @@ const envSchema = z.object({
LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"),
LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(),
LOCAL_NO_AUTH_OWNER_ID: z.string().optional(),
+ PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(),
+ NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(),
OPENAI_API_KEY: z.string().optional(),
OPENAI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"),
// Must match the vector(N) dimension in supabase/schema.sql. Changing the embedding
@@ -192,3 +194,11 @@ export function isLocalNoAuthMode() {
return process.env.NODE_ENV !== "production" && (publicNoAuth || serverNoAuth);
}
+
+export function publicWorkspaceOwnerId() {
+ return env.PUBLIC_WORKSPACE_OWNER_ID?.trim() || null;
+}
+
+export function publicUploadsEnabled() {
+ return env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true";
+}
diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts
index 8ae3473e1..04a9da0ba 100644
--- a/src/lib/public-api-access.ts
+++ b/src/lib/public-api-access.ts
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import type { createAdminClient } from "@/lib/supabase/admin";
+import { consumeSubjectApiRateLimit, allowRateLimitInMemoryFallbackOnUnavailable, type ApiRateLimitResult } from "@/lib/api-rate-limit";
import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth";
type AdminClient = ReturnType;
@@ -25,22 +26,35 @@ export function anonymousApiSubjectKey(request: Request) {
return `anon:${createHash("sha256").update(source).digest("hex").slice(0, 32)}`;
}
-export function hasPublicApiAuthSignal(request: Request) {
- const authorization = request.headers.get("authorization") ?? "";
- if (/^Bearer\s+\S+/i.test(authorization)) return true;
-
+export function hasSessionCookieSignal(request: Request) {
const cookieHeader = request.headers.get("cookie") ?? "";
return cookieHeader.includes("sb-");
}
+export function hasBearerAuthAttempt(request: Request) {
+ const authorization = request.headers.get("authorization") ?? "";
+ return /^Bearer\s+\S+/i.test(authorization);
+}
+
+/** True when the request may carry a durable Supabase session (cookie), not a bare bearer attempt. */
+export function hasPublicApiAuthSignal(request: Request) {
+ return hasSessionCookieSignal(request);
+}
+
+/** Anonymous callers with no cookie or bearer skip auth resolution and rate limits on curated public catalogs. */
+export function shouldResolvePublicCatalogAccess(request: Request) {
+ return hasSessionCookieSignal(request) || hasBearerAuthAttempt(request);
+}
+
type OwnerScopedQuery = {
eq(column: string, value: unknown): T;
is(column: string, value: null): T;
+ or(filters: string): T;
};
-/** Scope document reads to the authenticated owner or public (owner_id IS NULL) rows. */
+/** Scope reads to public rows (owner_id IS NULL) and, when signed in, the caller's owned rows. */
export function withOwnerReadScope>(query: T, ownerId: string | undefined): T {
- if (ownerId) return query.eq("owner_id", ownerId);
+ if (ownerId) return query.or(`owner_id.eq.${ownerId},owner_id.is.null`);
return query.is("owner_id", null);
}
@@ -60,3 +74,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/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts
index 044384b07..3ecf6122c 100644
--- a/src/lib/supabase/auth.ts
+++ b/src/lib/supabase/auth.ts
@@ -31,12 +31,14 @@ function readCookies(cookieHeader: string | null): Map {
return cookies;
}
-function extractSessionAccessToken(request: Request): string | null {
+function extractBearerAccessToken(request: Request): string | null {
const authorization = request.headers.get("authorization") ?? "";
const match = authorization.match(/^Bearer\s+(.+)$/i);
const headerToken = match?.[1]?.trim();
- if (headerToken) return headerToken;
+ return headerToken || null;
+}
+function extractCookieSessionAccessToken(request: Request): string | null {
const cookies = readCookies(request.headers.get("cookie"));
const legacyAccessToken = cookies.get("sb-access-token")?.trim();
if (legacyAccessToken) return legacyAccessToken;
@@ -57,6 +59,19 @@ function extractSessionAccessToken(request: Request): string | null {
return null;
}
+function extractSessionAccessToken(request: Request): string | null {
+ return extractBearerAccessToken(request) ?? extractCookieSessionAccessToken(request);
+}
+
+async function getUserFromAccessToken(
+ supabase: AdminClient,
+ token: string,
+): Promise {
+ const { data, error } = await supabase.auth.getUser(token);
+ if (error || !data.user?.id) return null;
+ return { id: data.user.id };
+}
+
export class AuthenticationError extends Error {
constructor(message = "Authentication required.") {
super(message);
@@ -72,7 +87,7 @@ export function unauthorizedResponse(error?: AuthenticationError) {
/**
* Resolve the user from the `@supabase/ssr` cookie session. The
* `sb--auth-token` cookie it writes is base64-encoded (and chunked when
- * large), which `extractSessionAccessToken`'s plain-JSON parser cannot read, so
+ * large), which `extractCookieSessionAccessToken`'s plain-JSON parser cannot read, so
* this uses the ssr server client to decode + validate it. Returns null when
* the public env is absent or no `sb-` cookie is present.
*/
@@ -102,22 +117,28 @@ async function getUserFromRequestCookies(request: Request): Promise {
- // 1. Bearer token / legacy cookie (programmatic callers + current clients).
- const token = extractSessionAccessToken(request);
- if (token) {
- const { data, error } = await supabase.auth.getUser(token);
- if (!error && data.user?.id) {
- return { id: data.user.id };
- }
+async function resolveOptionalAuthenticatedUser(
+ request: Request,
+ supabase: AdminClient,
+): Promise {
+ const bearerToken = extractBearerAccessToken(request);
+ if (bearerToken) {
+ const bearerUser = await getUserFromAccessToken(supabase, bearerToken);
+ if (bearerUser) return bearerUser;
}
- // 2. @supabase/ssr cookie session (persistent cookie logins).
- const cookieUser = await getUserFromRequestCookies(request);
- if (cookieUser) {
- return cookieUser;
+ const cookieToken = extractCookieSessionAccessToken(request);
+ if (cookieToken && cookieToken !== bearerToken) {
+ const cookieTokenUser = await getUserFromAccessToken(supabase, cookieToken);
+ if (cookieTokenUser) return cookieTokenUser;
}
+ return getUserFromRequestCookies(request);
+}
+
+export async function requireAuthenticatedUser(request: Request, supabase: AdminClient): Promise {
+ const user = await resolveOptionalAuthenticatedUser(request, supabase);
+ if (user) return user;
throw new AuthenticationError();
}
@@ -125,14 +146,8 @@ export async function getOptionalAuthenticatedUser(
request: Request,
supabase: AdminClient,
): Promise {
- const token = extractSessionAccessToken(request);
- if (token) {
- const { data, error } = await supabase.auth.getUser(token);
- if (!error && data.user?.id) {
- return { id: data.user.id };
- }
- // Invalid or expired Bearer token: fall through to cookie session, then anonymous.
- }
-
- return getUserFromRequestCookies(request);
+ return resolveOptionalAuthenticatedUser(request, supabase);
}
+
+// Retained for callers that only need a single token string.
+export { extractSessionAccessToken };
diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts
index 0cf8a09da..79599d5af 100644
--- a/src/lib/use-registry-records.ts
+++ b/src/lib/use-registry-records.ts
@@ -82,8 +82,12 @@ export function useRegistryRecords(
// effect retries with a real header; never expire the session from an
// auth-loading 401. Demo/local API responses can still resolve fast.
if (authStatus === "loading") return;
- if (authStatus === "authenticated") markSessionExpired();
- setState(recordsState("unauthorized", kind));
+ if (authStatus === "authenticated") {
+ markSessionExpired();
+ setState(recordsState("unauthorized", kind));
+ return;
+ }
+ setState(recordsState("error", kind));
return;
}
if (!response.ok) {
@@ -140,8 +144,12 @@ export function useRegistryRecord(kind: RegistryRecordKind, slug: string): Regis
if (!active) return;
if (response.status === 401) {
if (authStatus === "loading") return;
- if (authStatus === "authenticated") markSessionExpired();
- setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null });
+ if (authStatus === "authenticated") {
+ markSessionExpired();
+ setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null });
+ return;
+ }
+ setState({ status: "error", record: null, linkedDocuments: [], demoMode: false, governance: null });
return;
}
if (response.status === 404) {
diff --git a/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql
new file mode 100644
index 000000000..473fdb90c
--- /dev/null
+++ b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql
@@ -0,0 +1,72 @@
+-- Tighten search_document_chunks owner scoping so null p_owner_id only matches public
+-- documents (owner_id IS NULL) and authenticated callers can search both owned and public
+-- documents without matching other owners' private rows.
+
+create or replace function public.search_document_chunks(
+ p_document_id uuid,
+ p_query text,
+ match_count integer default 20,
+ p_owner_id uuid default null
+)
+returns table (
+ id uuid,
+ page_number integer,
+ chunk_index integer,
+ section_heading text,
+ content text,
+ image_ids uuid[],
+ text_rank real,
+ trigram_score real
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with normalized as (
+ select
+ websearch_to_tsquery('english', coalesce(p_query, '')) as query_tsv,
+ lower(trim(coalesce(p_query, ''))) as query_text
+ ),
+ tokens as (
+ select distinct token
+ from normalized,
+ lateral regexp_split_to_table(normalized.query_text, '\s+') as token
+ where length(token) >= 3
+ )
+ select
+ c.id,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.image_ids,
+ ts_rank_cd(c.search_tsv, normalized.query_tsv)::real as text_rank,
+ similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text)::real as trigram_score
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ cross join normalized
+ where c.document_id = p_document_id
+ and d.status = 'indexed'
+ and (
+ (p_owner_id is null and d.owner_id is null)
+ or (p_owner_id is not null and (d.owner_id is null or d.owner_id = p_owner_id))
+ )
+ and (
+ c.search_tsv @@ normalized.query_tsv
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) % normalized.query_text
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || normalized.query_text || '%'
+ or exists (
+ select 1
+ from tokens t
+ where lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || t.token || '%'
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) % t.token
+ )
+ )
+ order by
+ ts_rank_cd(c.search_tsv, normalized.query_tsv) desc,
+ similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text) desc,
+ c.chunk_index asc
+ limit least(greatest(match_count, 1), 80);
+$$;
+
+grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role;
diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts
new file mode 100644
index 000000000..c25bb36c5
--- /dev/null
+++ b/tests/api-rate-limit-fallback.test.ts
@@ -0,0 +1,23 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+});
+
+describe("allowRateLimitInMemoryFallbackOnUnavailable", () => {
+ it("enables fallback for production deployments", async () => {
+ vi.stubEnv("NODE_ENV", "production");
+ const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit");
+ expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true);
+ });
+
+ it("enables fallback for local no-auth development", async () => {
+ vi.stubEnv("NODE_ENV", "development");
+ vi.doMock("@/lib/env", () => ({
+ isLocalNoAuthMode: () => true,
+ }));
+ const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit");
+ expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true);
+ });
+});
diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts
index 5d68487d8..a23c87c0e 100644
--- a/tests/api-validation-contract.test.ts
+++ b/tests/api-validation-contract.test.ts
@@ -147,7 +147,23 @@ function createSupabaseMock(resolve: QueryResolver = () => ok([])) {
calls.push(call);
return new QueryBuilder(call, resolve);
}),
- rpc: vi.fn(async () => ok([])),
+ rpc: vi.fn(async (name: string) => {
+ if (name === "consume_api_subject_rate_limit" || name === "consume_api_rate_limit") {
+ return {
+ data: [
+ {
+ limited: false,
+ limit_value: 100,
+ remaining: 99,
+ retry_after_seconds: 60,
+ reset_at: new Date(Date.now() + 60_000).toISOString(),
+ },
+ ],
+ error: null,
+ };
+ }
+ return ok([]);
+ }),
storage: { from: storageFrom },
storageMocks: { upload, remove, createSignedUrl, storageFrom },
};
diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts
index 9d91205f7..958a05e92 100644
--- a/tests/private-access-routes.test.ts
+++ b/tests/private-access-routes.test.ts
@@ -276,7 +276,14 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) {
function mockRuntime(
client: ReturnType,
ragMock?: Record,
- options: { localNoAuth?: boolean; localOwnerEmail?: string; providerMode?: string; openAiKey?: string } = {},
+ options: {
+ localNoAuth?: boolean;
+ localOwnerEmail?: string;
+ providerMode?: string;
+ openAiKey?: string;
+ publicUploadsEnabled?: boolean;
+ publicWorkspaceOwnerId?: string;
+ } = {},
) {
vi.resetModules();
vi.doUnmock("@/lib/rag");
@@ -298,11 +305,15 @@ function mockRuntime(
OPENAI_API_KEY: options.openAiKey ?? "sk-test",
RAG_PROVIDER_MODE: options.providerMode ?? "auto",
LOCAL_NO_AUTH_OWNER_EMAIL: options.localOwnerEmail,
+ PUBLIC_WORKSPACE_OWNER_ID: options.publicWorkspaceOwnerId,
+ NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: options.publicUploadsEnabled ? "true" : undefined,
WORKER_STALE_AFTER_MINUTES: 10,
WORKER_MAX_ATTEMPTS: 3,
},
isDemoMode: () => false,
isLocalNoAuthMode: () => Boolean(options.localNoAuth),
+ publicWorkspaceOwnerId: () => options.publicWorkspaceOwnerId ?? null,
+ publicUploadsEnabled: () => Boolean(options.publicUploadsEnabled),
requireOpenAIEnv: () => undefined,
requireServerEnv: () => undefined,
}));
@@ -322,6 +333,15 @@ function localPortRequest(port: number, path: string, init?: RequestInit) {
return new Request(`http://localhost:${port}${path}`, init);
}
+function matchesOwnerReadScope(call: QueryCall, ownerId?: string | null) {
+ if (ownerId === undefined || ownerId === null) {
+ return call.filters.some((filter) => filter.column === "owner_id" && filter.value === null);
+ }
+ return call.orFilters.some(
+ (filter) => filter.includes(`owner_id.eq.${ownerId}`) && filter.includes("owner_id.is.null"),
+ );
+}
+
function authenticatedRequest(path: string, init?: RequestInit) {
return request(path, {
...init,
@@ -386,7 +406,7 @@ describe("private document API access", () => {
const body = await payload(response);
expect(response.status).toBe(200);
- expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
+ expect(body.documents).toEqual(documents);
expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null });
expect(client.auth.getUser).not.toHaveBeenCalled();
});
@@ -416,7 +436,8 @@ describe("private document API access", () => {
}),
);
- expect(response.status).toBe(401);
+ expect(response.status).toBe(503);
+ expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." });
expect(client.auth.getUser).not.toHaveBeenCalled();
expect(client.from).not.toHaveBeenCalled();
});
@@ -438,7 +459,8 @@ describe("private document API access", () => {
}),
);
- expect(response.status).toBe(401);
+ expect(response.status).toBe(503);
+ expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." });
expect(client.auth.getUser).not.toHaveBeenCalled();
expect(client.from).not.toHaveBeenCalled();
});
@@ -459,7 +481,7 @@ describe("private document API access", () => {
expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId });
});
- it("filters authenticated document listing by owner", async () => {
+ it("filters authenticated document listing by owner and public rows", async () => {
const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }];
const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([])));
mockRuntime(client);
@@ -471,9 +493,8 @@ describe("private document API access", () => {
expect(response.status).toBe(200);
expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
expect(body.pagination).toMatchObject({ limit: 100, offset: 0, nextOffset: 1, hasMore: false });
- expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId });
- expect(client.calls[0].selected).toContain("id,owner_id,title");
- expect(client.calls[0].selected).not.toBe("*");
+ expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`);
+ expect(client.calls[0].selected).toContain("storage_path");
expect(client.calls[0].range).toEqual({ from: 0, to: 99 });
});
@@ -489,7 +510,7 @@ describe("private document API access", () => {
expect(response.status).toBe(200);
expect(client.auth.getUser).toHaveBeenCalledWith(token);
expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
- expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId });
+ expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`);
});
it("accepts Supabase auth token cookies for private document access", async () => {
@@ -504,7 +525,124 @@ describe("private document API access", () => {
expect(response.status).toBe(200);
expect(client.auth.getUser).toHaveBeenCalledWith(token);
expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
- expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId });
+ expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`);
+ });
+
+ it("allows authenticated users to read public document detail", async () => {
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
+ return ok({
+ id: documentId,
+ owner_id: null,
+ title: "Public guideline",
+ file_name: "guideline.pdf",
+ file_type: "application/pdf",
+ page_count: 2,
+ chunk_count: 1,
+ metadata: { index_generation_id: "generation-a" },
+ });
+ }
+ if (call.table === "document_pages") return ok([]);
+ if (call.table === "document_images") return ok([]);
+ if (call.table === "document_chunks") return ok([]);
+ if (call.table === "document_table_facts") return ok([]);
+ return ok([]);
+ });
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/[id]/route");
+
+ const response = await GET(authenticatedRequest(`/api/documents/${documentId}`), {
+ params: Promise.resolve({ id: documentId }),
+ });
+ const body = await payload(response);
+
+ expect(response.status).toBe(200);
+ expect(body.document).toMatchObject({ id: documentId, title: "Public guideline", owner_id: null });
+ expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`);
+ });
+
+ it("allows authenticated users to open public document signed URLs", async () => {
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
+ return ok({ storage_path: "public/documents/guideline.pdf", file_type: "application/pdf" });
+ }
+ return ok(null);
+ });
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/[id]/signed-url/route");
+
+ const response = await GET(authenticatedRequest(`/api/documents/${documentId}/signed-url`), {
+ params: Promise.resolve({ id: documentId }),
+ });
+
+ expect(response.status).toBe(200);
+ expect((await payload(response)).url).toContain("public/documents/guideline.pdf");
+ });
+
+ it("recovers valid cookie auth when a stale bearer header is also present", async () => {
+ const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }];
+ const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([])));
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/route");
+
+ const response = await GET(
+ request("/api/documents", {
+ headers: {
+ authorization: "Bearer expired-token",
+ cookie: `sb-access-token=${token}`,
+ },
+ }),
+ );
+ const body = await payload(response);
+
+ expect(response.status).toBe(200);
+ expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
+ expect(client.auth.getUser).toHaveBeenCalledWith(token);
+ });
+
+ it("omits internal document list fields for anonymous callers", async () => {
+ const documents = [{ id: documentId, owner_id: null, title: "Public guideline", status: "indexed" }];
+ const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([])));
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/route");
+
+ const response = await GET(request("/api/documents?includeMeta=true"));
+ const body = await payload(response);
+
+ expect(response.status).toBe(200);
+ expect(client.calls[0].selected).not.toContain("storage_path");
+ expect(client.calls[0].selected).not.toContain("content_hash");
+ expect(body.documents).toEqual(documents);
+ });
+
+ 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("does not return raw internal database errors", async () => {
@@ -536,7 +674,7 @@ describe("private document API access", () => {
it("allows document signed URLs only for owned documents", async () => {
const client = createSupabaseMock((call) => {
- if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
return ok({ storage_path: `${userId}/documents/${documentId}/source.pdf`, file_type: "application/pdf" });
}
return ok(null);
@@ -624,7 +762,7 @@ describe("private document API access", () => {
metadata: { index_generation_id: "generation-a" },
});
}
- if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
return ok({ id: documentId, metadata: { index_generation_id: "generation-a" } });
}
return ok(null);
@@ -653,7 +791,7 @@ describe("private document API access", () => {
metadata: { index_generation_id: "generation-a" },
});
}
- if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
return ok({ id: documentId, metadata: {} });
}
return ok(null);
@@ -682,7 +820,7 @@ describe("private document API access", () => {
metadata: { index_generation_id: "generation-new" },
});
}
- if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
return ok({ id: documentId, metadata: { index_generation_id: "generation-old" } });
}
return ok(null);
@@ -722,6 +860,58 @@ describe("private document API access", () => {
expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled();
});
+ it("rejects anonymous upload with setup guidance when public uploads are not configured", async () => {
+ const client = createSupabaseMock();
+ mockRuntime(client);
+ 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(
+ request("/api/upload", {
+ method: "POST",
+ body: formData,
+ }),
+ );
+
+ expect(response.status).toBe(503);
+ expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." });
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+ expect(client.storageMocks.upload).not.toHaveBeenCalled();
+ });
+
+ it("uploads anonymous documents to the configured public workspace owner", async () => {
+ const publicOwnerId = "99999999-9999-4999-8999-999999999999";
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null);
+ if (call.table === "documents" && call.operation === "insert") {
+ const inserted = call.insertPayload as { id: string; owner_id: string; storage_path: string };
+ return ok({ id: inserted.id, owner_id: inserted.owner_id, storage_path: inserted.storage_path });
+ }
+ if (call.table === "ingestion_jobs" && call.operation === "insert") return ok({ id: "job-1", document_id: documentId });
+ return ok([]);
+ });
+ mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId });
+ 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(
+ request("/api/upload", {
+ method: "POST",
+ body: formData,
+ }),
+ );
+
+ expect(response.status).toBe(201);
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+ expect(
+ client.calls.find((call) => call.table === "documents" && call.operation === "insert")?.insertPayload,
+ ).toMatchObject({
+ owner_id: publicOwnerId,
+ });
+ });
+
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") {
@@ -2106,9 +2296,13 @@ describe("private document API access", () => {
}
return ok([]);
});
- client.rpc.mockImplementation(async (name: string) =>
- name === "search_document_chunks" ? fail("missing rpc") : ok([]),
- );
+ client.rpc.mockImplementation(async (name: string, args?: Record) => {
+ if (name === "search_document_chunks") return fail("missing rpc");
+ if (name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit") {
+ return { data: [rateLimitRow()], error: null };
+ }
+ return ok([]);
+ });
mockRuntime(client);
const { GET } = await import("../src/app/api/documents/[id]/search/route");
@@ -3003,7 +3197,9 @@ describe("private document API access", () => {
}));
const client = createSupabaseMock();
client.rpc.mockImplementation(async (name: string) =>
- name === "consume_api_rate_limit" ? fail("limiter table unavailable") : ok([]),
+ name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit"
+ ? fail("limiter table unavailable")
+ : ok([]),
);
mockRuntime(client, { searchChunksWithTelemetry });
const { POST } = await import("../src/app/api/search/route");
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 3d2bae636..2eb32ebbf 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -143,6 +143,7 @@ async function mockLocalProjectIdentity(page: Page) {
}
async function mockPrivateUnauthenticatedApi(page: Page) {
+ await mockLocalProjectIdentity(page);
await page.route("**/api/setup-status**", async (route) => {
await route.fulfill({
json: { demoMode: false, checks: readySetupChecks },
@@ -706,6 +707,36 @@ test.describe("Clinical KB UI smoke coverage", () => {
});
}
+ test("anonymous user can see enabled live search without a forced sign-in gate", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await mockPrivateUnauthenticatedApi(page);
+ await page.route(/\/api\/search(?:\?.*)?$/, async (route) => {
+ await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } });
+ });
+ await gotoApp(page, "/");
+ await waitForDemoDashboardReady(page);
+
+ await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0);
+ await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0);
+ await expect(page.getByTestId("global-search-input")).toBeEnabled();
+ });
+
+ test("anonymous mobile user can search without a forced sign-in gate", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 820 });
+ await mockPrivateUnauthenticatedApi(page);
+ await page.route(/\/api\/search(?:\?.*)?$/, async (route) => {
+ await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } });
+ });
+ await gotoApp(page, "/");
+ await waitForDemoDashboardReady(page);
+
+ await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0);
+ await expect(page.getByText("Service unavailable")).toHaveCount(0);
+ await expect(page.getByText("API unavailable")).toHaveCount(0);
+ await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0);
+ await expect(page.getByTestId("global-search-input")).toBeEnabled();
+ });
+
test("desktop sidebar mode sync and accessibility affordances stay coherent", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index ef9823b7d..3037cfaa4 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -524,12 +524,13 @@ test.describe("Clinical KB applications launcher", () => {
test("mode home deep links preserve focus=1 on initial load", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
- for (const path of ["/services?focus=1", "/forms?focus=1"]) {
- await gotoLauncher(page, path);
- const sharedSearch = page.getByTestId("global-search-input");
- await expect(sharedSearch).toBeVisible();
- await expect(sharedSearch).toBeFocused();
- }
+ await gotoLauncher(page, "/services?focus=1");
+ await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeVisible();
+ await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeFocused();
+
+ await gotoLauncher(page, "/forms?focus=1");
+ await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeVisible();
+ await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeFocused();
});
test("services mode shows source-backed records in search results", async ({ page }) => {
@@ -573,11 +574,12 @@ test.describe("Clinical KB applications launcher", () => {
test("form detail pages keep the shared forms search wired to form results", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoLauncher(page, "/forms/transport-crisis-form");
+ await expect(page.getByTestId("form-detail-page")).toBeVisible();
// Structural coverage — runs on every browser, WebKit included: the form
// detail page renders inside the shared shell with the Forms-mode composer
// present and no stale results.
- await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole("heading", { level: 1, name: "Transport order" })).toBeVisible();
await expect(page.getByTestId("form-search-results")).toHaveCount(0);
const formsSearchInput = page.locator('input[placeholder="Search forms..."]:visible').first();
From 90fde2ddd7d73d906f69fa0b68e4a472c6a09587 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 5 Jul 2026 21:59:38 +0800
Subject: [PATCH 07/16] test: fix unused param lint in document search
rate-limit mock
---
tests/private-access-routes.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts
index 958a05e92..92438c369 100644
--- a/tests/private-access-routes.test.ts
+++ b/tests/private-access-routes.test.ts
@@ -2296,7 +2296,7 @@ describe("private document API access", () => {
}
return ok([]);
});
- client.rpc.mockImplementation(async (name: string, args?: Record) => {
+ client.rpc.mockImplementation(async (name: string) => {
if (name === "search_document_chunks") return fail("missing rpc");
if (name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit") {
return { data: [rateLimitRow()], error: null };
From 650d26c428c02739c56ba14e07f83106be5d8fbe Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 5 Jul 2026 22:30:52 +0800
Subject: [PATCH 08/16] fix(rag): scope anonymous retrieval to public documents
via owner sentinel
---
scripts/commit-access-rag-fix.mjs | 209 ++++
src/lib/deep-memory.ts | 12 +-
src/lib/document-enrichment.ts | 4 +-
src/lib/owner-scope.ts | 31 +-
src/lib/rag.ts | 32 +-
...210000_retrieval_owner_filter_sentinel.sql | 1030 +++++++++++++++++
supabase/schema.sql | 44 +-
tests/owner-scope.test.ts | 9 +
8 files changed, 1325 insertions(+), 46 deletions(-)
create mode 100644 scripts/commit-access-rag-fix.mjs
create mode 100644 supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql
diff --git a/scripts/commit-access-rag-fix.mjs b/scripts/commit-access-rag-fix.mjs
new file mode 100644
index 000000000..72a78c7b0
--- /dev/null
+++ b/scripts/commit-access-rag-fix.mjs
@@ -0,0 +1,209 @@
+import fs from "node:fs";
+import { execSync } from "node:child_process";
+
+const ownerScope = `import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+
+export const PUBLIC_OWNER_FILTER_SENTINEL = "00000000-0000-0000-0000-000000000000";
+
+export function requireOwnerScope(ownerId: string | null | undefined): string | undefined {
+ if (ownerId) return ownerId;
+ if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") {
+ return undefined;
+ }
+ throw new Error(
+ "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.",
+ );
+}
+
+export function retrievalOwnerFilter(args: {
+ ownerId?: string | null;
+ documentIds?: string[];
+ allowGlobalSearch?: boolean;
+}): string | null | undefined {
+ if (args.ownerId) return requireOwnerScope(args.ownerId);
+ if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") {
+ return undefined;
+ }
+ if (args.allowGlobalSearch || args.documentIds?.length) {
+ return PUBLIC_OWNER_FILTER_SENTINEL;
+ }
+ throw new Error(
+ "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.",
+ );
+}
+`;
+
+fs.writeFileSync("src/lib/owner-scope.ts", ownerScope);
+
+let rag = fs.readFileSync("src/lib/rag.ts", "utf8");
+rag = rag.replace(
+ 'import { requireOwnerScope } from "@/lib/owner-scope";',
+ 'import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope";',
+);
+rag = rag.replace(
+ /function ownerScopeForDocumentFilteredRetrieval\([\s\S]*?\n\}/,
+ `function ownerScopeForDocumentFilteredRetrieval(
+ ownerId: string | undefined,
+ documentIds: string[] | undefined,
+ allowGlobalSearch?: boolean,
+) {
+ return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch });
+}`,
+);
+rag = rag.replaceAll(
+ "ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds)",
+ "ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch)",
+);
+rag = rag.replaceAll(
+ "ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList)",
+ "ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList, args.allowGlobalSearch)",
+);
+rag = rag.replace(
+ `documentFilter ? [documentFilter] : undefined,
+ ),`,
+ `documentFilter ? [documentFilter] : undefined,
+ documentFilter ? undefined : args.allowGlobalSearch,
+ ),`,
+);
+rag = rag.replace(
+ `documentIds: documentFilterList,
+ matchCount: textCandidateCount,`,
+ `documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
+ matchCount: textCandidateCount,`,
+);
+rag = rag.replaceAll(
+ `documentIds: documentFilterList,
+ matchCount: Math.min(candidateCount, 48),`,
+ `documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
+ matchCount: Math.min(candidateCount, 48),`,
+);
+rag = rag.replace(
+ `documentIds: documentFilterList,
+ matchCount: Math.min(candidateCount, 64),`,
+ `documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
+ matchCount: Math.min(candidateCount, 64),`,
+);
+rag = rag.replace(
+ "documentIds?: string[];\n matchCount: number;\n}) {\n const runChunkText",
+ "documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;\n}) {\n const runChunkText",
+);
+for (const fn of ["searchTableFactCandidates", "searchEmbeddingFieldCandidates", "searchIndexUnitCandidates"]) {
+ rag = rag.replace(
+ new RegExp(`async function ${fn}\\([\\s\\S]*?documentIds\\?: string\\[\\];\\n matchCount: number;`),
+ (m) => m.replace("documentIds?: string[];\n matchCount: number;", "documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;"),
+ );
+}
+if (!rag.includes('else documentQuery = documentQuery.is("owner_id", null);')) {
+ rag = rag.replace(
+ `if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId);
+ const { data: documents, error: documentsError } = await documentQuery;`,
+ `if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId);
+ else documentQuery = documentQuery.is("owner_id", null);
+ const { data: documents, error: documentsError } = await documentQuery;`,
+ );
+}
+fs.writeFileSync("src/lib/rag.ts", rag);
+
+let enrichment = fs.readFileSync("src/lib/document-enrichment.ts", "utf8");
+enrichment = enrichment.replace(
+ 'import { requireOwnerScope } from "@/lib/owner-scope";',
+ 'import { retrievalOwnerFilter } from "@/lib/owner-scope";',
+);
+enrichment = enrichment.replace(
+ "owner_filter: args.ownerId ? requireOwnerScope(args.ownerId) : null,",
+ "owner_filter: retrievalOwnerFilter({ ownerId: args.ownerId, documentIds: args.documentIds }),",
+);
+fs.writeFileSync("src/lib/document-enrichment.ts", enrichment);
+
+let memory = fs.readFileSync("src/lib/deep-memory.ts", "utf8");
+if (!memory.includes("retrievalOwnerFilter")) {
+ memory = memory.replace(
+ 'import { requireOwnerScope } from "@/lib/owner-scope";',
+ 'import { retrievalOwnerFilter } from "@/lib/owner-scope";',
+ );
+ memory = memory.replace(
+ /owner_filter:[\s\S]*?requireOwnerScope\(args\.ownerId\)[\s\S]*?\),/,
+ `owner_filter: retrievalOwnerFilter({
+ ownerId: args.ownerId,
+ documentIds: args.documentIds,
+ allowGlobalSearch: !args.ownerId && !args.documentIds?.length,
+ }),`,
+ );
+}
+fs.writeFileSync("src/lib/deep-memory.ts", memory);
+
+let schema = fs.readFileSync("supabase/schema.sql", "utf8");
+if (!schema.includes("create or replace function public.retrieval_owner_matches")) {
+ schema = schema.replace(
+ "create or replace function public.match_document_chunks(",
+ `create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid)
+returns boolean
+language sql
+immutable
+parallel safe
+set search_path = public, pg_temp
+as $$
+ select case
+ when owner_filter is null then true
+ when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null
+ else row_owner_id = owner_filter
+ end;
+$$;
+
+create or replace function public.match_document_chunks(`,
+ );
+}
+schema = schema.replaceAll("(owner_filter is null or d.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, d.owner_id)");
+schema = schema.replaceAll("(owner_filter is null or l.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, l.owner_id)");
+schema = schema.replaceAll("(owner_filter is null or s.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, s.owner_id)");
+schema = schema.replaceAll("(owner_filter is null or f.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, f.owner_id)");
+fs.writeFileSync("supabase/schema.sql", schema);
+
+const names = [
+ "retrieval_owner_matches",
+ "match_document_chunks",
+ "match_document_chunks_hybrid",
+ "match_document_memory_cards_hybrid",
+ "match_documents_for_query",
+ "match_document_chunks_text",
+ "match_document_lookup_chunks_text",
+ "get_related_document_metadata",
+ "match_document_table_facts_text",
+ "match_document_embedding_fields_hybrid",
+ "match_document_index_units_hybrid",
+];
+const chunks = names.map((name) => {
+ const re = new RegExp(`create or replace function public\\.${name}[\\s\\S]*?\\n\\$\\$;`, "i");
+ const match = schema.match(re);
+ if (!match) throw new Error(`missing ${name}`);
+ return match[0];
+});
+fs.writeFileSync(
+ "supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql",
+ `-- Public-only retrieval owner filter sentinel for hybrid RPCs.\nset search_path = public, extensions, pg_temp;\n\n${chunks.join("\n\n")}\n`,
+);
+
+let ownerTest = fs.readFileSync("tests/owner-scope.test.ts", "utf8");
+if (!ownerTest.includes("retrievalOwnerFilter")) {
+ ownerTest += `\ndescribe("retrievalOwnerFilter", () => {
+ it("returns the public sentinel for anonymous production global search", async () => {
+ vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, isLocalNoAuthMode: () => false }));
+ vi.stubEnv("NODE_ENV", "production");
+ const { retrievalOwnerFilter, PUBLIC_OWNER_FILTER_SENTINEL } = await import("../src/lib/owner-scope");
+ expect(retrievalOwnerFilter({ allowGlobalSearch: true })).toBe(PUBLIC_OWNER_FILTER_SENTINEL);
+ });
+});\n`;
+ fs.writeFileSync("tests/owner-scope.test.ts", ownerTest);
+}
+
+execSync(
+ "git add src/lib/owner-scope.ts src/lib/rag.ts src/lib/document-enrichment.ts src/lib/deep-memory.ts supabase/schema.sql supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql tests/owner-scope.test.ts scripts/commit-access-rag-fix.mjs",
+ { stdio: "inherit" },
+);
+execSync('git commit -m "fix(rag): scope anonymous retrieval to public documents via owner sentinel"', {
+ stdio: "inherit",
+});
+console.log("committed");
diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts
index fe6d02622..c4b1130a7 100644
--- a/src/lib/deep-memory.ts
+++ b/src/lib/deep-memory.ts
@@ -1,7 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { buildClinicalTextSearchQuery, classifyRagQuery, normalizedClinicalSearchTokens } from "@/lib/clinical-search";
import { logger } from "@/lib/logger";
-import { requireOwnerScope } from "@/lib/owner-scope";
+import { retrievalOwnerFilter } from "@/lib/owner-scope";
import {
buildDocumentIndexUnitInputs,
countDocumentIndexUnitsByType,
@@ -823,11 +823,11 @@ export async function fetchMemoryCardsForQuery(args: {
match_count: args.matchCount ?? 32,
min_similarity: 0.1,
document_filters: args.documentIds?.length ? args.documentIds : null,
- owner_filter: args.ownerId
- ? requireOwnerScope(args.ownerId)
- : args.documentIds?.length
- ? null
- : (requireOwnerScope(args.ownerId) ?? null),
+ owner_filter: retrievalOwnerFilter({
+ ownerId: args.ownerId,
+ documentIds: args.documentIds,
+ allowGlobalSearch: !args.ownerId && !args.documentIds?.length,
+ }),
});
if (error) {
diff --git a/src/lib/document-enrichment.ts b/src/lib/document-enrichment.ts
index 6aee1d8d0..ec77e4670 100644
--- a/src/lib/document-enrichment.ts
+++ b/src/lib/document-enrichment.ts
@@ -1,7 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { classifyDocumentOrganization } from "@/lib/document-organization";
import { env } from "@/lib/env";
-import { requireOwnerScope } from "@/lib/owner-scope";
+import { retrievalOwnerFilter } from "@/lib/owner-scope";
import { isClinicalImageEvidence } from "@/lib/image-filtering";
import {
buildCoveragePromptNote,
@@ -713,7 +713,7 @@ export async function fetchRelatedDocumentMetadata(args: {
}) {
const { data: rpcData, error: rpcError } = await args.supabase.rpc("get_related_document_metadata", {
document_ids: args.documentIds,
- owner_filter: args.ownerId ? requireOwnerScope(args.ownerId) : null,
+ owner_filter: retrievalOwnerFilter({ ownerId: args.ownerId, documentIds: args.documentIds }),
});
if (!rpcError) {
diff --git a/src/lib/owner-scope.ts b/src/lib/owner-scope.ts
index 4bec91eca..4eec27540 100644
--- a/src/lib/owner-scope.ts
+++ b/src/lib/owner-scope.ts
@@ -1,17 +1,7 @@
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
-/**
- * Fail-closed guard for multi-tenant owner scoping.
- *
- * The hybrid retrieval RPCs treat a null `owner_filter` as "all owners"
- * (fail-open), so calling them without an ownerId would silently return another
- * tenant's data. Call this at every retrieval RPC boundary that filters by
- * owner. In a real (multi-user) deployment a missing ownerId throws instead of
- * leaking; in demo / local-no-auth / the test runner there is no multi-tenancy,
- * so it stays permissive (returns undefined, preserving the previous behaviour).
- *
- * See the owner-scoping isolation audit.
- */
+export const PUBLIC_OWNER_FILTER_SENTINEL = "00000000-0000-0000-0000-000000000000";
+
export function requireOwnerScope(ownerId: string | null | undefined): string | undefined {
if (ownerId) return ownerId;
if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") {
@@ -21,3 +11,20 @@ export function requireOwnerScope(ownerId: string | null | undefined): string |
"Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.",
);
}
+
+export function retrievalOwnerFilter(args: {
+ ownerId?: string | null;
+ documentIds?: string[];
+ allowGlobalSearch?: boolean;
+}): string | null | undefined {
+ if (args.ownerId) return requireOwnerScope(args.ownerId);
+ if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") {
+ return undefined;
+ }
+ if (args.allowGlobalSearch || args.documentIds?.length) {
+ return PUBLIC_OWNER_FILTER_SENTINEL;
+ }
+ throw new Error(
+ "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.",
+ );
+}
diff --git a/src/lib/rag.ts b/src/lib/rag.ts
index 7192677a3..d504c10dd 100644
--- a/src/lib/rag.ts
+++ b/src/lib/rag.ts
@@ -1,5 +1,5 @@
import { createAdminClient } from "@/lib/supabase/admin";
-import { requireOwnerScope } from "@/lib/owner-scope";
+import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope";
import type { Database, Json } from "@/lib/supabase/database.types";
import {
embedTextWithTelemetry,
@@ -2069,10 +2069,12 @@ function assertGlobalSearchAllowed(args: SearchChunksArgs) {
}
}
-function ownerScopeForDocumentFilteredRetrieval(ownerId: string | undefined, documentIds: string[] | undefined) {
- if (ownerId) return requireOwnerScope(ownerId);
- if (documentIds?.length) return undefined;
- return requireOwnerScope(ownerId);
+function ownerScopeForDocumentFilteredRetrieval(
+ ownerId: string | undefined,
+ documentIds: string[] | undefined,
+ allowGlobalSearch?: boolean,
+) {
+ return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch });
}
export function buildRetrievalQueryVariants(
@@ -2200,6 +2202,7 @@ async function searchTextChunkCandidates(args: {
queryVariants: string[];
ownerId?: string;
documentIds?: string[];
+ allowGlobalSearch?: boolean;
matchCount: number;
}) {
const runChunkText = async (queryText: string, matchCount: number) => {
@@ -2207,7 +2210,7 @@ async function searchTextChunkCandidates(args: {
query_text: queryText,
match_count: matchCount,
document_filters: args.documentIds ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
return error || !data?.length ? ([] as SearchResult[]) : (data as SearchResult[]);
};
@@ -2360,7 +2363,7 @@ async function fetchBestDocumentLookupChunks(args: {
query_text: args.query,
document_filters: args.documentIds ?? undefined,
match_count: Math.max(args.limit * 3, 24),
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
if (!rpcError && rpcChunks?.length) {
const ranked = (rpcChunks as DocumentLookupChunkRow[])
@@ -2748,6 +2751,7 @@ async function loadChunksForSignalMatches(args: {
.in("id", documentIds)
.eq("status", "indexed");
if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId);
+ else documentQuery = documentQuery.is("owner_id", null);
const { data: documents, error: documentsError } = await documentQuery;
if (documentsError || !documents?.length) return [] as SearchResult[];
const documentById = new Map(documents.map((document) => [document.id, document]));
@@ -2807,6 +2811,7 @@ async function searchTableFactCandidates(args: {
queryVariants?: string[];
ownerId?: string;
documentIds?: string[];
+ allowGlobalSearch?: boolean;
matchCount: number;
}) {
const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice(
@@ -2819,7 +2824,7 @@ async function searchTableFactCandidates(args: {
query_text: variant,
match_count: index === 0 ? args.matchCount : Math.min(args.matchCount, 24),
document_filters: args.documentIds ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
if (error || !data?.length) return [] as TableFactRpcRow[];
return data as TableFactRpcRow[];
@@ -2858,6 +2863,7 @@ async function searchEmbeddingFieldCandidates(args: {
queryEmbedding: number[];
ownerId?: string;
documentIds?: string[];
+ allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
}) {
@@ -2867,7 +2873,7 @@ async function searchEmbeddingFieldCandidates(args: {
match_count: args.matchCount,
min_similarity: 0.12,
document_filters: args.documentIds ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error);
if (error || !data?.length) return [] as SearchResult[];
@@ -2908,6 +2914,7 @@ async function searchIndexUnitCandidates(args: {
queryEmbedding: number[];
ownerId?: string;
documentIds?: string[];
+ allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
}) {
@@ -2917,7 +2924,7 @@ async function searchIndexUnitCandidates(args: {
match_count: args.matchCount,
min_similarity: 0.1,
document_filters: args.documentIds ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error);
if (error || !data?.length) return [] as SearchResult[];
@@ -5529,6 +5536,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryVariants,
ownerId: args.ownerId,
documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
matchCount: textCandidateCount,
});
telemetry.text_candidate_count = textData.length;
@@ -5627,6 +5635,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryVariants,
ownerId: args.ownerId,
documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 48),
});
const tableFactLatencyMs = Date.now() - tableFactStartedAt;
@@ -5833,7 +5842,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
match_count: candidateCount,
min_similarity: minSimilarity,
document_filters: documentFilterList ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList, args.allowGlobalSearch),
});
return { data, error, latencyMs: Date.now() - startedAt };
})(),
@@ -5932,6 +5941,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
owner_filter: ownerScopeForDocumentFilteredRetrieval(
args.ownerId,
documentFilter ? [documentFilter] : undefined,
+ documentFilter ? undefined : args.allowGlobalSearch,
),
});
diff --git a/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql b/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql
new file mode 100644
index 000000000..64f42ee56
--- /dev/null
+++ b/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql
@@ -0,0 +1,1030 @@
+-- Public-only retrieval owner filter sentinel for hybrid RPCs.
+set search_path = public, extensions, pg_temp;
+
+create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid)
+returns boolean
+language sql
+immutable
+parallel safe
+set search_path = public, pg_temp
+as $
+ select case
+ when owner_filter is null then true
+ when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null
+ else row_owner_id = owner_filter
+ end;
+$;
+
+create or replace function public.match_document_chunks(
+ query_embedding extensions.vector(1536),
+ match_count integer default 8,
+ min_similarity double precision default 0.15,
+ document_filter uuid default null,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ document_id uuid,
+ title text,
+ file_name text,
+ page_number integer,
+ chunk_index integer,
+ section_heading text,
+ content text,
+ retrieval_synopsis text,
+ image_ids uuid[],
+ source_metadata jsonb,
+ document_labels jsonb,
+ document_summary text,
+ similarity double precision,
+ images jsonb
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ select
+ c.id,
+ c.document_id,
+ d.title,
+ d.file_name,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.retrieval_synopsis,
+ c.image_ids,
+ d.metadata as source_metadata,
+ '[]'::jsonb as document_labels,
+ null::text as document_summary,
+ 1 - (c.embedding <=> query_embedding) as similarity,
+ '[]'::jsonb as images
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ where (document_filter is null or c.document_id = document_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
+ and 1 - (c.embedding <=> query_embedding) >= min_similarity
+ order by c.embedding <=> query_embedding
+ limit match_count;
+$$;
+
+create or replace function public.match_document_chunks(
+ query_embedding extensions.vector(1536),
+ match_count integer default 8,
+ min_similarity double precision default 0.15,
+ document_filter uuid default null,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ document_id uuid,
+ title text,
+ file_name text,
+ page_number integer,
+ chunk_index integer,
+ section_heading text,
+ content text,
+ retrieval_synopsis text,
+ image_ids uuid[],
+ source_metadata jsonb,
+ document_labels jsonb,
+ document_summary text,
+ similarity double precision,
+ images jsonb
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ select
+ c.id,
+ c.document_id,
+ d.title,
+ d.file_name,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.retrieval_synopsis,
+ c.image_ids,
+ d.metadata as source_metadata,
+ '[]'::jsonb as document_labels,
+ null::text as document_summary,
+ 1 - (c.embedding <=> query_embedding) as similarity,
+ '[]'::jsonb as images
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ where (document_filter is null or c.document_id = document_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
+ and 1 - (c.embedding <=> query_embedding) >= min_similarity
+ order by c.embedding <=> query_embedding
+ limit match_count;
+$$;
+
+create or replace function public.match_document_chunks_hybrid(
+ query_embedding extensions.vector(1536),
+ query_text text,
+ match_count integer default 12,
+ min_similarity double precision default 0.12,
+ document_filters uuid[] default null,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ document_id uuid,
+ title text,
+ file_name text,
+ page_number integer,
+ chunk_index integer,
+ section_heading text,
+ content text,
+ retrieval_synopsis text,
+ image_ids uuid[],
+ source_metadata jsonb,
+ similarity double precision,
+ text_rank double precision,
+ hybrid_score double precision,
+ rrf_score double precision,
+ images jsonb
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with query as (
+ select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq
+ ),
+ vector_ranked as (
+ select
+ c.id,
+ c.document_id,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.retrieval_synopsis,
+ c.image_ids,
+ 1 - (c.embedding <=> query_embedding) as similarity,
+ (
+ ts_rank_cd(c.search_tsv, query.tsq) +
+ (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0)
+ )::double precision as text_rank,
+ row_number() over (order by c.embedding <=> query_embedding) as vector_rank,
+ null::bigint as text_match_rank,
+ coalesce((d.metadata->'rag_indexing_version') is not null, false) as has_deep_index,
+ d.updated_at as doc_updated_at,
+ coalesce(
+ (select q.quality_score from public.document_index_quality q where q.document_id = c.document_id),
+ 0.7
+ ) as quality_score
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ cross join query
+ where (document_filters is null or c.document_id = any(document_filters))
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
+ and 1 - (c.embedding <=> query_embedding) >= min_similarity
+ order by c.embedding <=> query_embedding
+ limit greatest(match_count * 6, 48)
+ ),
+ text_ranked as (
+ select
+ c.id,
+ c.document_id,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.retrieval_synopsis,
+ c.image_ids,
+ 1 - (c.embedding <=> query_embedding) as similarity,
+ (
+ ts_rank_cd(c.search_tsv, query.tsq) +
+ (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0)
+ )::double precision as text_rank,
+ null::bigint as vector_rank,
+ row_number() over (
+ order by
+ (
+ ts_rank_cd(c.search_tsv, query.tsq) +
+ (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0)
+ ) desc,
+ c.embedding <=> query_embedding
+ ) as text_match_rank,
+ coalesce((d.metadata->'rag_indexing_version') is not null, false) as has_deep_index,
+ d.updated_at as doc_updated_at,
+ coalesce(
+ (select q.quality_score from public.document_index_quality q where q.document_id = c.document_id),
+ 0.7
+ ) as quality_score
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ cross join query
+ where (document_filters is null or c.document_id = any(document_filters))
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
+ and c.search_tsv @@ query.tsq
+ order by (
+ ts_rank_cd(c.search_tsv, query.tsq) +
+ (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0)
+ ) desc
+ limit greatest(match_count * 6, 48)
+ ),
+ combined as (
+ select * from vector_ranked
+ union all
+ select * from text_ranked
+ ),
+ scored as (
+ select
+ id,
+ document_id,
+ page_number,
+ chunk_index,
+ section_heading,
+ content,
+ retrieval_synopsis,
+ image_ids,
+ max(similarity)::double precision as similarity,
+ max(text_rank)::double precision as text_rank,
+ min(vector_rank) as vector_rank,
+ min(text_match_rank) as text_match_rank,
+ max(quality_score)::double precision as quality_score,
+ bool_or(has_deep_index) as has_deep_index,
+ max(doc_updated_at) as doc_updated_at
+ from combined
+ group by id, document_id, page_number, chunk_index, section_heading, content, retrieval_synopsis, image_ids
+ ),
+ scored_metrics as (
+ select
+ scored.*,
+ (
+ (scored.similarity * 0.62)
+ + (least(scored.text_rank, 1) * 0.22)
+ + (scored.quality_score * 0.10)
+ + (case when scored.doc_updated_at > now() - interval '90 days' then 0.06 else 0 end)
+ )::double precision as hybrid_score,
+ (
+ coalesce(1.0 / (60 + scored.vector_rank), 0) +
+ coalesce(1.0 / (60 + scored.text_match_rank), 0)
+ )::double precision as rrf_score
+ from scored
+ ),
+ hybrid_candidates as (
+ select id
+ from scored_metrics
+ order by hybrid_score desc, similarity desc, text_rank desc
+ limit match_count
+ ),
+ vector_candidates as (
+ select id
+ from scored_metrics
+ order by similarity desc, hybrid_score desc
+ limit match_count
+ ),
+ text_candidates as (
+ select id
+ from scored_metrics
+ order by text_rank desc, hybrid_score desc
+ limit match_count
+ ),
+ rrf_candidates as (
+ select id
+ from scored_metrics
+ order by rrf_score desc, hybrid_score desc
+ limit match_count
+ ),
+ candidate_ids as (
+ select id from hybrid_candidates
+ union
+ select id from vector_candidates
+ union
+ select id from text_candidates
+ union
+ select id from rrf_candidates
+ )
+ select
+ c.id,
+ c.document_id,
+ d.title,
+ d.file_name,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.retrieval_synopsis,
+ c.image_ids,
+ d.metadata as source_metadata,
+ c.similarity,
+ c.text_rank,
+ c.hybrid_score,
+ c.rrf_score,
+ public.chunk_image_metadata(c.image_ids) as images
+ from scored_metrics c
+ join candidate_ids candidates on candidates.id = c.id
+ join public.documents d on d.id = c.document_id
+ order by c.hybrid_score desc, c.rrf_score desc, c.similarity desc, c.text_rank desc
+ limit match_count;
+$$;
+
+create or replace function public.match_document_memory_cards_hybrid_v2(
+ query_embedding extensions.vector(1536),
+ query_text text,
+ match_count integer default 32,
+ min_similarity double precision default 0.1,
+ document_filters uuid[] default null,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ document_id uuid,
+ owner_id uuid,
+ section_id uuid,
+ card_type text,
+ title text,
+ content text,
+ normalized_terms text[],
+ page_number integer,
+ source_chunk_ids uuid[],
+ source_image_ids uuid[],
+ confidence real,
+ metadata jsonb,
+ similarity double precision,
+ text_rank double precision,
+ hybrid_score double precision,
+ rrf_score double precision
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with query as (
+ select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq
+ ),
+ vector_ranked as (
+ select
+ m.*,
+ (1 - (m.embedding <=> query_embedding))::double precision as similarity,
+ ts_rank_cd(m.search_tsv, query.tsq)::double precision as text_rank,
+ row_number() over (order by m.embedding <=> query_embedding) as vector_rank,
+ null::bigint as text_match_rank
+ from public.document_memory_cards m
+ join public.documents d on d.id = m.document_id
+ cross join query
+ where (document_filters is null or m.document_id = any(document_filters))
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_artifact_generation(m.metadata, d.metadata)
+ and (1 - (m.embedding <=> query_embedding)) >= min_similarity
+ order by m.embedding <=> query_embedding
+ limit greatest(match_count * 6, 96)
+ ),
+ text_ranked as (
+ select
+ m.*,
+ (1 - (m.embedding <=> query_embedding))::double precision as similarity,
+ ts_rank_cd(m.search_tsv, query.tsq)::double precision as text_rank,
+ null::bigint as vector_rank,
+ row_number() over (
+ order by ts_rank_cd(m.search_tsv, query.tsq) desc, m.embedding <=> query_embedding
+ ) as text_match_rank
+ from public.document_memory_cards m
+ join public.documents d on d.id = m.document_id
+ cross join query
+ where (document_filters is null or m.document_id = any(document_filters))
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_artifact_generation(m.metadata, d.metadata)
+ and m.search_tsv @@ query.tsq
+ order by ts_rank_cd(m.search_tsv, query.tsq) desc
+ limit greatest(match_count * 6, 96)
+ ),
+ combined as (
+ select * from vector_ranked
+ union all
+ select * from text_ranked
+ ),
+ scored as (
+ select
+ id, document_id, owner_id, section_id, card_type, title, content, normalized_terms,
+ page_number, source_chunk_ids, source_image_ids, confidence, metadata,
+ max(similarity)::double precision as similarity,
+ max(text_rank)::double precision as text_rank,
+ min(vector_rank) as vector_rank,
+ min(text_match_rank) as text_match_rank
+ from combined
+ group by
+ id, document_id, owner_id, section_id, card_type, title, content, normalized_terms,
+ page_number, source_chunk_ids, source_image_ids, confidence, metadata
+ )
+ select
+ id, document_id, owner_id, section_id, card_type, title, content, normalized_terms,
+ page_number, source_chunk_ids, source_image_ids, confidence, metadata, similarity, text_rank,
+ (
+ (similarity * 0.62)
+ + (least(text_rank, 1) * 0.24)
+ + (confidence * 0.10)
+ + (
+ coalesce(1.0 / (60 + vector_rank), 0)
+ + coalesce(1.0 / (60 + text_match_rank), 0)
+ ) * 0.04
+ )::double precision as hybrid_score,
+ (
+ coalesce(1.0 / (60 + vector_rank), 0)
+ + coalesce(1.0 / (60 + text_match_rank), 0)
+ )::double precision as rrf_score
+ from scored
+ order by hybrid_score desc, similarity desc, text_rank desc, confidence desc
+ limit match_count;
+$$;
+
+create or replace function public.match_documents_for_query(
+ query_text text,
+ match_count integer default 12,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ owner_id uuid,
+ title text,
+ file_name text,
+ status text,
+ page_count integer,
+ chunk_count integer,
+ image_count integer,
+ metadata jsonb,
+ text_rank double precision,
+ match_reason text
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with query as (
+ select
+ websearch_to_tsquery('english', coalesce(query_text, '')) as tsq,
+ lower(coalesce(query_text, '')) as normalized
+ ),
+ ranked as (
+ select
+ d.id,
+ d.owner_id,
+ d.title,
+ d.file_name,
+ d.status,
+ d.page_count,
+ d.chunk_count,
+ d.image_count,
+ d.metadata,
+ (
+ (ts_rank_cd(d.title_search_tsv, query.tsq) * 4.0) +
+ (ts_rank_cd(d.search_tsv, query.tsq) * 1.5) +
+ coalesce(max(ts_rank_cd(to_tsvector('english', l.label), query.tsq)) * 1.2, 0) +
+ coalesce(ts_rank_cd(to_tsvector('english', s.summary), query.tsq), 0) +
+ (greatest(
+ similarity(lower(coalesce(d.title, '') || ' ' || coalesce(d.file_name, '')), query.normalized),
+ coalesce(max(similarity(lower(l.label), query.normalized)), 0),
+ coalesce(similarity(lower(s.summary), query.normalized), 0)
+ ) * 1.6)
+ )::double precision as text_rank,
+ case
+ when d.title_search_tsv @@ query.tsq then 'title'
+ when max(l.label) filter (where to_tsvector('english', l.label) @@ query.tsq) is not null then 'label'
+ when s.summary is not null and to_tsvector('english', s.summary) @@ query.tsq then 'summary'
+ when similarity(lower(coalesce(d.title, '') || ' ' || coalesce(d.file_name, '')), query.normalized) >= 0.18 then 'fuzzy_title'
+ when d.search_tsv @@ query.tsq then 'metadata'
+ else 'none'
+ end as match_reason
+ from public.documents d
+ left join public.document_labels l
+ on l.document_id = d.id
+ and coalesce(l.metadata->>'review_status', 'new') <> 'hidden'
+ and coalesce(l.metadata->>'hidden', 'false') <> 'true'
+ left join public.document_summaries s on s.document_id = d.id
+ cross join query
+ where public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and (
+ d.title_search_tsv @@ query.tsq
+ or d.search_tsv @@ query.tsq
+ or to_tsvector('english', coalesce(l.label, '')) @@ query.tsq
+ or to_tsvector('english', coalesce(s.summary, '')) @@ query.tsq
+ or similarity(lower(coalesce(d.title, '') || ' ' || coalesce(d.file_name, '')), query.normalized) >= 0.18
+ or similarity(lower(coalesce(l.label, '')), query.normalized) >= 0.2
+ or similarity(lower(coalesce(s.summary, '')), query.normalized) >= 0.16
+ )
+ group by d.id, d.owner_id, d.title, d.file_name, d.status, d.page_count, d.chunk_count, d.image_count, d.metadata, s.summary, query.tsq, query.normalized
+ )
+ select *
+ from ranked
+ where text_rank > 0
+ order by text_rank desc, page_count desc, title asc
+ limit match_count;
+$$;
+
+create or replace function public.match_document_chunks_text(
+ query_text text,
+ match_count integer default 12,
+ document_filters uuid[] default null,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ document_id uuid,
+ title text,
+ file_name text,
+ page_number integer,
+ chunk_index integer,
+ section_heading text,
+ content text,
+ retrieval_synopsis text,
+ image_ids uuid[],
+ source_metadata jsonb,
+ document_labels jsonb,
+ document_summary text,
+ similarity double precision,
+ text_rank double precision,
+ hybrid_score double precision,
+ lexical_score double precision,
+ images jsonb
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with query as (
+ select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq
+ ),
+ ranked as (
+ select
+ c.id,
+ c.document_id,
+ d.title,
+ d.file_name,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.retrieval_synopsis,
+ c.image_ids,
+ d.metadata as source_metadata,
+ (
+ ts_rank_cd(c.search_tsv, query.tsq) +
+ (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0)
+ )::double precision as text_rank
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ cross join query
+ where (document_filters is null or c.document_id = any(document_filters))
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
+ and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)
+ order by (
+ ts_rank_cd(c.search_tsv, query.tsq) +
+ (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0)
+ ) desc
+ limit least(greatest(match_count * 2, 24), 96)
+ ),
+ -- Batch-fetch label metadata for all distinct document_ids in the result set.
+ -- One query replaces N per-row calls to document_label_metadata().
+ doc_labels as (
+ select
+ l.document_id,
+ coalesce(
+ jsonb_agg(
+ jsonb_build_object(
+ 'id', l.id,
+ 'document_id', l.document_id,
+ 'owner_id', l.owner_id,
+ 'label', l.label,
+ 'label_type', l.label_type,
+ 'source', l.source,
+ 'confidence', l.confidence,
+ 'metadata', l.metadata,
+ 'created_at', l.created_at,
+ 'updated_at', l.updated_at
+ )
+ order by l.confidence desc, l.label
+ ),
+ '[]'::jsonb
+ ) as labels
+ from public.document_labels l
+ where l.document_id in (select distinct ranked.document_id from ranked)
+ and coalesce(l.metadata->>'review_status', 'new') <> 'hidden'
+ and coalesce(l.metadata->>'hidden', 'false') <> 'true'
+ group by l.document_id
+ ),
+ -- Batch-fetch summary text for all distinct document_ids in the result set.
+ -- One query replaces N per-row calls to document_summary_text().
+ doc_summaries as (
+ select distinct on (s.document_id)
+ s.document_id,
+ s.summary
+ from public.document_summaries s
+ where s.document_id in (select distinct ranked.document_id from ranked)
+ order by s.document_id
+ )
+ select
+ ranked.id,
+ ranked.document_id,
+ ranked.title,
+ ranked.file_name,
+ ranked.page_number,
+ ranked.chunk_index,
+ ranked.section_heading,
+ ranked.content,
+ ranked.retrieval_synopsis,
+ ranked.image_ids,
+ ranked.source_metadata,
+ coalesce(doc_labels.labels, '[]'::jsonb) as document_labels,
+ doc_summaries.summary as document_summary,
+ -- Text-only fallback has NO vector cosine similarity. Do not fabricate one:
+ -- a synthetic value here was read downstream as a real semantic score and
+ -- could label a pure keyword hit as "strong"/"moderate" evidence (>=0.64).
+ -- Leave similarity at 0; the lexical signal lives in lexical_score.
+ 0::double precision as similarity,
+ ranked.text_rank,
+ -- Cap hybrid_score well below the 0.64 "moderate" threshold so a lexical-only
+ -- row can order amongst its peers but can never masquerade as a moderate/strong
+ -- cosine match when merged with vector results.
+ least(0.5, 0.18 + (least(ranked.text_rank, 1) * 0.3))::double precision as hybrid_score,
+ least(0.99, 0.4 + (least(ranked.text_rank, 1) * 0.59))::double precision as lexical_score,
+ public.chunk_image_metadata(ranked.image_ids) as images
+ from ranked
+ left join doc_labels on doc_labels.document_id = ranked.document_id
+ left join doc_summaries on doc_summaries.document_id = ranked.document_id
+ order by lexical_score desc, text_rank desc
+ limit match_count;
+$$;
+
+create or replace function public.match_document_lookup_chunks_text(
+ query_text text,
+ document_filters uuid[],
+ match_count integer default 24,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ document_id uuid,
+ page_number integer,
+ chunk_index integer,
+ section_heading text,
+ section_path text[],
+ heading_level integer,
+ parent_heading text,
+ anchor_id text,
+ content text,
+ retrieval_synopsis text,
+ image_ids uuid[],
+ text_rank double precision
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with query as (
+ select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq
+ )
+ select
+ c.id,
+ c.document_id,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.section_path,
+ c.heading_level,
+ c.parent_heading,
+ c.anchor_id,
+ c.content,
+ c.retrieval_synopsis,
+ c.image_ids,
+ (
+ ts_rank_cd(c.search_tsv, query.tsq) +
+ (case when c.section_heading is not null then ts_rank_cd(to_tsvector('english', c.section_heading), query.tsq) * 0.35 else 0 end) +
+ (ts_rank_cd(d.title_search_tsv, query.tsq) * 0.25)
+ )::double precision as text_rank
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ cross join query
+ where document_filters is not null
+ and c.document_id = any(document_filters)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
+ and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)
+ order by text_rank desc, c.chunk_index asc
+ limit least(greatest(match_count, 1), 80);
+$$;
+
+create or replace function public.get_related_document_metadata(
+ document_ids uuid[],
+ owner_filter uuid default null
+)
+returns table (
+ document_id uuid,
+ labels jsonb,
+ summary text
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ select
+ d.id as document_id,
+ coalesce(
+ (
+ select jsonb_agg(
+ jsonb_build_object(
+ 'id', l.id,
+ 'document_id', l.document_id,
+ 'owner_id', l.owner_id,
+ 'label', l.label,
+ 'label_type', l.label_type,
+ 'source', l.source,
+ 'confidence', l.confidence,
+ 'metadata', l.metadata,
+ 'created_at', l.created_at,
+ 'updated_at', l.updated_at
+ )
+ order by l.confidence desc, l.label
+ )
+ from public.document_labels l
+ where l.document_id = d.id
+ and public.retrieval_owner_matches(owner_filter, l.owner_id)
+ and coalesce(l.metadata->>'review_status', 'new') <> 'hidden'
+ and coalesce(l.metadata->>'hidden', 'false') <> 'true'
+ ),
+ '[]'::jsonb
+ ) as labels,
+ (
+ select s.summary
+ from public.document_summaries s
+ where s.document_id = d.id
+ and public.retrieval_owner_matches(owner_filter, s.owner_id)
+ order by s.generated_at desc
+ limit 1
+ ) as summary
+ from public.documents d
+ where d.id = any(document_ids)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id);
+$$;
+
+create or replace function public.match_document_table_facts_text(
+ query_text text,
+ match_count integer default 16,
+ document_filters uuid[] default null,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ document_id uuid,
+ source_chunk_id uuid,
+ source_image_id uuid,
+ page_number integer,
+ table_title text,
+ row_label text,
+ clinical_parameter text,
+ threshold_value text,
+ action text,
+ text_rank double precision,
+ match_reason text,
+ metadata jsonb
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with query as (
+ select
+ websearch_to_tsquery('english', coalesce(query_text, '')) as tsq,
+ lower(coalesce(query_text, '')) as normalized,
+ regexp_split_to_array(lower(coalesce(query_text, '')), '[^a-z0-9]+') as terms
+ ),
+ ranked as (
+ select
+ f.id,
+ f.document_id,
+ f.source_chunk_id,
+ f.source_image_id,
+ f.page_number,
+ f.table_title,
+ f.row_label,
+ f.clinical_parameter,
+ f.threshold_value,
+ f.action,
+ (
+ ts_rank_cd(f.search_tsv, query.tsq) +
+ (
+ similarity(
+ lower(
+ coalesce(f.table_title, '') || ' ' ||
+ coalesce(f.row_label, '') || ' ' ||
+ coalesce(f.clinical_parameter, '') || ' ' ||
+ coalesce(f.threshold_value, '') || ' ' ||
+ coalesce(f.action, '')
+ ),
+ query.normalized
+ ) * 0.8
+ ) +
+ case
+ when coalesce(f.threshold_value, '') <> ''
+ and regexp_split_to_array(lower(f.threshold_value), '[^a-z0-9]+') && query.terms then 0.12
+ else 0
+ end +
+ case
+ when coalesce(f.action, '') <> ''
+ and regexp_split_to_array(lower(f.action), '[^a-z0-9]+') && query.terms then 0.1
+ else 0
+ end
+ )::double precision as text_rank,
+ case
+ when coalesce(f.threshold_value, '') <> '' then 'table_threshold'
+ when coalesce(f.action, '') <> '' then 'table_action'
+ else 'table_row'
+ end as match_reason,
+ f.metadata
+ from public.document_table_facts f
+ join public.documents d on d.id = f.document_id
+ cross join query
+ where (document_filters is null or f.document_id = any(document_filters))
+ and public.retrieval_owner_matches(owner_filter, f.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_artifact_generation(f.metadata, d.metadata)
+ and (
+ f.search_tsv @@ query.tsq
+ or f.normalized_terms && query.terms
+ or similarity(
+ lower(
+ coalesce(f.table_title, '') || ' ' ||
+ coalesce(f.row_label, '') || ' ' ||
+ coalesce(f.clinical_parameter, '') || ' ' ||
+ coalesce(f.threshold_value, '') || ' ' ||
+ coalesce(f.action, '')
+ ),
+ query.normalized
+ ) >= 0.18
+ )
+ )
+ select *
+ from ranked
+ where text_rank > 0
+ order by text_rank desc, page_number asc nulls last
+ limit match_count;
+$$;
+
+create or replace function public.match_document_embedding_fields_hybrid(
+ query_embedding extensions.vector(1536),
+ query_text text,
+ match_count integer default 16,
+ min_similarity double precision default 0.5,
+ document_filters uuid[] default null,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ document_id uuid,
+ source_chunk_id uuid,
+ field_type text,
+ content text,
+ similarity double precision,
+ text_rank double precision,
+ hybrid_score double precision
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with query as (
+ select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq
+ ),
+ vector_hits as (
+ select f.id
+ from public.document_embedding_fields f
+ join public.documents d on d.id = f.document_id
+ where (document_filters is null or f.document_id = any(document_filters))
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_artifact_generation(f.metadata, d.metadata)
+ and f.source_chunk_id is not null
+ and 1 - (f.embedding <=> query_embedding) >= min_similarity
+ order by f.embedding <=> query_embedding
+ limit greatest(match_count * 3, 32)
+ ),
+ text_hits as (
+ select f.id
+ from public.document_embedding_fields f
+ join public.documents d on d.id = f.document_id
+ cross join query
+ where (document_filters is null or f.document_id = any(document_filters))
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and d.status = 'indexed'
+ and public.is_committed_artifact_generation(f.metadata, d.metadata)
+ and f.source_chunk_id is not null
+ and f.search_tsv @@ query.tsq
+ order by ts_rank_cd(f.search_tsv, query.tsq) desc
+ limit greatest(match_count * 3, 32)
+ ),
+ candidate_ids as (
+ select id from vector_hits
+ union
+ select id from text_hits
+ ),
+ ranked as (
+ select
+ f.id, f.document_id, f.source_chunk_id, f.field_type, f.content,
+ (1 - (f.embedding <=> query_embedding))::double precision as similarity,
+ ts_rank_cd(f.search_tsv, query.tsq)::double precision as text_rank
+ from public.document_embedding_fields f
+ join candidate_ids ci on ci.id = f.id
+ cross join query
+ )
+ select
+ id, document_id, source_chunk_id, field_type, content, similarity, text_rank,
+ ((similarity * 0.7) + (least(text_rank, 1) * 0.3))::double precision as hybrid_score
+ from ranked
+ order by hybrid_score desc, similarity desc, text_rank desc
+ limit match_count;
+$$;
+
+create or replace function public.match_document_index_units_hybrid(
+ query_embedding extensions.vector(1536),
+ query_text text,
+ match_count integer default 24,
+ min_similarity double precision default 0.1,
+ document_filters uuid[] default null,
+ owner_filter uuid default null
+)
+returns table (
+ id uuid,
+ document_id uuid,
+ source_chunk_id uuid,
+ source_image_id uuid,
+ unit_type text,
+ title text,
+ content text,
+ page_start integer,
+ page_end integer,
+ heading_path text[],
+ normalized_terms text[],
+ source_span jsonb,
+ quality_score real,
+ extraction_mode text,
+ similarity double precision,
+ text_rank double precision,
+ hybrid_score double precision,
+ metadata jsonb
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with query as (
+ select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq,
+ regexp_split_to_array(lower(coalesce(query_text, '')), '\s+') as terms
+ ),
+ ranked as (
+ select u.id, u.document_id, u.source_chunk_id, u.source_image_id, u.unit_type, u.title, u.content, u.page_start,
+ u.page_end, u.heading_path, u.normalized_terms, u.source_span, u.quality_score, u.extraction_mode,
+ (1 - (u.embedding <=> query_embedding))::double precision as similarity,
+ (ts_rank_cd(u.search_tsv, query.tsq)
+ + case when u.normalized_terms && query.terms then 0.25 else 0 end
+ + case when u.unit_type in ('askable_question', 'table_fact', 'clinical_fact', 'threshold', 'workflow_step', 'medication_monitoring', 'alias', 'visual_summary', 'flowchart_step', 'diagram_decision', 'risk_matrix_cell', 'medication_chart_row', 'chart_finding', 'visual_askable_question', 'table_threshold') then 0.06
+ when u.unit_type = 'section_summary' then 0.03
+ else 0 end
+ )::double precision as text_rank,
+ u.metadata
+ from public.document_index_units u
+ join public.documents d on d.id = u.document_id
+ cross join query
+ where d.status = 'indexed'
+ and (document_filters is null or u.document_id = any(document_filters))
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
+ and public.is_committed_artifact_generation(u.metadata, d.metadata)
+ and u.source_chunk_id is not null
+ and (u.search_tsv @@ query.tsq or u.normalized_terms && query.terms)
+ order by text_rank desc
+ limit greatest(match_count * 3, 48)
+ )
+ select id, document_id, source_chunk_id, source_image_id, unit_type, title, content, page_start, page_end, heading_path,
+ normalized_terms, source_span, quality_score, extraction_mode, similarity, text_rank,
+ (
+ (similarity * 0.52)
+ + (least(text_rank, 1) * 0.28)
+ + (quality_score * 0.12)
+ + (case when extraction_mode in ('model_heavy', 'hybrid') then 0.04 else 0 end)
+ + (case when unit_type in ('askable_question', 'threshold', 'table_fact', 'table_threshold', 'visual_askable_question') then 0.04
+ when unit_type in ('workflow_step', 'medication_monitoring', 'flowchart_step', 'diagram_decision', 'medication_chart_row', 'risk_matrix_cell') then 0.03
+ else 0 end)
+ )::double precision as hybrid_score,
+ metadata
+ from ranked
+ order by hybrid_score desc, similarity desc, text_rank desc
+ limit match_count;
+$$;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 16ce2230c..15ce06969 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -1903,6 +1903,20 @@ as $$
nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', '');
$$;
+create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid)
+returns boolean
+language sql
+immutable
+parallel safe
+set search_path = public, pg_temp
+as $
+ select case
+ when owner_filter is null then true
+ when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null
+ else row_owner_id = owner_filter
+ end;
+$;
+
create or replace function public.match_document_chunks(
query_embedding extensions.vector(1536),
match_count integer default 8,
@@ -1950,7 +1964,7 @@ as $$
from public.document_chunks c
join public.documents d on d.id = c.document_id
where (document_filter is null or c.document_id = document_filter)
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and 1 - (c.embedding <=> query_embedding) >= min_similarity
@@ -2018,7 +2032,7 @@ as $$
join public.documents d on d.id = c.document_id
cross join query
where (document_filters is null or c.document_id = any(document_filters))
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and 1 - (c.embedding <=> query_embedding) >= min_similarity
@@ -2059,7 +2073,7 @@ as $$
join public.documents d on d.id = c.document_id
cross join query
where (document_filters is null or c.document_id = any(document_filters))
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and c.search_tsv @@ query.tsq
@@ -2211,7 +2225,7 @@ as $$
join public.documents d on d.id = m.document_id
cross join query
where (document_filters is null or m.document_id = any(document_filters))
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and public.is_committed_artifact_generation(m.metadata, d.metadata)
and (1 - (m.embedding <=> query_embedding)) >= min_similarity
@@ -2231,7 +2245,7 @@ as $$
join public.documents d on d.id = m.document_id
cross join query
where (document_filters is null or m.document_id = any(document_filters))
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and public.is_committed_artifact_generation(m.metadata, d.metadata)
and m.search_tsv @@ query.tsq
@@ -2629,7 +2643,7 @@ as $$
and coalesce(l.metadata->>'hidden', 'false') <> 'true'
left join public.document_summaries s on s.document_id = d.id
cross join query
- where (owner_filter is null or d.owner_id = owner_filter)
+ where public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and (
d.title_search_tsv @@ query.tsq
@@ -2703,7 +2717,7 @@ as $$
join public.documents d on d.id = c.document_id
cross join query
where (document_filters is null or c.document_id = any(document_filters))
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)
@@ -2836,7 +2850,7 @@ as $$
cross join query
where document_filters is not null
and c.document_id = any(document_filters)
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)
@@ -2881,7 +2895,7 @@ as $$
)
from public.document_labels l
where l.document_id = d.id
- and (owner_filter is null or l.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, l.owner_id)
and coalesce(l.metadata->>'review_status', 'new') <> 'hidden'
and coalesce(l.metadata->>'hidden', 'false') <> 'true'
),
@@ -2891,13 +2905,13 @@ as $$
select s.summary
from public.document_summaries s
where s.document_id = d.id
- and (owner_filter is null or s.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, s.owner_id)
order by s.generated_at desc
limit 1
) as summary
from public.documents d
where d.id = any(document_ids)
- and (owner_filter is null or d.owner_id = owner_filter);
+ and public.retrieval_owner_matches(owner_filter, d.owner_id);
$$;
create or replace function public.match_document_table_facts_text(
@@ -2978,7 +2992,7 @@ as $$
join public.documents d on d.id = f.document_id
cross join query
where (document_filters is null or f.document_id = any(document_filters))
- and (owner_filter is null or f.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, f.owner_id)
and d.status = 'indexed'
and public.is_committed_artifact_generation(f.metadata, d.metadata)
and (
@@ -3033,7 +3047,7 @@ as $$
from public.document_embedding_fields f
join public.documents d on d.id = f.document_id
where (document_filters is null or f.document_id = any(document_filters))
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and public.is_committed_artifact_generation(f.metadata, d.metadata)
and f.source_chunk_id is not null
@@ -3047,7 +3061,7 @@ as $$
join public.documents d on d.id = f.document_id
cross join query
where (document_filters is null or f.document_id = any(document_filters))
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and d.status = 'indexed'
and public.is_committed_artifact_generation(f.metadata, d.metadata)
and f.source_chunk_id is not null
@@ -3938,7 +3952,7 @@ as $$
cross join query
where d.status = 'indexed'
and (document_filters is null or u.document_id = any(document_filters))
- and (owner_filter is null or d.owner_id = owner_filter)
+ and public.retrieval_owner_matches(owner_filter, d.owner_id)
and public.is_committed_artifact_generation(u.metadata, d.metadata)
and u.source_chunk_id is not null
and (u.search_tsv @@ query.tsq or u.normalized_terms && query.terms)
diff --git a/tests/owner-scope.test.ts b/tests/owner-scope.test.ts
index a47a6bbdc..33030b816 100644
--- a/tests/owner-scope.test.ts
+++ b/tests/owner-scope.test.ts
@@ -27,3 +27,12 @@ describe("requireOwnerScope (fail-closed owner scoping)", () => {
expect(() => requireOwnerScope(null)).toThrow(/tenant/);
});
});
+
+describe("retrievalOwnerFilter", () => {
+ it("returns the public sentinel for anonymous production global search", async () => {
+ vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, isLocalNoAuthMode: () => false }));
+ vi.stubEnv("NODE_ENV", "production");
+ const { retrievalOwnerFilter, PUBLIC_OWNER_FILTER_SENTINEL } = await import("../src/lib/owner-scope");
+ expect(retrievalOwnerFilter({ allowGlobalSearch: true })).toBe(PUBLIC_OWNER_FILTER_SENTINEL);
+ });
+});
From c3318020a97e9d4b391418f794539ee9c9b06fe3 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 5 Jul 2026 22:31:27 +0800
Subject: [PATCH 09/16] fix(access): complete forms fallback, signed-url
hardening, upload guard
---
src/app/api/upload/route.ts | 20 ++++++++++++++++++++
src/lib/rag.ts | 3 +++
tests/private-access-routes.test.ts | 12 ++++++++----
3 files changed, 31 insertions(+), 4 deletions(-)
diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts
index a2df002f9..c6845d7f7 100644
--- a/src/app/api/upload/route.ts
+++ b/src/app/api/upload/route.ts
@@ -4,11 +4,13 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { env, publicUploadsEnabled, publicWorkspaceOwnerId } 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";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
+import { assertSafeLocalProjectRequest, localProjectOriginErrorResponse, UnsafeLocalProjectOriginError } from "@/lib/local-project-guard";
import { publicAccessContext } from "@/lib/public-api-access";
import { probeSupabaseHealth } from "@/lib/supabase/health";
import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data";
@@ -83,6 +85,7 @@ export async function POST(request: Request) {
let insertedDocumentOwnerId: string | null = null;
try {
+ assertSafeLocalProjectRequest(request);
supabase = createAdminClient();
const adminSupabase = supabase;
const access = await publicAccessContext(request, adminSupabase);
@@ -90,6 +93,20 @@ export async function POST(request: Request) {
if (!uploadOwnerId) {
return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 });
}
+
+ const rateLimit = await consumeSubjectApiRateLimit({
+ supabase: adminSupabase,
+ subject: access.rateLimitSubject,
+ 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",
@@ -303,6 +320,9 @@ export async function POST(request: Request) {
if (error instanceof AuthenticationError) {
return unauthorizedResponse();
}
+ if (error instanceof UnsafeLocalProjectOriginError) {
+ return localProjectOriginErrorResponse(error);
+ }
return jsonError(error);
}
diff --git a/src/lib/rag.ts b/src/lib/rag.ts
index d504c10dd..eb3348003 100644
--- a/src/lib/rag.ts
+++ b/src/lib/rag.ts
@@ -2357,6 +2357,7 @@ async function fetchBestDocumentLookupChunks(args: {
query: string;
limit: number;
ownerId?: string;
+ allowGlobalSearch?: boolean;
}) {
const terms = documentLookupChunkTerms(args.query);
const { data: rpcChunks, error: rpcError } = await args.supabase.rpc("match_document_lookup_chunks_text", {
@@ -5816,6 +5817,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 48),
telemetry,
});
@@ -5829,6 +5831,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 64),
telemetry,
});
diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts
index 92438c369..b6476fc12 100644
--- a/tests/private-access-routes.test.ts
+++ b/tests/private-access-routes.test.ts
@@ -436,8 +436,10 @@ describe("private document API access", () => {
}),
);
- expect(response.status).toBe(503);
- expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." });
+ expect(response.status).toBe(409);
+ expect(await payload(response)).toMatchObject({
+ error: "Use the ensured Clinical KB local URL before calling this API.",
+ });
expect(client.auth.getUser).not.toHaveBeenCalled();
expect(client.from).not.toHaveBeenCalled();
});
@@ -459,8 +461,10 @@ describe("private document API access", () => {
}),
);
- expect(response.status).toBe(503);
- expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." });
+ expect(response.status).toBe(409);
+ expect(await payload(response)).toMatchObject({
+ error: "Use the ensured Clinical KB local URL before calling this API.",
+ });
expect(client.auth.getUser).not.toHaveBeenCalled();
expect(client.from).not.toHaveBeenCalled();
});
From 89817617b9f478a6c2b3841146cccf946a445e99 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 5 Jul 2026 22:28:06 +0800
Subject: [PATCH 10/16] feat(db): promote locally reviewed documents to public
corpus for anonymous RAG
---
package.json | 1 +
scripts/promote-public-documents.ts | 80 +++++++++++++++++++
...mote_locally_reviewed_documents_public.sql | 65 +++++++++++++++
3 files changed, 146 insertions(+)
create mode 100644 scripts/promote-public-documents.ts
create mode 100644 supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql
diff --git a/package.json b/package.json
index c38c76383..0f96c119f 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts",
"supabase:recovery-status": "tsx scripts/supabase-recovery-status.ts",
"promote:query-misses": "tsx scripts/promote-query-misses.ts",
+ "promote:public-documents": "tsx scripts/promote-public-documents.ts",
"eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts",
"eval:answer-quality": "node scripts/run-eval-safe.mjs scripts/eval-answer-quality.ts",
"eval:quality": "node scripts/run-eval-safe.mjs scripts/eval-quality.ts",
diff --git a/scripts/promote-public-documents.ts b/scripts/promote-public-documents.ts
new file mode 100644
index 000000000..e7b1b606d
--- /dev/null
+++ b/scripts/promote-public-documents.ts
@@ -0,0 +1,80 @@
+import { loadEnvConfig } from "@next/env";
+
+loadEnvConfig(process.cwd());
+
+type PromoteArgs = {
+ ownerId?: string;
+};
+
+async function loadAdminClient() {
+ const { createAdminClient } = await import("@/lib/supabase/admin");
+ return createAdminClient();
+}
+
+function parseArgs(argv: string[]): PromoteArgs {
+ const args: PromoteArgs = {
+ ownerId: process.env.PUBLIC_WORKSPACE_OWNER_ID ?? process.env.LOCAL_NO_AUTH_OWNER_ID,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (token === "--owner-id") {
+ args.ownerId = argv[index + 1];
+ index += 1;
+ continue;
+ }
+ throw new Error(`Unknown argument: ${token}`);
+ }
+
+ return args;
+}
+
+async function countCandidates(supabase: Awaited>, ownerId?: string) {
+ let query = supabase
+ .from("documents")
+ .select("id", { count: "exact", head: true })
+ .eq("status", "indexed")
+ .not("owner_id", "is", null)
+ .in("metadata->>clinical_validation_status", ["locally_reviewed", "approved"]);
+
+ if (ownerId) query = query.eq("owner_id", ownerId);
+
+ const { count, error } = await query;
+ if (error) throw new Error(error.message);
+ return count ?? 0;
+}
+
+async function countPublic(supabase: Awaited>) {
+ const { count, error } = await supabase
+ .from("documents")
+ .select("id", { count: "exact", head: true })
+ .eq("status", "indexed")
+ .is("owner_id", null);
+
+ if (error) throw new Error(error.message);
+ return count ?? 0;
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const supabase = await loadAdminClient();
+
+ const [candidateCount, publicCount] = await Promise.all([
+ countCandidates(supabase, args.ownerId),
+ countPublic(supabase),
+ ]);
+
+ console.log("[public-documents:promote] indexed public documents:", publicCount);
+ console.log(
+ `[public-documents:promote] pending promotion${args.ownerId ? ` for owner ${args.ownerId}` : ""}:`,
+ candidateCount,
+ );
+ console.log(
+ "Apply migration 20260705220000_promote_locally_reviewed_documents_public.sql with `npx supabase db push --linked`.",
+ );
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+});
diff --git a/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql b/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql
new file mode 100644
index 000000000..76c9a5560
--- /dev/null
+++ b/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql
@@ -0,0 +1,65 @@
+-- Promote locally reviewed indexed documents to the public corpus (owner_id IS NULL)
+-- so anonymous callers can list, preview, search, and use them in RAG.
+
+begin;
+
+create temporary table promoted_public_documents on commit drop as
+with promoted as (
+ update public.documents d
+ set
+ owner_id = null,
+ metadata = jsonb_set(
+ coalesce(d.metadata, '{}'::jsonb),
+ '{public_corpus}',
+ 'true'::jsonb,
+ true
+ ),
+ updated_at = now()
+ where d.status = 'indexed'
+ and d.owner_id is not null
+ and coalesce(d.metadata->>'clinical_validation_status', 'unverified') in ('locally_reviewed', 'approved')
+ returning d.id, d.owner_id as previous_owner_id
+)
+select id, previous_owner_id from promoted;
+
+update public.document_labels dl
+set owner_id = null, updated_at = now()
+from promoted_public_documents pd
+where dl.document_id = pd.id;
+
+update public.document_summaries ds
+set owner_id = null, updated_at = now()
+from promoted_public_documents pd
+where ds.document_id = pd.id;
+
+update public.document_sections ds
+set owner_id = null, updated_at = now()
+from promoted_public_documents pd
+where ds.document_id = pd.id;
+
+update public.document_memory_cards dmc
+set owner_id = null, updated_at = now()
+from promoted_public_documents pd
+where dmc.document_id = pd.id;
+
+update public.document_table_facts dtf
+set owner_id = null, updated_at = now()
+from promoted_public_documents pd
+where dtf.document_id = pd.id;
+
+update public.document_embedding_fields def
+set owner_id = null, updated_at = now()
+from promoted_public_documents pd
+where def.document_id = pd.id;
+
+update public.document_index_quality diq
+set owner_id = null, updated_at = now()
+from promoted_public_documents pd
+where diq.document_id = pd.id;
+
+update public.document_index_units diu
+set owner_id = null, updated_at = now()
+from promoted_public_documents pd
+where diu.document_id = pd.id;
+
+commit;
From 85f247ad0e0412fd1d3b19bbfa7562edb8b62fe5 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 5 Jul 2026 22:36:09 +0800
Subject: [PATCH 11/16] test: stabilize scope-sources stress flow via answer
options menu
---
tests/ui-stress.spec.ts | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts
index 89a8aa2a4..1cffeb387 100644
--- a/tests/ui-stress.spec.ts
+++ b/tests/ui-stress.spec.ts
@@ -278,12 +278,14 @@ test.describe("Clinical KB long-content stress coverage", () => {
await expect(page.getByLabel("Source-backed answer")).toBeVisible();
await expect(page.getByTestId("plain-answer-response")).toBeVisible();
- const scopeTrigger = page.locator('[data-testid="scope-trigger"]:visible');
+ const actionMenu = page.getByRole("button", { name: "Open answer options" });
await page.keyboard.press("Escape");
- await scopeTrigger.click();
+ await actionMenu.click();
+ const actionsMenu = page.getByTestId("daily-actions-menu");
+ await expect(actionsMenu).toBeVisible();
+ await actionsMenu.getByRole("menuitem", { name: "Scope sources" }).click();
const scopeContainer = page.getByTestId("scope-command-popover");
await expect(scopeContainer).toBeVisible();
- await expect(scopeContainer).toBeVisible();
await expect(
scopeContainer.getByText(/Type to filter 24 (loaded )?documents\. Selected documents stay pinned here\./),
).toBeVisible();
From e474d037d30163a8ff211effd27838247b770a42 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 5 Jul 2026 22:49:00 +0800
Subject: [PATCH 12/16] fix: allow anonymous setup-status on production for
mobile access
---
src/app/api/setup-status/route.ts | 25 ++++-------------------
tests/setup-status-route.test.ts | 34 +++++++++++++++++++++++--------
2 files changed, 29 insertions(+), 30 deletions(-)
diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts
index bf0551c94..ba34855b8 100644
--- a/src/app/api/setup-status/route.ts
+++ b/src/app/api/setup-status/route.ts
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
import { env, isDemoMode } from "@/lib/env";
import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard";
import { createAdminClient } from "@/lib/supabase/admin";
-import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health";
import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project";
@@ -409,27 +408,11 @@ async function readSetupStatusPayload() {
}
}
-async function requireProductionSetupStatusAuth(request: Request) {
+export async function GET(request: Request) {
const identity = localProjectRequestIdentityPayload(request);
- if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) {
- return identity;
+ if (!identity.localServer.safeLocalOrigin) {
+ return unsafeLocalProjectResponse(identity);
}
- await requireAuthenticatedUser(request, createAdminClient());
- return identity;
-}
-export async function GET(request: Request) {
- try {
- const identity = await requireProductionSetupStatusAuth(request);
- if (!identity.localServer.safeLocalOrigin) {
- return unsafeLocalProjectResponse(identity);
- }
-
- return setupStatusResponse(await readSetupStatusPayload());
- } catch (error) {
- if (error instanceof AuthenticationError) {
- return unauthorizedResponse(error);
- }
- throw error;
- }
+ return setupStatusResponse(await readSetupStatusPayload());
}
diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts
index 170aac7f5..f952b2ef8 100644
--- a/tests/setup-status-route.test.ts
+++ b/tests/setup-status-route.test.ts
@@ -7,10 +7,13 @@ afterEach(() => {
});
describe("/api/setup-status", () => {
- it("requires auth for non-local production requests before returning setup posture", async () => {
+ it("returns setup posture for anonymous production requests without exposing secret values", async () => {
vi.stubEnv("NODE_ENV", "production");
- const getUser = vi.fn();
- const createAdminClient = vi.fn(() => ({ auth: { getUser } }));
+ const from = vi.fn(async () => ({ error: null, data: [], count: 0 }));
+ const createAdminClient = vi.fn(() => ({
+ from,
+ rpc: vi.fn(),
+ }));
vi.doMock("@/lib/env", () => ({
env: {
NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co",
@@ -24,16 +27,29 @@ describe("/api/setup-status", () => {
isLocalNoAuthMode: () => false,
}));
vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient }));
+ vi.doMock("@/lib/supabase/health", () => ({
+ probeSupabaseHealth: vi.fn(async () => ({ ok: true })),
+ isSupabaseUnavailableError: () => false,
+ formatSupabaseUnavailableError: (error: unknown) => String(error),
+ }));
+ vi.doMock("@/lib/supabase/project", () => ({
+ checkSupabaseProjectConfig: () => ({ status: "ready", detail: "Clinical KB Database target is configured." }),
+ formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.",
+ }));
const { GET } = await import("../src/app/api/setup-status/route");
const response = await GET(new Request("https://clinical.example/api/setup-status"));
const body = await response.json();
- expect(response.status).toBe(401);
- expect(body).toEqual({ error: "Authentication required." });
- expect(JSON.stringify(body)).not.toContain("OPENAI");
- expect(JSON.stringify(body)).not.toContain("Supabase");
- expect(createAdminClient).toHaveBeenCalledTimes(1);
- expect(getUser).not.toHaveBeenCalled();
+ expect(response.status).toBe(200);
+ expect(body).toMatchObject({
+ demoMode: false,
+ checks: expect.arrayContaining([
+ expect.objectContaining({ id: "env" }),
+ expect.objectContaining({ id: "openai" }),
+ ]),
+ });
+ expect(JSON.stringify(body)).not.toContain("service-role-key");
+ expect(JSON.stringify(body)).not.toContain("openai-key");
});
});
From 3a1cf9c194e0eb631704c497059ad5560de04ee0 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 5 Jul 2026 23:31:16 +0800
Subject: [PATCH 13/16] Auto-hide answer support chips when content sits below
on mobile
---
src/components/ClinicalDashboard.tsx | 1 +
.../answer-result-surface.tsx | 23 +++-
.../clinical-dashboard/evidence-panels.tsx | 100 +++++++++++-------
.../use-collapse-when-content-below.ts | 96 +++++++++++++++++
4 files changed, 180 insertions(+), 40 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 aa6c1cb2d..ddc94bf1f 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -4082,6 +4082,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..f7d59ca68 100644
--- a/src/components/clinical-dashboard/answer-result-surface.tsx
+++ b/src/components/clinical-dashboard/answer-result-surface.tsx
@@ -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 () => {
@@ -182,6 +187,13 @@ export function StagedAnswerResultSurface({
const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel);
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..cd49bab95 100644
--- a/src/components/clinical-dashboard/evidence-panels.tsx
+++ b/src/components/clinical-dashboard/evidence-panels.tsx
@@ -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 ? (