diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 410715ef5..ab1d0f1f0 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -91,6 +91,12 @@ export async function POST(request: Request) { supabase = createAdminClient(); const adminSupabase = supabase; const access = await publicAccessContext(request, adminSupabase); + // Anonymous ("public") uploads are pooled under the non-null PUBLIC_WORKSPACE_OWNER_ID as a + // moderation quarantine: pooled documents are intentionally NOT anonymously viewable and NOT in + // RAG retrieval (both gated on owner_id IS NULL) until an operator reviews and promotes them via + // scripts/promote-public-documents.ts (or the promote migration). This is the documented + // public-workspace model — see TEN-N3 in docs/tenancy-defense-in-depth-review.md and + // withOwnerReadScope in src/lib/public-api-access.ts. Do not make pooled uploads public here. const uploadOwnerId = access.ownerId ?? (publicUploadsEnabled() ? publicWorkspaceOwnerId() : null); if (!uploadOwnerId) { return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 }); diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 330f75e80..2d238cf77 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -59,7 +59,16 @@ type OwnerScopedQuery = { or(filters: string): T; }; -/** Scope reads to public rows (owner_id IS NULL) and, when signed in, the caller's owned rows. */ +/** + * Scope reads to public rows (owner_id IS NULL) and, when signed in, the caller's owned rows. + * + * Anonymous callers intentionally see ONLY the public corpus (owner_id IS NULL). Documents pooled + * under PUBLIC_WORKSPACE_OWNER_ID by anonymous uploads (see upload/route.ts) are a deliberate + * moderation quarantine: they stay out of anonymous viewing here — and out of RAG retrieval, which + * is gated separately on owner_id IS NULL — until an operator reviews and promotes them to + * owner_id IS NULL via scripts/promote-public-documents.ts (or the 20260706120000 promote + * migration). Do not union the pool owner in here without making that content-moderation decision. + */ export function withOwnerReadScope>(query: T, ownerId: string | undefined): T { if (ownerId) return query.or(`owner_id.eq.${ownerId},owner_id.is.null`); return query.is("owner_id", null);