From c4520fe404fd0fb0616ea489468d68922123a683 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:42:15 +0800 Subject: [PATCH 01/10] fix(auth): keep content public and restrict uploads Persist signed-in favourites and preferences in Supabase, enforce administrator claims on corpus mutations, and keep published document reads anonymous. Verified with verify:pr-local; production-readiness remains environment-blocked without local provider variables. --- .env.example | 9 +- docs/site-map.md | 2 + package.json | 3 + scripts/set-site-administrator.ts | 46 ++++ src/app/api/account/favourites/route.ts | 80 +++++++ src/app/api/account/preferences/route.ts | 62 ++++++ src/app/api/documents/[id]/labels/route.ts | 6 +- src/app/api/documents/[id]/reindex/route.ts | 2 +- src/app/api/documents/[id]/reviews/route.ts | 2 +- src/app/api/documents/[id]/route.ts | 4 +- src/app/api/documents/[id]/summarize/route.ts | 2 +- .../api/documents/[id]/table-facts/route.ts | 42 ++-- src/app/api/documents/bulk/reindex/route.ts | 2 +- src/app/api/documents/bulk/route.ts | 2 +- src/app/api/eval-cases/route.ts | 2 +- src/app/api/ingestion/batches/route.ts | 2 +- .../api/ingestion/jobs/[id]/retry/route.ts | 2 +- src/app/api/ingestion/jobs/route.ts | 2 +- src/app/api/ingestion/quality/route.ts | 2 +- src/app/api/jobs/route.ts | 2 +- src/app/api/setup-status/route.ts | 11 +- src/app/api/upload/route.ts | 20 +- src/app/layout.tsx | 5 +- src/components/ClinicalDashboard.tsx | 38 ++-- src/components/DocumentViewer.tsx | 37 ++-- src/components/account-data-provider.tsx | 205 ++++++++++++++++++ .../favourites-command-library-page.tsx | 11 + .../clinical-dashboard/settings-dialog.tsx | 49 ++--- .../clinical-dashboard/use-app-preferences.ts | 82 ++++++- .../use-saved-registry-favourites.ts | 27 +-- .../differential-detail-page.tsx | 37 ++-- src/components/forms/form-detail-page.tsx | 18 +- .../services/service-detail-page.tsx | 20 +- src/lib/account-preferences.ts | 118 ++++++++++ src/lib/authorization.ts | 13 ++ src/lib/client-env.ts | 4 - src/lib/env.ts | 10 - src/lib/public-api-access.ts | 9 +- src/lib/supabase/auth.ts | 29 ++- src/lib/supabase/client.tsx | 2 +- src/lib/supabase/database.types.ts | 39 ++++ ...35_user_account_data_and_admin_uploads.sql | 59 +++++ tests/account-access-model.test.ts | 82 +++++++ .../audit-navigation-auth-regressions.test.ts | 2 +- tests/client-secret-surface.test.ts | 2 +- tests/document-admin-rate-limit.test.ts | 8 +- tests/private-access-routes.test.ts | 63 ++---- tests/supabase-schema.test.ts | 2 +- 48 files changed, 1021 insertions(+), 257 deletions(-) create mode 100644 scripts/set-site-administrator.ts create mode 100644 src/app/api/account/favourites/route.ts create mode 100644 src/app/api/account/preferences/route.ts create mode 100644 src/components/account-data-provider.tsx create mode 100644 src/lib/account-preferences.ts create mode 100644 src/lib/authorization.ts create mode 100644 supabase/migrations/20260719064735_user_account_data_and_admin_uploads.sql create mode 100644 tests/account-access-model.test.ts diff --git a/.env.example b/.env.example index c95fa343e..ef70ac855 100644 --- a/.env.example +++ b/.env.example @@ -118,12 +118,9 @@ RAG_AWAIT_QUERY_LOGS=false # Design-exploration mockup routes (/mockups/*) 404 in production builds unless # explicitly opted in. Always reachable in dev/test. #NEXT_PUBLIC_MOCKUPS_ENABLED=false -# Allow unauthenticated visitors to upload documents — gates an anonymous write -# path, so leave unset (off) unless you intend a public-intake workspace. When -# enabled, anonymous uploads are owned by PUBLIC_WORKSPACE_OWNER_ID so that -# owner-scoped reads still apply to them. -#NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED=false -#PUBLIC_WORKSPACE_OWNER_ID= +# Content browsing is public. Uploads and corpus-management actions require a +# signed-in user whose Supabase app_metadata.site_role is "administrator". +# Assign that claim only through the approval-gated auth:set-administrator script. # Optional canonical public origin for generated metadata, for example # https://clinical-kb.example.org. Railway deployments otherwise use the # provider-supplied RAILWAY_PUBLIC_DOMAIN; request hosts are only used in dev. diff --git a/docs/site-map.md b/docs/site-map.md index 2596e3e50..80ecd1317 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1031,6 +1031,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## API routes +- `/api/account/favourites` - Route discovered from app directory Source: `src/app/api/account/favourites/route.ts`. +- `/api/account/preferences` - Route discovered from app directory Source: `src/app/api/account/preferences/route.ts`. - `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`. - `/api/answer-feedback` - Route discovered from app directory Source: `src/app/api/answer-feedback/route.ts`. - `/api/answer/stream` - Streaming answer response. Source: `src/app/api/answer/stream/route.ts`. diff --git a/package.json b/package.json index be0084dc2..667af8d6c 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "backfill:source-metadata": "node scripts/run-tsx.mjs scripts/backfill-source-metadata.ts", "backfill:unknown-status": "node scripts/run-tsx.mjs scripts/derive-unknown-status.ts", "check:supabase-project": "node scripts/run-tsx.mjs scripts/check-supabase-project.ts", + "auth:set-administrator": "node scripts/run-tsx.mjs scripts/set-site-administrator.ts", "check:indexing": "node scripts/run-tsx.mjs scripts/check-indexing.ts", "check:m13-migration": "node scripts/run-tsx.mjs scripts/check-m13-migration.ts", "check:july8-live-batch": "node scripts/run-tsx.mjs scripts/check-july8-live-batch.ts", @@ -166,6 +167,8 @@ "workflow:rag-lab": "node scripts/productivity-workflow.mjs rag-lab", "workflow:operator-closeout": "node scripts/productivity-workflow.mjs operator-closeout", "workflow:lifecycle": "node scripts/productivity-workflow.mjs lifecycle", + "skills": "node scripts/list-database-skills.mjs", + "check:skills": "node scripts/list-database-skills.mjs --check", "check:drift": "node scripts/run-tsx.mjs scripts/check-drift.ts", "drift:manifest": "node scripts/run-tsx.mjs scripts/generate-drift-manifest.ts" }, diff --git a/scripts/set-site-administrator.ts b/scripts/set-site-administrator.ts new file mode 100644 index 000000000..79a247dde --- /dev/null +++ b/scripts/set-site-administrator.ts @@ -0,0 +1,46 @@ +import { loadEnvConfig } from "@next/env"; + +async function main() { + loadEnvConfig(process.cwd()); + + if (process.env.ALLOW_SUPABASE_ADMIN_MUTATION !== "true") { + throw new Error( + "Refusing to change Supabase Auth. Re-run only after approval with ALLOW_SUPABASE_ADMIN_MUTATION=true.", + ); + } + + const emailFlagIndex = process.argv.findIndex((argument) => argument === "--email"); + const inlineEmail = process.argv.find((argument) => argument.startsWith("--email="))?.slice("--email=".length); + const requestedEmail = (inlineEmail ?? (emailFlagIndex >= 0 ? process.argv[emailFlagIndex + 1] : "")) + ?.trim() + .toLowerCase(); + + if (!requestedEmail || !requestedEmail.includes("@")) { + throw new Error("Usage: npm run auth:set-administrator -- --email user@example.com"); + } + + const { createAdminClient } = await import("@/lib/supabase/admin"); + const supabase = createAdminClient(); + let matchedUser: Awaited>["data"]["users"][number] | null = null; + + for (let page = 1; page <= 100 && !matchedUser; page += 1) { + const { data, error } = await supabase.auth.admin.listUsers({ page, perPage: 1000 }); + if (error) throw error; + matchedUser = data.users.find((user) => user.email?.trim().toLowerCase() === requestedEmail) ?? null; + if (data.users.length < 1000) break; + } + + if (!matchedUser) throw new Error("No Supabase Auth user matched the supplied email address."); + + const { error } = await supabase.auth.admin.updateUserById(matchedUser.id, { + app_metadata: { ...matchedUser.app_metadata, site_role: "administrator" }, + }); + if (error) throw error; + + console.log("Administrator claim updated. Sign out and sign in again before using administration tools."); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "Administrator claim update failed."); + process.exitCode = 1; +}); diff --git a/src/app/api/account/favourites/route.ts b/src/app/api/account/favourites/route.ts new file mode 100644 index 000000000..95717f2d5 --- /dev/null +++ b/src/app/api/account/favourites/route.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { jsonError } from "@/lib/http"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { requireAuthenticatedUser } from "@/lib/supabase/auth"; +import { parseJsonBody } from "@/lib/validation/body"; + +export const runtime = "nodejs"; + +const mutationSchema = z + .object({ + contentType: z.enum(["service", "form", "differential"]), + contentKey: z.string().trim().min(1).max(180), + saved: z.boolean(), + }) + .strict(); + +export async function GET(request: Request) { + try { + const supabase = createAdminClient(); + const user = await requireAuthenticatedUser(request, supabase); + const { data, error } = await supabase + .from("user_favourites") + .select("content_type,content_key,created_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }); + if (error) throw new Error(error.message); + return Response.json({ + favourites: (data ?? []).map((row) => ({ + contentType: row.content_type, + contentKey: row.content_key, + createdAt: row.created_at, + })), + }); + } catch (error) { + return jsonError(error); + } +} + +export async function PUT(request: Request) { + try { + const supabase = createAdminClient(); + const user = await requireAuthenticatedUser(request, supabase); + const input = await parseJsonBody(request, mutationSchema, "Saved-item request is invalid."); + + if (input.saved) { + const { error } = await supabase + .from("user_favourites") + .upsert( + { user_id: user.id, content_type: input.contentType, content_key: input.contentKey }, + { onConflict: "user_id,content_type,content_key", ignoreDuplicates: true }, + ); + if (error) throw new Error(error.message); + } else { + const { error } = await supabase + .from("user_favourites") + .delete() + .eq("user_id", user.id) + .eq("content_type", input.contentType) + .eq("content_key", input.contentKey); + if (error) throw new Error(error.message); + } + + return Response.json({ saved: input.saved }); + } catch (error) { + return jsonError(error); + } +} + +export async function DELETE(request: Request) { + try { + const supabase = createAdminClient(); + const user = await requireAuthenticatedUser(request, supabase); + const { error } = await supabase.from("user_favourites").delete().eq("user_id", user.id); + if (error) throw new Error(error.message); + return Response.json({ cleared: true }); + } catch (error) { + return jsonError(error); + } +} diff --git a/src/app/api/account/preferences/route.ts b/src/app/api/account/preferences/route.ts new file mode 100644 index 000000000..30cc78b7b --- /dev/null +++ b/src/app/api/account/preferences/route.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +import { normalizePreferences } from "@/lib/account-preferences"; +import { jsonError } from "@/lib/http"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { requireAuthenticatedUser } from "@/lib/supabase/auth"; +import { parseJsonBody } from "@/lib/validation/body"; + +export const runtime = "nodejs"; + +const preferencesSchema = z + .object({ + density: z.enum(["comfortable", "compact", "spacious"]), + motion: z.enum(["system", "reduced"]), + jurisdiction: z.enum(["wa", "nsw", "vic", "qld", "sa", "tas", "act", "nt", "national"]), + population: z.enum(["adults", "older-adults", "adolescents", "all"]), + answerStyle: z.enum(["conservative", "balanced", "comprehensive"]), + landing: z.enum(["ask", "search", "browse"]), + showRecentOnHome: z.boolean(), + showProtocolsOnHome: z.boolean(), + compactCitations: z.boolean(), + notifyGuidelineUpdates: z.boolean(), + notifyProductNews: z.boolean(), + notifySavedChanges: z.boolean(), + }) + .strict(); + +export async function GET(request: Request) { + try { + const supabase = createAdminClient(); + const user = await requireAuthenticatedUser(request, supabase); + const { data, error } = await supabase + .from("user_preferences") + .select("preferences,updated_at") + .eq("user_id", user.id) + .maybeSingle(); + if (error) throw new Error(error.message); + return Response.json({ + preferences: data ? normalizePreferences(data.preferences) : null, + updatedAt: data?.updated_at, + }); + } catch (error) { + return jsonError(error); + } +} + +export async function PUT(request: Request) { + try { + const supabase = createAdminClient(); + const user = await requireAuthenticatedUser(request, supabase); + const preferences = await parseJsonBody(request, preferencesSchema, "Account preferences are invalid."); + const { error } = await supabase.from("user_preferences").upsert({ + user_id: user.id, + preferences, + updated_at: new Date().toISOString(), + }); + if (error) throw new Error(error.message); + return Response.json({ preferences }); + } catch (error) { + return jsonError(error); + } +} diff --git a/src/app/api/documents/[id]/labels/route.ts b/src/app/api/documents/[id]/labels/route.ts index 6cba3a086..ffef4f08e 100644 --- a/src/app/api/documents/[id]/labels/route.ts +++ b/src/app/api/documents/[id]/labels/route.ts @@ -110,7 +110,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const normalized = parseManualLabel(parsed); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const rateLimit = await consumeApiRateLimit({ supabase, @@ -178,7 +178,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const parsed = await parseJsonBody(request, labelPatchSchema, "Enter a manual tag or label review action."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const rateLimit = await consumeApiRateLimit({ supabase, @@ -287,7 +287,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i const parsed = await parseJsonBody(request, manualLabelDeleteSchema, "Choose a manual tag to remove."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const rateLimit = await consumeApiRateLimit({ supabase, diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index d0f767cfe..6dc499334 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -40,7 +40,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const { id: rawId } = await params; const { id } = parseRouteParams({ id: rawId }, reindexRouteParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const mode = await readMode(request); const rateLimit = await consumeApiRateLimit({ supabase, ownerId: user.id, bucket: "document_reindex" }); if (rateLimit.limited) diff --git a/src/app/api/documents/[id]/reviews/route.ts b/src/app/api/documents/[id]/reviews/route.ts index 0c234bcf1..c761381e4 100644 --- a/src/app/api/documents/[id]/reviews/route.ts +++ b/src/app/api/documents/[id]/reviews/route.ts @@ -54,7 +54,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: "Source reviews are unavailable in demo mode." }, { status: 400 }); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const body = await parseJsonBody(request, bodySchema, "Invalid source review."); const rateLimit = await consumeApiRateLimit({ supabase, ownerId: user.id, bucket: "source_review" }); if (rateLimit.limited) return rateLimitJsonResponse("Too many source review requests. Retry shortly.", rateLimit); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 455cacfcf..1a5674a0e 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -139,7 +139,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const body = await parseJsonBody(request, renameSchema, "Enter a document title between 1 and 180 characters."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const { data: document, error: documentError } = await supabase .from("documents") .select("id,owner_id,title,file_name,storage_path,content_hash,metadata") @@ -200,7 +200,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const { data, error } = await supabase.rpc("delete_document_if_idle", { p_document_id: id, p_owner_id: user.id, diff --git a/src/app/api/documents/[id]/summarize/route.ts b/src/app/api/documents/[id]/summarize/route.ts index 976af5a39..45432a249 100644 --- a/src/app/api/documents/[id]/summarize/route.ts +++ b/src/app/api/documents/[id]/summarize/route.ts @@ -35,7 +35,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: } const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const rateLimit = await consumeApiRateLimit({ supabase, ownerId: user.id, bucket: "document_summarize" }); if (rateLimit.limited) return rateLimitJsonResponse("Too many document summary requests. Retry shortly.", rateLimit); diff --git a/src/app/api/documents/[id]/table-facts/route.ts b/src/app/api/documents/[id]/table-facts/route.ts index 8481cb753..e0f1d3a17 100644 --- a/src/app/api/documents/[id]/table-facts/route.ts +++ b/src/app/api/documents/[id]/table-facts/route.ts @@ -7,6 +7,7 @@ import { invalidateRagCachesForOwner } 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 { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { tableReviewMetadata, tableReviewSchema } from "@/lib/table-review"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; @@ -44,19 +45,16 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: if (isDemoMode()) return NextResponse.json({ tableFacts: [], demoMode: true }); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - - const rateLimit = await consumeApiRateLimit({ - supabase, - ownerId: user.id, - bucket: "document_admin", - allowInMemoryFallbackOnUnavailable: true, - }); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); if (rateLimit.limited) { - return rateLimitJsonResponse("Too many document administration requests. Retry shortly.", rateLimit); + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); } - const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id }); + const { data: document, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").eq("id", id), + access.ownerId, + ).maybeSingle(); + if (documentError) throw new Error(documentError.message); if (!document) { return NextResponse.json({ error: "Document not found." }, { status: 404 }); } @@ -69,11 +67,23 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: .order("page_number", { ascending: true }) .order("created_at", { ascending: true }); if (error) throw new Error(error.message); - return NextResponse.json({ - tableFacts: (data ?? []).filter((fact) => - isCommittedGenerationMetadata({ rowMetadata: fact.metadata, committedGeneration }), - ), - }); + const tableFacts = (data ?? []) + .filter((fact) => isCommittedGenerationMetadata({ rowMetadata: fact.metadata, committedGeneration })) + .map((fact) => ({ + id: fact.id, + document_id: fact.document_id, + page_number: fact.page_number, + table_title: fact.table_title, + row_label: fact.row_label, + clinical_parameter: fact.clinical_parameter, + threshold_value: fact.threshold_value, + action: fact.action, + normalized_terms: fact.normalized_terms, + source_chunk_id: fact.source_chunk_id, + source_image_id: fact.source_image_id, + created_at: fact.created_at, + })); + return NextResponse.json({ tableFacts }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); @@ -89,7 +99,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const parsed = await parseJsonBody(request, updateSchema, "Table review payload is invalid."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const rateLimit = await consumeApiRateLimit({ supabase, diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 386b77f41..16630452d 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -31,7 +31,7 @@ export async function POST(request: Request) { const parsed = await parseJsonBody(request, bulkReindexSchema, "Bulk reindex payload is invalid."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const rateLimit = await consumeApiRateLimit({ supabase, ownerId: user.id, bucket: "bulk_reindex" }); if (rateLimit.limited) return rateLimitJsonResponse("Too many bulk reindex requests. Retry shortly.", rateLimit); diff --git a/src/app/api/documents/bulk/route.ts b/src/app/api/documents/bulk/route.ts index 1223d7087..5bc1803de 100644 --- a/src/app/api/documents/bulk/route.ts +++ b/src/app/api/documents/bulk/route.ts @@ -129,7 +129,7 @@ export async function POST(request: Request) { const parsed = await parseJsonBody(request, bulkMetadataSchema, "Bulk edit payload is invalid."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const rateLimit = await consumeApiRateLimit({ supabase, diff --git a/src/app/api/eval-cases/route.ts b/src/app/api/eval-cases/route.ts index c6bf7cbb5..5f2236eb7 100644 --- a/src/app/api/eval-cases/route.ts +++ b/src/app/api/eval-cases/route.ts @@ -124,7 +124,7 @@ export async function POST(request: Request) { const parsed = await parseJsonBody(request, evalCaptureSchema, "Eval capture payload is invalid."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const rateLimit = await consumeApiRateLimit({ supabase, diff --git a/src/app/api/ingestion/batches/route.ts b/src/app/api/ingestion/batches/route.ts index ed6e93ecc..f6e181d75 100644 --- a/src/app/api/ingestion/batches/route.ts +++ b/src/app/api/ingestion/batches/route.ts @@ -59,7 +59,7 @@ export async function GET(request: Request) { } const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const { data, error, count } = await supabase .from("import_batches") .select("*", { count: "exact" }) diff --git a/src/app/api/ingestion/jobs/[id]/retry/route.ts b/src/app/api/ingestion/jobs/[id]/retry/route.ts index bef50a2bd..93f70eed5 100644 --- a/src/app/api/ingestion/jobs/[id]/retry/route.ts +++ b/src/app/api/ingestion/jobs/[id]/retry/route.ts @@ -27,7 +27,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const { id: rawId } = await params; const { id } = parseRouteParams({ id: rawId }, ingestionRetryRouteParamsSchema, "Invalid ingestion job id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const staleThreshold = new Date(Date.now() - env.WORKER_STALE_AFTER_MINUTES * 60_000).toISOString(); const resetNextRunAt = ingestionRollbackFenceStamp(); diff --git a/src/app/api/ingestion/jobs/route.ts b/src/app/api/ingestion/jobs/route.ts index 1505222b9..30302701c 100644 --- a/src/app/api/ingestion/jobs/route.ts +++ b/src/app/api/ingestion/jobs/route.ts @@ -61,7 +61,7 @@ export async function GET(request: Request) { } const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); let query = supabase .from("ingestion_jobs") diff --git a/src/app/api/ingestion/quality/route.ts b/src/app/api/ingestion/quality/route.ts index a83a54f22..3d5d226c3 100644 --- a/src/app/api/ingestion/quality/route.ts +++ b/src/app/api/ingestion/quality/route.ts @@ -321,7 +321,7 @@ export async function GET(request: Request) { if (isDemoMode()) return NextResponse.json({ items: [], demoMode: true }); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const rateLimit = await consumeApiRateLimit({ supabase, diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts index 2601c061d..7715e6c14 100644 --- a/src/app/api/jobs/route.ts +++ b/src/app/api/jobs/route.ts @@ -64,7 +64,7 @@ export async function GET(request: Request) { } const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const user = await requireAuthenticatedUser(request, supabase, { administrator: true }); const { data, error, count } = await supabase .from("ingestion_jobs") .select("*, documents!inner(title,file_name,status)", { count: "exact" }) diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index e45fe0e9a..adcf7d111 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { env, isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { allowDeepHealthProbe } from "@/lib/deep-probe-auth"; +import { PublicApiError } from "@/lib/http"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser } from "@/lib/supabase/auth"; @@ -446,12 +447,14 @@ export async function GET(request: Request) { if (!authorizedForDetail && request.headers.has("authorization")) { try { - await requireAuthenticatedUser(request, createAdminClient()); + await requireAuthenticatedUser(request, createAdminClient(), { administrator: true }); authorizedForDetail = true; } catch (error) { - if (!(error instanceof AuthenticationError)) throw error; - // Invalid or expired credentials receive the same coarse posture as an - // anonymous caller; setup status is not an authentication oracle. + const expectedAuthorizationFailure = + error instanceof AuthenticationError || (error instanceof PublicApiError && error.status === 403); + if (!expectedAuthorizationFailure) throw error; + // Invalid, expired, and non-administrator credentials receive the same + // coarse posture as an anonymous caller; setup status is not an auth or role oracle. } } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index cbcfc9cd9..8451ae0a6 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -2,15 +2,14 @@ import { randomUUID } from "node:crypto"; import { createHash } from "node:crypto"; import { NextResponse } from "next/server"; import { z } from "zod"; -import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env"; +import { env } from "@/lib/env"; import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext } from "@/lib/public-api-access"; +import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { probeSupabaseHealth } from "@/lib/supabase/health"; import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data"; import { acquireUploadAdmission, parseUploadContentLength } from "@/lib/upload-admission"; @@ -95,21 +94,12 @@ export async function POST(request: Request) { try { 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 }); - } + const administrator = await requireAuthenticatedUser(request, adminSupabase, { administrator: true }); + const uploadOwnerId = administrator.id; const rateLimit = await consumeSubjectApiRateLimit({ supabase: adminSupabase, - subject: access.rateLimitSubject, + subject: { kind: "owner", ownerId: administrator.id }, bucket: "document_upload", allowInMemoryFallbackOnUnavailable: false, }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2037b32c5..7d9a185a0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { AuthProvider } from "@/lib/supabase/client"; +import { AccountDataProvider } from "@/components/account-data-provider"; import { PwaLifecycle } from "@/components/pwa-lifecycle"; import { WebVitalsReporter } from "@/components/web-vitals-reporter"; import { resolveMetadataBase } from "@/lib/metadata-base"; @@ -101,7 +102,9 @@ export default async function RootLayout({ - {children} + + {children} + ); diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index ebb5761be..0e6e9c62e 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -36,12 +36,8 @@ import { } from "react"; import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { extractSafetyFindings } from "@/lib/clinical-safety"; -import { - isLocalNoAuthMode, - publicUploadsEnabled, - resolveClientDemoMode, - resolveUploadReadOnlyMode, -} from "@/lib/client-env"; +import { isLocalNoAuthMode, resolveClientDemoMode, resolveUploadReadOnlyMode } from "@/lib/client-env"; +import { isAdministratorUser } from "@/lib/authorization"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { @@ -866,8 +862,8 @@ export function ClinicalDashboard({ setAnswerThreadBootstrapped(true); }); }, [answerThreadOwnerId, authStatus]); - // Local no-auth still has private upload APIs (`canUsePrivateApis`); do not lock the - // upload drawer just because `resolveClientDemoMode` treats no-auth as demo for favourites. + // Local no-auth can still exercise public-read APIs, but administration is always + // derived separately from the immutable account role claim. const uploadReadOnlyMode = resolveUploadReadOnlyMode({ explicitDemoMode, authUnavailableFallback: browserAuthUnavailableDemoFallback, @@ -879,7 +875,9 @@ export function ClinicalDashboard({ const canUseNonProductionDemoFallback = localProjectReady && hasNonProductionSupabaseApiKeyFallback(setupChecks); const canUsePrivateApis = localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); - const canUploadDocuments = canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis); + const isAdministrator = isAdministratorUser(auth.session?.user); + const canUseAdministrativeApis = localProjectReady && isAdministrator; + const canUploadDocuments = canUseAdministrativeApis && canUsePublicSearchApis; const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady; const canRunSearch = explicitDemoMode || @@ -926,6 +924,17 @@ export function ClinicalDashboard({ }, [router]); const openLibraryHealthTarget = useCallback( (target: LibraryHealthTarget) => { + if (!canUseAdministrativeApis) { + closeDashboardTransientSurfaces("documents"); + setDocumentsDrawerMode("library"); + setDocumentsDrawerOpen(true); + setActionNotice({ + tone: "warning", + message: "Library health and indexing controls are administrator-only.", + }); + return; + } + const targetId = target === "documents" ? "dashboard-documents-drawer" @@ -959,7 +968,7 @@ export function ClinicalDashboard({ document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth", block: "start" }); }, 0); }, - [closeDashboardTransientSurfaces], + [canUseAdministrativeApis, closeDashboardTransientSurfaces], ); useEffect(() => { @@ -1561,7 +1570,8 @@ export function ClinicalDashboard({ ); const needsSetupRecheck = useMemo(() => setupNeedsSlowRecheck(setupChecks), [setupChecks]); const dashboardDataSurfaceVisible = documentScopeOpen || documentsDrawerOpen || uploadDrawerOpen; - const administrationSurfaceVisible = uploadDrawerOpen || (documentsDrawerOpen && documentsDrawerMode === "admin"); + const administrationSurfaceVisible = + canUseAdministrativeApis && (uploadDrawerOpen || (documentsDrawerOpen && documentsDrawerMode === "admin")); useEffect(() => { dashboardDataLoadedRef.current = false; @@ -2846,7 +2856,7 @@ export function ClinicalDashboard({ } function openUploadDrawer() { - if (!canUsePrivateApis) { + if (!canUseAdministrativeApis) { openDocumentsDrawer("library"); setActionNotice({ tone: "warning", @@ -3339,7 +3349,7 @@ export function ClinicalDashboard({ setUploadMobileTab("jobs"); void refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); }; - const documentsDrawerIsAdmin = documentsDrawerMode === "admin" && canUsePrivateApis; + const documentsDrawerIsAdmin = documentsDrawerMode === "admin" && canUseAdministrativeApis; const documentsDrawerTitle = documentsDrawerMode === "recent" ? "Recent documents" @@ -4022,7 +4032,7 @@ export function ClinicalDashboard({ onBulkMetadataUpdate={bulkUpdateMetadata} bulkActionStatus={bulkActionStatus} bulkActionBusy={bulkActionBusy} - canManageDocuments={canUsePrivateApis} + canManageDocuments={canUseAdministrativeApis} onTagSearch={handleTagSearch} onMutateLabel={mutateDocumentLabel} /> diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index b50f41bd6..53658c1f1 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -78,6 +78,7 @@ import { import { formatClinicalDate } from "@/lib/source-metadata"; import { partitionViewerImages } from "@/lib/image-filtering"; import { isLocalNoAuthMode } from "@/lib/client-env"; +import { isAdministratorUser } from "@/lib/authorization"; import { useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; import { DocumentManagementActions } from "@/components/DocumentManagementActions"; @@ -1781,6 +1782,7 @@ export function DocumentViewer({ ); const { status: authStatus, + session, isConfigured, authorizationHeader, registerAuthRequest, @@ -1795,6 +1797,8 @@ export function DocumentViewer({ const clientDemoMode = localNoAuthMode || serverDemoMode; const canViewSourceDocuments = localProjectReady; const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); + const canUseAdministrativeApis = + localProjectReady && (serverDemoMode || (authStatus === "authenticated" && isAdministratorUser(session?.user))); useEffect(() => { if (authStatus !== "loading") { @@ -2651,18 +2655,19 @@ export function DocumentViewer({ Add to scope -
- - Admin controls - - -
+ {canUseAdministrativeApis ? ( +
+ + Admin controls + + +
+ ) : null} ) : null} @@ -2980,14 +2985,14 @@ export function DocumentViewer({ /> ) : null} - {canUsePrivateApis ? ( + {canUseAdministrativeApis ? (
Document tools
- {canUsePrivateApis && tableFacts.length ? ( + {canUseAdministrativeApis && tableFacts.length ? (
Table tools @@ -3024,7 +3029,7 @@ export function DocumentViewer({
diff --git a/src/components/account-data-provider.tsx b/src/components/account-data-provider.tsx new file mode 100644 index 000000000..578fa76f3 --- /dev/null +++ b/src/components/account-data-provider.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; + +import { useAuthSession } from "@/lib/supabase/client"; +import { + readSavedRegistrySlugs, + savedDifferentialsStorageKey, + savedFormsStorageKey, + savedServicesStorageKey, + subscribeSavedRegistrySlugs, + writeSavedRegistrySlugs, +} from "@/lib/saved-registry-storage"; + +export type FavouriteContentType = "service" | "form" | "differential"; + +type FavouritesByType = Record; + +const emptyFavourites: FavouritesByType = { service: [], form: [], differential: [] }; +const storageKeyByType = { + service: savedServicesStorageKey, + form: savedFormsStorageKey, + differential: savedDifferentialsStorageKey, +} satisfies Record; +const demoAccountData = process.env.NEXT_PUBLIC_DEMO_MODE === "true"; + +function readDemoFavourites(): FavouritesByType { + return { + service: readSavedRegistrySlugs(savedServicesStorageKey), + form: readSavedRegistrySlugs(savedFormsStorageKey), + differential: readSavedRegistrySlugs(savedDifferentialsStorageKey), + }; +} + +type AccountDataContextValue = { + favourites: FavouritesByType; + ready: boolean; + error: string | null; + isSaved: (contentType: FavouriteContentType, contentKey: string) => boolean; + setFavourite: (contentType: FavouriteContentType, contentKey: string, saved: boolean) => Promise; + clearFavourites: () => Promise; +}; + +const unavailableAccountData: AccountDataContextValue = { + favourites: emptyFavourites, + ready: true, + error: null, + isSaved: () => false, + setFavourite: async () => false, + clearFavourites: async () => false, +}; + +const AccountDataContext = createContext(unavailableAccountData); + +function normalizedFavourites(value: unknown): FavouritesByType { + const rows = Array.isArray(value) ? value : []; + const result: FavouritesByType = { service: [], form: [], differential: [] }; + for (const row of rows) { + if (!row || typeof row !== "object") continue; + const contentType = (row as { contentType?: unknown }).contentType; + const contentKey = (row as { contentKey?: unknown }).contentKey; + if ( + (contentType === "service" || contentType === "form" || contentType === "differential") && + typeof contentKey === "string" && + contentKey.trim() + ) { + result[contentType].push(contentKey.trim()); + } + } + return result; +} + +export function AccountDataProvider({ children }: { children: ReactNode }) { + const auth = useAuthSession(); + const [favourites, setFavourites] = useState(emptyFavourites); + const [ready, setReady] = useState(auth.status !== "authenticated"); + const [error, setError] = useState(null); + + useEffect(() => { + if (auth.status !== "authenticated") { + const refreshDemoFavourites = () => setFavourites(demoAccountData ? readDemoFavourites() : emptyFavourites); + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + refreshDemoFavourites(); + setReady(true); + setError(null); + }); + const unsubscribe = demoAccountData ? subscribeSavedRegistrySlugs(refreshDemoFavourites) : undefined; + return () => { + cancelled = true; + unsubscribe?.(); + }; + } + + const controller = new AbortController(); + queueMicrotask(() => { + if (!controller.signal.aborted) setReady(false); + }); + fetch("/api/account/favourites", { + cache: "no-store", + headers: auth.authorizationHeader, + signal: controller.signal, + }) + .then(async (response) => { + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message ?? payload.error ?? "Saved items could not be loaded."); + setFavourites(normalizedFavourites(payload.favourites)); + setError(null); + }) + .catch((cause) => { + if (cause instanceof DOMException && cause.name === "AbortError") return; + setFavourites(emptyFavourites); + setError(cause instanceof Error ? cause.message : "Saved items could not be loaded."); + }) + .finally(() => { + if (!controller.signal.aborted) setReady(true); + }); + + return () => controller.abort(); + }, [auth.authEpoch, auth.authorizationHeader, auth.status]); + + const setFavourite = useCallback( + async (contentType: FavouriteContentType, contentKey: string, saved: boolean) => { + if (auth.status !== "authenticated") { + if (demoAccountData) { + const current = favourites[contentType]; + return writeSavedRegistrySlugs( + storageKeyByType[contentType], + saved + ? [contentKey, ...current.filter((item) => item !== contentKey)] + : current.filter((item) => item !== contentKey), + ); + } + setError("Sign in or create an account to save favourites."); + return false; + } + + const key = contentKey.trim(); + if (!key) return false; + const previous = favourites; + setFavourites((current) => ({ + ...current, + [contentType]: saved + ? [key, ...current[contentType].filter((item) => item !== key)] + : current[contentType].filter((item) => item !== key), + })); + + const response = await fetch("/api/account/favourites", { + method: "PUT", + headers: { "Content-Type": "application/json", ...auth.authorizationHeader }, + body: JSON.stringify({ contentType, contentKey: key, saved }), + }).catch(() => null); + if (!response?.ok) { + setFavourites(previous); + const payload = await response?.json().catch(() => ({})); + setError(payload?.message ?? payload?.error ?? "Saved items could not be updated."); + if (response?.status === 401) auth.markSessionExpired(); + return false; + } + setError(null); + return true; + }, + [auth, favourites], + ); + + const clearFavourites = useCallback(async () => { + if (auth.status !== "authenticated") { + if (!demoAccountData) return false; + return (Object.values(storageKeyByType) as string[]).every((key) => writeSavedRegistrySlugs(key, [])); + } + const previous = favourites; + setFavourites(emptyFavourites); + const response = await fetch("/api/account/favourites", { + method: "DELETE", + headers: auth.authorizationHeader, + }).catch(() => null); + if (!response?.ok) { + setFavourites(previous); + setError("Saved items could not be cleared."); + if (response?.status === 401) auth.markSessionExpired(); + return false; + } + setError(null); + return true; + }, [auth, favourites]); + + const value = useMemo( + () => ({ + favourites, + ready, + error, + isSaved: (contentType, contentKey) => favourites[contentType].includes(contentKey), + setFavourite, + clearFavourites, + }), + [clearFavourites, error, favourites, ready, setFavourite], + ); + + return {children}; +} + +export function useAccountData() { + return useContext(AccountDataContext); +} diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index b8cdc4766..5cd55ef8a 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -46,6 +46,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { useAuthSession } from "@/lib/supabase/client"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; type FavouriteType = @@ -1016,6 +1017,7 @@ function ItemWorkspace({ item, onClose }: { item: FavouriteItem; onClose: () => export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: string; demoMode: boolean }) { const router = useRouter(); const command = useSearchCommand(); + const auth = useAuthSession(); const [navCollapsed, setNavCollapsed] = useFavouritesNavCollapsed(); const savedRegistryFavourites = useSavedRegistryFavourites(); const items = useMemo( @@ -1107,6 +1109,15 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?:
+ {!demoMode && auth.status !== "authenticated" && auth.status !== "loading" ? ( +

+ Sign in or create an account from Account settings to save favourites and access them across devices. +

+ ) : null} +
total + items.length, 0); const [settingsEmail, setSettingsEmail] = useState(""); const [emailEntryOpen, setEmailEntryOpen] = useState(false); const [settingsEmailAttempted, setSettingsEmailAttempted] = useState(false); @@ -224,8 +216,13 @@ export function SettingsDialog({ setAccountNotice(null); } - function chooseSettingsProvider(provider: string) { - setAccountNotice(`${provider} sign-in is a placeholder for now. Continue with email to use this workspace.`); + async function chooseSettingsProvider(provider: "Apple" | "Google" | "Microsoft") { + setAccountNotice(null); + if (provider === "Apple") { + setAccountNotice("Apple sign-in is not configured. Continue with email, Google, or Microsoft."); + return; + } + await auth.signInWithOAuth(provider === "Google" ? "google" : "azure"); } function handleClearRecent() { @@ -234,12 +231,9 @@ export function SettingsDialog({ setPrivacyNotice("Recent searches cleared."); } - function handleClearSaved() { - writeSavedRegistrySlugs(savedServicesStorageKey, []); - writeSavedRegistrySlugs(savedFormsStorageKey, []); - writeSavedRegistrySlugs(savedDifferentialsStorageKey, []); - refreshDataCounts(); - setPrivacyNotice("Saved items cleared."); + async function handleClearSaved() { + const cleared = await accountData.clearFavourites(); + setPrivacyNotice(cleared ? "Saved items cleared." : "Sign in to clear account favourites."); } function handleResetPreferences() { @@ -446,9 +440,12 @@ export function SettingsDialog({
- chooseSettingsProvider("Apple")} /> - chooseSettingsProvider("Google")} /> - chooseSettingsProvider("Microsoft")} /> + void chooseSettingsProvider("Apple")} /> + void chooseSettingsProvider("Google")} /> + void chooseSettingsProvider("Microsoft")} + />
@@ -457,7 +454,7 @@ export function SettingsDialog({ aria-hidden="true" className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[color:var(--text-soft)]" /> - Accounts save preferences and search history. Do not enter PHI. + Accounts sync favourites and preferences across signed-in devices. Do not enter PHI.

{(accountNotice || !auth.isConfigured || (settingsEmailAttempted && auth.error)) && ( @@ -691,10 +688,10 @@ export function SettingsDialog({ 0 ? `${dataCounts.saved} saved` : "None"} + meta={savedCount > 0 ? `${savedCount} saved` : "None"} actionLabel="Clear saved items" onClick={handleClearSaved} - disabled={dataCounts.saved === 0} + disabled={savedCount === 0} /> = {}; +const ignoreExpiredSession = () => undefined; + +function useAuthSessionIfAvailable() { + try { + return useAuthSession(); + } catch (error) { + if (error instanceof Error && error.message === "useAuthSession must be used within AuthProvider.") return null; + throw error; + } +} /** * App-wide, non-clinical preferences persisted per browser. This mirrors the @@ -226,21 +239,78 @@ export function readAppPreferences(): AppPreferences { } export function useAppPreferences() { + const auth = useAuthSessionIfAvailable(); + const authStatus = auth?.status ?? "signed_out"; + const authorizationHeader = auth?.authorizationHeader ?? emptyAuthorizationHeader; + const authEpoch = auth?.authEpoch ?? 0; + const markSessionExpired = auth?.markSessionExpired ?? ignoreExpiredSession; const preferences = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + useEffect(() => { + if (authStatus !== "authenticated") return; + const controller = new AbortController(); + fetch("/api/account/preferences", { + cache: "no-store", + headers: authorizationHeader, + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) { + if (response.status === 401) markSessionExpired(); + return; + } + const payload = await response.json().catch(() => ({})); + if (payload.preferences) { + persist(normalizePreferences(payload.preferences)); + return; + } + const bootstrapResponse = await fetch("/api/account/preferences", { + method: "PUT", + headers: { "Content-Type": "application/json", ...authorizationHeader }, + body: JSON.stringify(getSnapshot()), + signal: controller.signal, + }); + if (bootstrapResponse.status === 401) markSessionExpired(); + }) + .catch(() => undefined); + return () => controller.abort(); + }, [authEpoch, authStatus, authorizationHeader, markSessionExpired]); + useEffect(() => { applyPreferenceSideEffects(preferences); }, [preferences]); - const setPreference = useCallback((key: Key, value: AppPreferences[Key]) => { - const current = getSnapshot(); - if (current[key] === value) return; - persist({ ...current, [key]: value }); - }, []); + const persistAccountPreferences = useCallback( + (next: AppPreferences) => { + if (authStatus !== "authenticated") return; + fetch("/api/account/preferences", { + method: "PUT", + headers: { "Content-Type": "application/json", ...authorizationHeader }, + body: JSON.stringify(next), + }) + .then((response) => { + if (response.status === 401) markSessionExpired(); + }) + .catch(() => undefined); + }, + [authStatus, authorizationHeader, markSessionExpired], + ); + + const setPreference = useCallback( + (key: Key, value: AppPreferences[Key]) => { + const current = getSnapshot(); + if (current[key] === value) return; + const next = { ...current, [key]: value }; + persist(next); + persistAccountPreferences(next); + }, + [persistAccountPreferences], + ); const resetPreferences = useCallback(() => { persist(DEFAULT_PREFERENCES); - }, []); + persistAccountPreferences(DEFAULT_PREFERENCES); + }, [persistAccountPreferences]); return { preferences, setPreference, resetPreferences }; } diff --git a/src/components/clinical-dashboard/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index e645be8c3..98fd7e3be 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -2,17 +2,11 @@ import { BrainCircuit, ClipboardList } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; +import { useAccountData } from "@/components/account-data-provider"; import type { FavouriteItem } from "@/components/clinical-dashboard/favourites-prototype-data"; import type { ServiceRecord } from "@/lib/services"; -import { - readSavedRegistrySlugs, - savedDifferentialsStorageKey, - savedFormsStorageKey, - savedServicesStorageKey, - subscribeSavedRegistrySlugs, -} from "@/lib/saved-registry-storage"; import { useRegistryRecords } from "@/lib/use-registry-records"; function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): FavouriteItem { @@ -31,19 +25,10 @@ function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): F } export function useSavedRegistryFavourites(): FavouriteItem[] { - const [savedServices, setSavedServices] = useState([]); - const [savedForms, setSavedForms] = useState([]); - const [savedDifferentials, setSavedDifferentials] = useState([]); - - useEffect(() => { - const refresh = () => { - setSavedServices(readSavedRegistrySlugs(savedServicesStorageKey)); - setSavedForms(readSavedRegistrySlugs(savedFormsStorageKey)); - setSavedDifferentials(readSavedRegistrySlugs(savedDifferentialsStorageKey)); - }; - refresh(); - return subscribeSavedRegistrySlugs(refresh); - }, []); + const { favourites } = useAccountData(); + const savedServices = favourites.service; + const savedForms = favourites.form; + const savedDifferentials = favourites.differential; const services = useRegistryRecords("service", { enabled: savedServices.length > 0 }); const forms = useRegistryRecords("form", { enabled: savedForms.length > 0 }); diff --git a/src/components/differentials/differential-detail-page.tsx b/src/components/differentials/differential-detail-page.tsx index 12e8d4dd8..729ca80bc 100644 --- a/src/components/differentials/differential-detail-page.tsx +++ b/src/components/differentials/differential-detail-page.tsx @@ -47,11 +47,7 @@ import { type DifferentialSafetyFact, } from "@/lib/differential-detail"; import type { DifferentialRecord, DifferentialSection } from "@/lib/differentials"; -import { - readSavedRegistrySlugs, - savedDifferentialsStorageKey, - writeSavedRegistrySlugs, -} from "@/lib/saved-registry-storage"; +import { useAccountData } from "@/components/account-data-provider"; const sectionIcons: Record = { fit: CircleCheck, @@ -933,7 +929,9 @@ export function DifferentialDetailPage({ }) { const [activeTab, setActiveTab] = useState("overview"); const [openSections, setOpenSections] = useState>(() => new Set()); - const [saved, setSaved] = useState(false); + const accountData = useAccountData(); + const saved = accountData.isSaved("differential", record.slug); + const [saveNotice, setSaveNotice] = useState(null); const expandableSectionIds = useMemo( () => @@ -952,13 +950,6 @@ export function DifferentialDetailPage({ } }, []); - useEffect(() => { - // Hydrate the saved flag from localStorage after mount (SSR-safe: the - // server always renders the unsaved state). - // eslint-disable-next-line react-hooks/set-state-in-effect - setSaved(readSavedRegistrySlugs(savedDifferentialsStorageKey).includes(record.slug)); - }, [record.slug]); - const changeTab = useCallback((id: DifferentialDetailTabId) => { setActiveTab(id); const url = new URL(window.location.href); @@ -981,10 +972,16 @@ export function DifferentialDetailPage({ setOpenSections(allOpen ? new Set() : new Set(expandableSectionIds)); } - function toggleSaved() { - const slugs = readSavedRegistrySlugs(savedDifferentialsStorageKey); - const next = saved ? slugs.filter((slug) => slug !== record.slug) : [...new Set([record.slug, ...slugs])]; - if (writeSavedRegistrySlugs(savedDifferentialsStorageKey, next)) setSaved(!saved); + async function toggleSaved() { + const nowSaved = !saved; + const updated = await accountData.setFavourite("differential", record.slug, nowSaved); + setSaveNotice( + updated + ? nowSaved + ? "Diagnosis saved." + : "Diagnosis removed from saved items." + : "Sign in or create an account to save diagnoses.", + ); } const hasMustNotMiss = record.sections.some((section) => section.id === "must-not-miss"); @@ -1041,6 +1038,12 @@ export function DifferentialDetailPage({
+ {saveNotice ? ( +

+ {saveNotice} +

+ ) : null} +
- typeof window === "undefined" ? false : readSavedRegistrySlugs(savedFormsStorageKey).includes(form.slug), - ); + const accountData = useAccountData(); + const saved = accountData.isSaved("form", form.slug); const [notice, setNotice] = useState(null); const code = formCode(form); const details = formCatalogDetails(form); @@ -511,16 +510,13 @@ export function FormDetailPage({ form }: { form: FormRecord }) { } } - function toggleSaved() { + async function toggleSaved() { try { - const current = readSavedRegistrySlugs(savedFormsStorageKey); - const next = current.includes(form.slug) ? current.filter((item) => item !== form.slug) : [form.slug, ...current]; - if (!writeSavedRegistrySlugs(savedFormsStorageKey, next)) { - setNotice("Save failed"); + const nowSaved = !saved; + if (!(await accountData.setFavourite("form", form.slug, nowSaved))) { + setNotice("Sign in or create an account to save forms"); return; } - const nowSaved = next.includes(form.slug); - setSaved(nowSaved); setNotice(nowSaved ? "Form saved" : "Form removed from saved items"); } catch { setNotice("Save failed"); diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 77996ed25..0d1ae76b8 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -51,7 +51,7 @@ import { type ServiceStatusChip, type ServiceSummaryCard, } from "@/lib/service-ranker"; -import { readSavedRegistrySlugs, savedServicesStorageKey, writeSavedRegistrySlugs } from "@/lib/saved-registry-storage"; +import { useAccountData } from "@/components/account-data-provider"; const missingText = "Not listed"; @@ -421,9 +421,8 @@ function TagList({ items, emptyLabel }: { items: string[]; emptyLabel: string }) export function ServiceDetailPage({ service }: { service: ServiceRecord }) { const router = useRouter(); - const [saved, setSaved] = useState(() => - typeof window === "undefined" ? false : readSavedRegistrySlugs(savedServicesStorageKey).includes(service.slug), - ); + const accountData = useAccountData(); + const saved = accountData.isSaved("service", service.slug); const [notice, setNotice] = useState(null); const primaryContact = hasText(service.primaryContact?.value) ? service.primaryContact @@ -484,18 +483,13 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { } } - function toggleSaved() { + async function toggleSaved() { try { - const current = readSavedRegistrySlugs(savedServicesStorageKey); - const next = current.includes(service.slug) - ? current.filter((item) => item !== service.slug) - : [service.slug, ...current]; - if (!writeSavedRegistrySlugs(savedServicesStorageKey, next)) { - setNotice("Save failed"); + const nowSaved = !saved; + if (!(await accountData.setFavourite("service", service.slug, nowSaved))) { + setNotice("Sign in or create an account to save services"); return; } - const nowSaved = next.includes(service.slug); - setSaved(nowSaved); setNotice(nowSaved ? "Service saved" : "Service removed from saved items"); } catch { setNotice("Save failed"); diff --git a/src/lib/account-preferences.ts b/src/lib/account-preferences.ts new file mode 100644 index 000000000..bc3171a8f --- /dev/null +++ b/src/lib/account-preferences.ts @@ -0,0 +1,118 @@ +export type DensityPreference = "comfortable" | "compact" | "spacious"; +export type MotionPreference = "system" | "reduced"; +export type PopulationPreference = "adults" | "older-adults" | "adolescents" | "all"; +export type AnswerStylePreference = "conservative" | "balanced" | "comprehensive"; +export type LandingPreference = "ask" | "search" | "browse"; + +export type AppPreferences = { + density: DensityPreference; + motion: MotionPreference; + jurisdiction: string; + population: PopulationPreference; + answerStyle: AnswerStylePreference; + landing: LandingPreference; + showRecentOnHome: boolean; + showProtocolsOnHome: boolean; + compactCitations: boolean; + notifyGuidelineUpdates: boolean; + notifyProductNews: boolean; + notifySavedChanges: boolean; +}; + +export const JURISDICTION_OPTIONS = [ + { value: "wa", label: "Western Australia" }, + { value: "nsw", label: "New South Wales" }, + { value: "vic", label: "Victoria" }, + { value: "qld", label: "Queensland" }, + { value: "sa", label: "South Australia" }, + { value: "tas", label: "Tasmania" }, + { value: "act", label: "Australian Capital Territory" }, + { value: "nt", label: "Northern Territory" }, + { value: "national", label: "National (Australia)" }, +] as const; + +export const POPULATION_OPTIONS: ReadonlyArray<{ value: PopulationPreference; label: string }> = [ + { value: "adults", label: "Adults" }, + { value: "older-adults", label: "Older adults" }, + { value: "adolescents", label: "Adolescents" }, + { value: "all", label: "All ages" }, +]; + +export const ANSWER_STYLE_OPTIONS: ReadonlyArray<{ + value: AnswerStylePreference; + label: string; + description: string; +}> = [ + { value: "conservative", label: "Conservative", description: "Guideline-first, cautious phrasing" }, + { value: "balanced", label: "Balanced", description: "Guidelines with practical context" }, + { value: "comprehensive", label: "Comprehensive", description: "Fuller detail and alternatives" }, +]; + +export const DENSITY_OPTIONS: ReadonlyArray<{ value: DensityPreference; label: string }> = [ + { value: "comfortable", label: "Comfortable" }, + { value: "compact", label: "Compact" }, + { value: "spacious", label: "Spacious" }, +]; + +export const LANDING_OPTIONS: ReadonlyArray<{ value: LandingPreference; label: string }> = [ + { value: "ask", label: "Ask" }, + { value: "search", label: "Search" }, + { value: "browse", label: "Browse" }, +]; + +export const DEFAULT_PREFERENCES: AppPreferences = { + density: "comfortable", + motion: "system", + jurisdiction: "wa", + population: "adults", + answerStyle: "conservative", + landing: "ask", + showRecentOnHome: true, + showProtocolsOnHome: true, + compactCitations: false, + notifyGuidelineUpdates: true, + notifyProductNews: false, + notifySavedChanges: true, +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function coerceEnum(value: unknown, allowed: ReadonlyArray, fallback: T): T { + return typeof value === "string" && (allowed as ReadonlyArray).includes(value) ? (value as T) : fallback; +} + +function coerceBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +export function normalizePreferences(input: unknown): AppPreferences { + if (!isPlainObject(input)) return DEFAULT_PREFERENCES; + const jurisdiction = + typeof input.jurisdiction === "string" && JURISDICTION_OPTIONS.some((option) => option.value === input.jurisdiction) + ? input.jurisdiction + : DEFAULT_PREFERENCES.jurisdiction; + return { + density: coerceEnum(input.density, ["comfortable", "compact", "spacious"], DEFAULT_PREFERENCES.density), + motion: coerceEnum(input.motion, ["system", "reduced"], DEFAULT_PREFERENCES.motion), + jurisdiction, + population: coerceEnum( + input.population, + ["adults", "older-adults", "adolescents", "all"], + DEFAULT_PREFERENCES.population, + ), + answerStyle: coerceEnum( + input.answerStyle, + ["conservative", "balanced", "comprehensive"], + DEFAULT_PREFERENCES.answerStyle, + ), + landing: coerceEnum(input.landing, ["ask", "search", "browse"], DEFAULT_PREFERENCES.landing), + showRecentOnHome: coerceBoolean(input.showRecentOnHome, DEFAULT_PREFERENCES.showRecentOnHome), + showProtocolsOnHome: coerceBoolean(input.showProtocolsOnHome, DEFAULT_PREFERENCES.showProtocolsOnHome), + compactCitations: coerceBoolean(input.compactCitations, DEFAULT_PREFERENCES.compactCitations), + notifyGuidelineUpdates: coerceBoolean(input.notifyGuidelineUpdates, DEFAULT_PREFERENCES.notifyGuidelineUpdates), + notifyProductNews: coerceBoolean(input.notifyProductNews, DEFAULT_PREFERENCES.notifyProductNews), + notifySavedChanges: coerceBoolean(input.notifySavedChanges, DEFAULT_PREFERENCES.notifySavedChanges), + }; +} diff --git a/src/lib/authorization.ts b/src/lib/authorization.ts new file mode 100644 index 000000000..4fa30980d --- /dev/null +++ b/src/lib/authorization.ts @@ -0,0 +1,13 @@ +import type { User } from "@supabase/supabase-js"; + +export const administratorRoleClaim = "site_role"; +export const administratorRoleValue = "administrator"; + +export function isAdministratorAppMetadata(metadata: unknown): boolean { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return false; + return (metadata as Record)[administratorRoleClaim] === administratorRoleValue; +} + +export function isAdministratorUser(user: Pick | null | undefined): boolean { + return isAdministratorAppMetadata(user?.app_metadata); +} diff --git a/src/lib/client-env.ts b/src/lib/client-env.ts index cea8c53cf..13e8a1f51 100644 --- a/src/lib/client-env.ts +++ b/src/lib/client-env.ts @@ -33,7 +33,3 @@ export function resolveUploadReadOnlyMode({ environment, }); } - -export function publicUploadsEnabled() { - return process.env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true"; -} diff --git a/src/lib/env.ts b/src/lib/env.ts index e37c3764b..285f7a372 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -20,8 +20,6 @@ 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().uuid().optional(), - PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(), - NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(), NEXT_PUBLIC_MOCKUPS_ENABLED: z.enum(["true", "false"]).optional(), OPENAI_API_KEY: z.string().optional(), OPENAI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"), @@ -305,14 +303,6 @@ 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"; -} - export function mockupsEnabled() { // Design-exploration mockup routes (/mockups/*) are a development surface. // They stay reachable in dev/test builds, but a production deploy 404s them diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 101faae41..cf3c20a80 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -64,12 +64,9 @@ type OwnerScopedQuery = { /** * 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. + * Anonymous callers intentionally see ONLY the public corpus (owner_id IS NULL). Uploads are + * administrator-only; newly uploaded owner-scoped documents remain private until the existing + * publication-review workflow promotes them to the public corpus. */ export function withOwnerReadScope>(query: T, ownerId: string | undefined): T { if (ownerId) return query.or(`owner_id.eq.${ownerId},owner_id.is.null`); diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index d27d31a9f..7e29a31cd 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -1,12 +1,18 @@ import { createServerClient, parseCookieHeader } from "@supabase/ssr"; import { env } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { PublicApiError, jsonError } from "@/lib/http"; +import { isAdministratorAppMetadata } from "@/lib/authorization"; import { createAdminClient } from "@/lib/supabase/admin"; type AdminClient = ReturnType; export type AuthenticatedUser = { id: string; + appMetadata: Record; +}; + +type AuthenticationRequirement = { + administrator?: boolean; }; function readCookies(cookieHeader: string | null): Map { @@ -66,12 +72,12 @@ function extractSessionAccessToken(request: Request): string | null { 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 }; + return { id: data.user.id, appMetadata: data.user.app_metadata ?? {} }; } -export class AuthenticationError extends Error { +export class AuthenticationError extends PublicApiError { constructor(message = "Authentication required.") { - super(message); + super(message, 401, { code: "authentication_required" }); this.name = "AuthenticationError"; } } @@ -115,7 +121,7 @@ async function getUserFromRequestCookies(request: Request): Promise { +export async function requireAuthenticatedUser( + request: Request, + supabase: AdminClient, + requirement: AuthenticationRequirement = {}, +): Promise { const user = await resolveOptionalAuthenticatedUser(request, supabase); - if (user) return user; - throw new AuthenticationError(); + if (!user) throw new AuthenticationError(); + if (requirement.administrator && !isAdministratorAppMetadata(user.appMetadata)) { + throw new PublicApiError("Administrator access required.", 403, { code: "administrator_required" }); + } + return user; } export async function getOptionalAuthenticatedUser( diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx index 3c8e8940c..4e0711fed 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -356,7 +356,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setSession(null); setStatus("expired"); setNotice(null); - setError("Your session expired. Sign in again to use private documents."); + setError("Your session expired. Sign in again to use account features."); }, [invalidateAuthRequests]); const accessToken = session?.access_token ?? null; diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 0e3e3e581..99b68a328 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2111,6 +2111,45 @@ export type Database = { }; Relationships: []; }; + user_favourites: { + Row: { + content_key: string; + content_type: string; + created_at: string; + user_id: string; + }; + Insert: { + content_key: string; + content_type: string; + created_at?: string; + user_id: string; + }; + Update: { + content_key?: string; + content_type?: string; + created_at?: string; + user_id?: string; + }; + Relationships: []; + }; + user_preferences: { + Row: { + preferences: Json; + updated_at: string; + user_id: string; + }; + Insert: { + preferences?: Json; + updated_at?: string; + user_id: string; + }; + Update: { + preferences?: Json; + updated_at?: string; + user_id?: string; + }; + Relationships: []; + }; }; Views: { document_strict_gate_status: { diff --git a/supabase/migrations/20260719064735_user_account_data_and_admin_uploads.sql b/supabase/migrations/20260719064735_user_account_data_and_admin_uploads.sql new file mode 100644 index 000000000..1a6096f27 --- /dev/null +++ b/supabase/migrations/20260719064735_user_account_data_and_admin_uploads.sql @@ -0,0 +1,59 @@ +-- Public clinical content remains available through the server's public-read +-- routes. These tables contain only account-owned data and are never readable +-- by anonymous callers. +create table if not exists public.user_favourites ( + user_id uuid not null references auth.users(id) on delete cascade, + content_type text not null, + content_key text not null, + created_at timestamptz not null default now(), + primary key (user_id, content_type, content_key), + constraint user_favourites_content_type_check + check (content_type in ('service', 'form', 'differential')), + constraint user_favourites_content_key_check + check (content_key = btrim(content_key) and char_length(content_key) between 1 and 180) +); + +create table if not exists public.user_preferences ( + user_id uuid primary key references auth.users(id) on delete cascade, + preferences jsonb not null default '{}'::jsonb, + updated_at timestamptz not null default now(), + constraint user_preferences_object_check check (jsonb_typeof(preferences) = 'object'), + constraint user_preferences_size_check check (pg_column_size(preferences) <= 16384) +); + +alter table public.user_favourites enable row level security; +alter table public.user_preferences enable row level security; + +revoke all on table public.user_favourites from public, anon, authenticated; +revoke all on table public.user_preferences from public, anon, authenticated; +grant select, insert, update, delete on table public.user_favourites to service_role; +grant select, insert, update, delete on table public.user_preferences to service_role; + +create policy "users read own favourites" on public.user_favourites + for select to authenticated + using ((select auth.uid()) = user_id); +create policy "users insert own favourites" on public.user_favourites + for insert to authenticated + with check ((select auth.uid()) = user_id); +create policy "users delete own favourites" on public.user_favourites + for delete to authenticated + using ((select auth.uid()) = user_id); + +create policy "users read own preferences" on public.user_preferences + for select to authenticated + using ((select auth.uid()) = user_id); +create policy "users insert own preferences" on public.user_preferences + for insert to authenticated + with check ((select auth.uid()) = user_id); +create policy "users update own preferences" on public.user_preferences + for update to authenticated + using ((select auth.uid()) = user_id) + with check ((select auth.uid()) = user_id); +create policy "users delete own preferences" on public.user_preferences + for delete to authenticated + using ((select auth.uid()) = user_id); + +-- Uploads are accepted only by the server route after it validates the +-- administrator claim. Remove direct Data/Storage API write capability from +-- both public roles as a second enforcement layer. +revoke insert, update, delete on table storage.objects from anon, authenticated; diff --git a/tests/account-access-model.test.ts b/tests/account-access-model.test.ts new file mode 100644 index 000000000..b2affd427 --- /dev/null +++ b/tests/account-access-model.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { isAdministratorAppMetadata } from "@/lib/authorization"; + +function source(path: string) { + return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); +} + +describe("public content and account authorization model", () => { + it("trusts only the immutable administrator app-metadata claim", () => { + expect(isAdministratorAppMetadata({ site_role: "administrator" })).toBe(true); + expect(isAdministratorAppMetadata({ site_role: "user" })).toBe(false); + expect(isAdministratorAppMetadata({ role: "administrator" })).toBe(false); + expect(isAdministratorAppMetadata(null)).toBe(false); + }); + + it("requires administrator authorization before any upload work", () => { + const uploadRoute = source("src/app/api/upload/route.ts"); + const authCheck = uploadRoute.indexOf("requireAuthenticatedUser(request, adminSupabase, { administrator: true })"); + const formRead = uploadRoute.indexOf("request.formData()"); + const storageWrite = uploadRoute.indexOf(".upload(storagePath, buffer"); + + expect(authCheck).toBeGreaterThan(-1); + expect(authCheck).toBeLessThan(formRead); + expect(authCheck).toBeLessThan(storageWrite); + expect(uploadRoute).not.toContain("publicUploadsEnabled"); + expect(uploadRoute).not.toContain("PUBLIC_WORKSPACE_OWNER_ID"); + }); + + it("keeps administrator assignment behind an explicit provider-mutation gate", () => { + const script = source("scripts/set-site-administrator.ts"); + expect(script).toContain('process.env.ALLOW_SUPABASE_ADMIN_MUTATION !== "true"'); + expect(script).toContain('app_metadata: { ...matchedUser.app_metadata, site_role: "administrator" }'); + }); + + it("stores favourites and preferences as owner-scoped RLS data", () => { + const migration = source("supabase/migrations/20260719064735_user_account_data_and_admin_uploads.sql"); + expect(migration).toContain("create table if not exists public.user_favourites"); + expect(migration).toContain("create table if not exists public.user_preferences"); + expect(migration).toContain("references auth.users(id) on delete cascade"); + expect(migration).toContain("alter table public.user_favourites enable row level security"); + expect(migration).toContain("alter table public.user_preferences enable row level security"); + expect(migration).toContain("revoke all on table public.user_favourites from public, anon, authenticated"); + expect(migration).not.toMatch(/grant [^;]* on table public\.user_(?:favourites|preferences) to authenticated/); + expect(migration.match(/\(select auth\.uid\(\)\) = user_id/g)?.length).toBeGreaterThanOrEqual(7); + expect(migration).toContain("revoke insert, update, delete on table storage.objects from anon, authenticated"); + }); + + it("keeps public document reads separate from administrator mutations", () => { + const documentRoute = source("src/app/api/documents/[id]/route.ts"); + const tableFactsRoute = source("src/app/api/documents/[id]/table-facts/route.ts"); + const documentViewer = source("src/components/DocumentViewer.tsx"); + expect(documentRoute).toContain("loadAuthorizedDocumentDetail({ request, rawId, query: detailQuery })"); + expect(documentRoute.match(/administrator: true/g)?.length).toBe(2); + expect(tableFactsRoute).toContain("enforceDocumentReadRateLimit(request, supabase)"); + expect(tableFactsRoute).toContain("withOwnerReadScope("); + expect(tableFactsRoute.match(/administrator: true/g)?.length).toBe(1); + expect(documentViewer).toContain("isAdministratorUser(session?.user)"); + expect(documentViewer).toContain("{canUseAdministrativeApis ? ("); + expect(documentViewer).toContain("canManage={canUseAdministrativeApis}"); + expect(documentViewer).toContain("canReview={canUseAdministrativeApis}"); + }); + + it("does not turn an ordinary signed-in setup-status request into a server error", () => { + const setupStatusRoute = source("src/app/api/setup-status/route.ts"); + expect(setupStatusRoute).toContain("error instanceof PublicApiError && error.status === 403"); + expect(setupStatusRoute).toContain("non-administrator credentials receive the same"); + }); + + it("does not load or display administrative dashboard surfaces for ordinary accounts", () => { + const dashboard = source("src/components/ClinicalDashboard.tsx"); + expect(dashboard).toContain("if (!canUseAdministrativeApis)"); + expect(dashboard).toContain( + 'canUseAdministrativeApis && (uploadDrawerOpen || (documentsDrawerOpen && documentsDrawerMode === "admin"))', + ); + expect(dashboard).toContain( + 'const documentsDrawerIsAdmin = documentsDrawerMode === "admin" && canUseAdministrativeApis;', + ); + }); +}); diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index db20a8188..9bd9f6d9a 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -143,7 +143,7 @@ describe("audit navigation and auth regressions", () => { "function openUploadDrawer()", "function openEvidenceDrawer()", ); - expect(uploadMutationContract).toContain("if (!canUsePrivateApis) {"); + expect(uploadMutationContract).toContain("if (!canUseAdministrativeApis) {"); }); it("keeps the private upload workspace tabs and panels programmatically associated", () => { diff --git a/tests/client-secret-surface.test.ts b/tests/client-secret-surface.test.ts index 98d223ad2..9db8dcf02 100644 --- a/tests/client-secret-surface.test.ts +++ b/tests/client-secret-surface.test.ts @@ -19,7 +19,7 @@ const SRC_ROOT = join(ROOT, "src"); const SERVER_ENV = join(SRC_ROOT, "lib", "env.ts"); const PUBLIC_ENV = join(SRC_ROOT, "lib", "client-env.ts"); const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]; -const PUBLIC_ENV_KEYS = new Set(["NODE_ENV", "NEXT_PUBLIC_LOCAL_NO_AUTH", "NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED"]); +const PUBLIC_ENV_KEYS = new Set(["NODE_ENV", "NEXT_PUBLIC_LOCAL_NO_AUTH"]); const importCache = new Map(); function sourceFiles(dir: string): string[] { diff --git a/tests/document-admin-rate-limit.test.ts b/tests/document-admin-rate-limit.test.ts index fea56be86..6d2172cc9 100644 --- a/tests/document-admin-rate-limit.test.ts +++ b/tests/document-admin-rate-limit.test.ts @@ -74,11 +74,13 @@ describe("document-admin rate limiting", () => { it("returns 429 + Retry-After when the admin bucket is exhausted, before any table access", async () => { const client = rateLimitedClient(); mockRuntime(client); - const { GET } = await import("../src/app/api/documents/[id]/table-facts/route"); + const { PATCH } = await import("../src/app/api/documents/[id]/table-facts/route"); - const response = await GET( + const response = await PATCH( new Request(`http://localhost/api/documents/${documentId}/table-facts`, { - headers: { authorization: "Bearer valid-token" }, + method: "PATCH", + headers: { authorization: "Bearer valid-token", "content-type": "application/json" }, + body: JSON.stringify({ factId: documentId, reviewClass: "clinical_useful" }), }), { params: Promise.resolve({ id: documentId }) }, ); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 046b98a74..b07722ec4 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -235,7 +235,7 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { const storageFrom = vi.fn(() => ({ upload, createSignedUrl, remove })); const getUser = vi.fn(async (receivedToken?: string) => receivedToken === token - ? { data: { user: { id: userId } }, error: null } + ? { data: { user: { id: userId, app_metadata: { site_role: "administrator" } } }, error: null } : { data: { user: null }, error: { message: "Invalid token" } }, ); const subjectRateLimitCounts = new Map(); @@ -304,8 +304,6 @@ function mockRuntime( localOwnerEmail?: string; providerMode?: string; openAiKey?: string; - publicUploadsEnabled?: boolean; - publicWorkspaceOwnerId?: string; maxConcurrentUploads?: number; maxInFlightUploadMb?: number; demoMode?: boolean; @@ -335,15 +333,11 @@ 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: () => Boolean(options.demoMode), isLocalNoAuthMode: () => Boolean(options.localNoAuth), - publicWorkspaceOwnerId: () => options.publicWorkspaceOwnerId ?? null, - publicUploadsEnabled: () => Boolean(options.publicUploadsEnabled), requireOpenAIEnv: () => undefined, requireServerEnv: () => undefined, })); @@ -462,7 +456,7 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(client.auth.getUser).toHaveBeenCalledTimes(1); }); - it("rejects local no-auth upload calls from unmanaged localhost ports before Supabase access", async () => { + it("does not let local no-auth bypass administrator upload authentication", async () => { const client = createSupabaseMock(); mockRuntime(client, undefined, { localNoAuth: true }); const { POST } = await import("../src/app/api/upload/route"); @@ -476,13 +470,13 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(503); - expect(await payload(response)).toMatchObject({ error: "Public uploads are not configured for this workspace." }); + expect(response.status).toBe(401); + expect(await payload(response)).toMatchObject({ code: "authentication_required" }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); - it("rejects managed-port upload calls with stale localhost referers before Supabase access", async () => { + it("does not let a localhost referer bypass administrator upload authentication", async () => { const client = createSupabaseMock(); mockRuntime(client, undefined, { localNoAuth: true }); const { POST } = await import("../src/app/api/upload/route"); @@ -499,8 +493,8 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(503); - expect(await payload(response)).toMatchObject({ error: "Public uploads are not configured for this workspace." }); + expect(response.status).toBe(401); + expect(await payload(response)).toMatchObject({ code: "authentication_required" }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -1158,7 +1152,7 @@ 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 () => { + it("rejects anonymous uploads without touching storage", async () => { const client = createSupabaseMock(); mockRuntime(client); const { POST } = await import("../src/app/api/upload/route"); @@ -1172,58 +1166,47 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(503); - expect(await payload(response)).toMatchObject({ error: "Public uploads are not configured for this workspace." }); + expect(response.status).toBe(401); + expect(await payload(response)).toMatchObject({ code: "authentication_required" }); 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([]); + it("rejects an authenticated non-administrator upload", async () => { + const client = createSupabaseMock(); + client.auth.getUser.mockResolvedValueOnce({ + data: { user: { id: userId, app_metadata: { site_role: "user" } } }, + error: null, }); - mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId }); + mockRuntime(client); const { POST } = await import("../src/app/api/upload/route"); const formData = new FormData(); formData.set("file", new File(["%PDF-1.7\n%%EOF"], "guideline.pdf", { type: "application/pdf" })); const response = await POST( - request("/api/upload", { + authenticatedRequest("/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, - }); + expect(response.status).toBe(403); + expect(await payload(response)).toMatchObject({ code: "administrator_required" }); + expect(client.storageMocks.upload).not.toHaveBeenCalled(); }); - it("fails closed for anonymous uploads when the durable anonymous limiter is unavailable", async () => { - const publicOwnerId = "99999999-9999-4999-8999-999999999999"; + it("fails closed for administrator uploads when the durable limiter is unavailable", async () => { const client = createSupabaseMock(); client.rpc.mockImplementation(async (name: string) => name === "consume_api_subject_rate_limit" ? fail("anonymous limiter table unavailable") : ok([]), ); - mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId }); + mockRuntime(client); const { POST } = await import("../src/app/api/upload/route"); const formData = new FormData(); formData.set("file", new File(["%PDF-1.7\n%%EOF"], "guideline.pdf", { type: "application/pdf" })); const response = await POST( - request("/api/upload", { + authenticatedRequest("/api/upload", { method: "POST", body: formData, }), diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index d943090f8..778a1c030 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -1221,7 +1221,7 @@ describe("Supabase Preview replay guards", () => { const migrationFiles = readdirSync(migrationDirectoryUrl) .filter((fileName) => /^\d+_.+\.sql$/.test(fileName)) .sort(); - expect(migrationFiles.at(-1)).toBe("20260719055623_enforce_public_title_word_scope.sql"); + expect(migrationFiles.at(-1)).toBe("20260719064735_user_account_data_and_admin_uploads.sql"); expect(documentTitleWordScopeMigration).toContain( "v_status := public.default_privileges_status('postgres', 'public')", ); From 79355ba425a0a9090fa97a951658c55f79cfeda3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:44:13 +0800 Subject: [PATCH 02/10] docs: record auth access review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 1cdb14679..279897a40 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -20,6 +20,7 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | | ---------- | -------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-19 | codex/public-content-account-access | c4520fe404fd0fb0616ea489468d68922123a683 | authentication, account-data isolation, anonymous content access, and administrator-only mutation pre-PR review | No high-confidence P0-P2 finding remained. Anonymous reads stay constrained to the published public corpus; bearer/cookie sessions are server-validated; favourites and preferences are queried through the service role only after authentication and always filtered by the authenticated user ID; administrator authorization trusts only Supabase app metadata; local/demo modes cannot bypass upload authorization; and the upload route authorizes before parsing or storage writes. Highest residual risk is live OAuth/provider configuration and hosted end-to-end behavior, which require deployed-environment proof. | npm run verify:pr-local passed runtime, formatting, ESLint, TypeScript, 320 test files / 2,901 tests, production build/client-secret scan, and 36 offline RAG fixtures. Focused auth/current-main integration reruns passed 74/74 and 11/11. Live Supabase migration and role/grant verification were completed separately against the approved Clinical KB project. check:production-readiness passed runtime/privacy guards but was environment-blocked by absent local Supabase/OpenAI variables. verify:ui was lock-blocked before Playwright by another registered worktree; local project identity was verified at the repository-selected server URL before cleanup. | | 2026-07-14 | multiple remote branches (16 refs) | multiple SHAs | remote branch cleanup | Safely deleted 16 fully merged and redundant remote branches on origin (including `claude/canary-gate-fixes`, `claude/codebase-index-coverage`, `claude/design-elevation-e1e2`, `claude/design-sync-fixes-p1`, `claude/docs-script-linter`, `claude/document-image-viewer-review-ox7t11`, `claude/generation-token-starvation-fix`, `claude/github-actions-codex-issue-f4t4s5`, `claude/hero-composer-hydration`, `claude/pdf-signed-url-refresh`, `claude/pt-audit-monitor-marker-fix`, `claude/pt-audit-pr2-variant-early-exit`, `claude/pt-audit-pr4-trust-copy`, `claude/pt-audit-pt17-live-monitor`, `codex/eval-canary-quota-handling`, and `cursor/clean-sentry-lockfile-orphans-74cf`). | Confirmed zero unique commits against origin/main and MERGED/CLOSED status on GitHub via `gh pr list`. | | 2026-07-14 | worktrees (6244, 8ba3, b6ff, e6b5, repo-improvement-review-09945c) | detached HEADs | local worktree cleanup | Safely removed and unregistered 5 clean, inactive worktrees from the git registry. | Ran `git worktree remove` and verified final active worktrees. | | 2026-07-14 | main | 570e6ba56ae60bea56a32801b9cc96c5a8dfde4f | branch alignment | Fast-forwarded local `main` to latest `origin/main` commit. | Verified main and origin/main revisions and updated ref locally. | From 2ed2378a377f12e5fce39af11967fbd1c486c255 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:24:41 +0000 Subject: [PATCH 03/10] fix: apply CodeRabbit auto-fixes Fixed 5 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit --- src/app/api/account/preferences/route.ts | 24 +++++++-- src/components/DocumentViewer.tsx | 1 + src/components/account-data-provider.tsx | 54 +++++++++++++------ .../clinical-dashboard/settings-dialog.tsx | 10 +++- .../differential-detail-page.tsx | 16 +++--- 5 files changed, 74 insertions(+), 31 deletions(-) diff --git a/src/app/api/account/preferences/route.ts b/src/app/api/account/preferences/route.ts index 30cc78b7b..f1f7de1e3 100644 --- a/src/app/api/account/preferences/route.ts +++ b/src/app/api/account/preferences/route.ts @@ -35,10 +35,17 @@ export async function GET(request: Request) { .eq("user_id", user.id) .maybeSingle(); if (error) throw new Error(error.message); - return Response.json({ - preferences: data ? normalizePreferences(data.preferences) : null, - updatedAt: data?.updated_at, - }); + return Response.json( + { + preferences: data ? normalizePreferences(data.preferences) : null, + updatedAt: data?.updated_at, + }, + { + headers: { + "Cache-Control": "private, no-store", + }, + }, + ); } catch (error) { return jsonError(error); } @@ -55,7 +62,14 @@ export async function PUT(request: Request) { updated_at: new Date().toISOString(), }); if (error) throw new Error(error.message); - return Response.json({ preferences }); + return Response.json( + { preferences }, + { + headers: { + "Cache-Control": "private, no-store", + }, + }, + ); } catch (error) { return jsonError(error); } diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 53658c1f1..69f821903 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2662,6 +2662,7 @@ export function DocumentViewer({ boolean; - setFavourite: (contentType: FavouriteContentType, contentKey: string, saved: boolean) => Promise; - clearFavourites: () => Promise; + setFavourite: ( + contentType: FavouriteContentType, + contentKey: string, + saved: boolean, + ) => Promise; + clearFavourites: () => Promise; }; const unavailableAccountData: AccountDataContextValue = { @@ -46,8 +54,8 @@ const unavailableAccountData: AccountDataContextValue = { ready: true, error: null, isSaved: () => false, - setFavourite: async () => false, - clearFavourites: async () => false, + setFavourite: async () => ({ success: false, reason: "unauthenticated", message: "Account data unavailable." }), + clearFavourites: async () => ({ success: false, reason: "unauthenticated", message: "Account data unavailable." }), }; const AccountDataContext = createContext(unavailableAccountData); @@ -125,19 +133,24 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { if (auth.status !== "authenticated") { if (demoAccountData) { const current = favourites[contentType]; - return writeSavedRegistrySlugs( + const success = writeSavedRegistrySlugs( storageKeyByType[contentType], saved ? [contentKey, ...current.filter((item) => item !== contentKey)] : current.filter((item) => item !== contentKey), ); + return success + ? { success: true as const } + : { success: false, reason: "request-error" as const, message: "Failed to save to local storage." }; } - setError("Sign in or create an account to save favourites."); - return false; + const message = "Sign in or create an account to save favourites."; + setError(message); + return { success: false, reason: "unauthenticated" as const, message }; } const key = contentKey.trim(); - if (!key) return false; + if (!key) + return { success: false, reason: "request-error" as const, message: "Invalid content key provided." }; const previous = favourites; setFavourites((current) => ({ ...current, @@ -154,20 +167,27 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { if (!response?.ok) { setFavourites(previous); const payload = await response?.json().catch(() => ({})); - setError(payload?.message ?? payload?.error ?? "Saved items could not be updated."); + const message = payload?.message ?? payload?.error ?? "Saved items could not be updated."; + setError(message); if (response?.status === 401) auth.markSessionExpired(); - return false; + return { success: false, reason: "request-error" as const, message }; } setError(null); - return true; + return { success: true as const }; }, [auth, favourites], ); const clearFavourites = useCallback(async () => { if (auth.status !== "authenticated") { - if (!demoAccountData) return false; - return (Object.values(storageKeyByType) as string[]).every((key) => writeSavedRegistrySlugs(key, [])); + if (!demoAccountData) { + const message = "Sign in or create an account to clear favourites."; + return { success: false, reason: "unauthenticated" as const, message }; + } + const success = (Object.values(storageKeyByType) as string[]).every((key) => writeSavedRegistrySlugs(key, [])); + return success + ? { success: true as const } + : { success: false, reason: "request-error" as const, message: "Failed to clear local storage." }; } const previous = favourites; setFavourites(emptyFavourites); @@ -177,12 +197,14 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { }).catch(() => null); if (!response?.ok) { setFavourites(previous); - setError("Saved items could not be cleared."); + const payload = await response?.json().catch(() => ({})); + const message = payload?.message ?? payload?.error ?? "Saved items could not be cleared."; + setError(message); if (response?.status === 401) auth.markSessionExpired(); - return false; + return { success: false, reason: "request-error" as const, message }; } setError(null); - return true; + return { success: true as const }; }, [auth, favourites]); const value = useMemo( diff --git a/src/components/clinical-dashboard/settings-dialog.tsx b/src/components/clinical-dashboard/settings-dialog.tsx index 389d433d9..cc5bdd92a 100644 --- a/src/components/clinical-dashboard/settings-dialog.tsx +++ b/src/components/clinical-dashboard/settings-dialog.tsx @@ -232,8 +232,14 @@ export function SettingsDialog({ } async function handleClearSaved() { - const cleared = await accountData.clearFavourites(); - setPrivacyNotice(cleared ? "Saved items cleared." : "Sign in to clear account favourites."); + const result = await accountData.clearFavourites(); + if (result.success) { + setPrivacyNotice("Saved items cleared."); + } else if (result.reason === "unauthenticated") { + setPrivacyNotice("Sign in to clear account favourites."); + } else { + setPrivacyNotice(result.message); + } } function handleResetPreferences() { diff --git a/src/components/differentials/differential-detail-page.tsx b/src/components/differentials/differential-detail-page.tsx index 729ca80bc..ecdc3fd0f 100644 --- a/src/components/differentials/differential-detail-page.tsx +++ b/src/components/differentials/differential-detail-page.tsx @@ -974,14 +974,14 @@ export function DifferentialDetailPage({ async function toggleSaved() { const nowSaved = !saved; - const updated = await accountData.setFavourite("differential", record.slug, nowSaved); - setSaveNotice( - updated - ? nowSaved - ? "Diagnosis saved." - : "Diagnosis removed from saved items." - : "Sign in or create an account to save diagnoses.", - ); + const result = await accountData.setFavourite("differential", record.slug, nowSaved); + if (result.success) { + setSaveNotice(nowSaved ? "Diagnosis saved." : "Diagnosis removed from saved items."); + } else if (result.reason === "unauthenticated") { + setSaveNotice("Sign in or create an account to save diagnoses."); + } else { + setSaveNotice(result.message); + } } const hasMustNotMiss = record.sections.some((section) => section.id === "must-not-miss"); From bfe6baf7baf50deb546102ec5ca572d4fbe77daa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 09:33:19 +0000 Subject: [PATCH 04/10] fix(account): check favourite save result.success explicitly Service and form detail pages treated setFavourite's failure object as truthy, so signed-out or rejected saves still showed a success notice. Align both pages with the differential detail pattern and add a regression assertion that all detail pages inspect result.success. Co-authored-by: BigSimmo --- src/components/forms/form-detail-page.tsx | 9 ++++++--- src/components/services/service-detail-page.tsx | 9 ++++++--- tests/account-access-model.test.ts | 11 +++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index ba5e7c541..414c27359 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -513,11 +513,14 @@ export function FormDetailPage({ form }: { form: FormRecord }) { async function toggleSaved() { try { const nowSaved = !saved; - if (!(await accountData.setFavourite("form", form.slug, nowSaved))) { + const result = await accountData.setFavourite("form", form.slug, nowSaved); + if (result.success) { + setNotice(nowSaved ? "Form saved" : "Form removed from saved items"); + } else if (result.reason === "unauthenticated") { setNotice("Sign in or create an account to save forms"); - return; + } else { + setNotice(result.message); } - setNotice(nowSaved ? "Form saved" : "Form removed from saved items"); } catch { setNotice("Save failed"); } diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 0d1ae76b8..c0b938236 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -486,11 +486,14 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { async function toggleSaved() { try { const nowSaved = !saved; - if (!(await accountData.setFavourite("service", service.slug, nowSaved))) { + const result = await accountData.setFavourite("service", service.slug, nowSaved); + if (result.success) { + setNotice(nowSaved ? "Service saved" : "Service removed from saved items"); + } else if (result.reason === "unauthenticated") { setNotice("Sign in or create an account to save services"); - return; + } else { + setNotice(result.message); } - setNotice(nowSaved ? "Service saved" : "Service removed from saved items"); } catch { setNotice("Save failed"); } diff --git a/tests/account-access-model.test.ts b/tests/account-access-model.test.ts index b2affd427..8c8a1b9f3 100644 --- a/tests/account-access-model.test.ts +++ b/tests/account-access-model.test.ts @@ -79,4 +79,15 @@ describe("public content and account authorization model", () => { 'const documentsDrawerIsAdmin = documentsDrawerMode === "admin" && canUseAdministrativeApis;', ); }); + + it("checks favourite save results explicitly instead of relying on object truthiness", () => { + const serviceDetail = source("src/components/services/service-detail-page.tsx"); + const formDetail = source("src/components/forms/form-detail-page.tsx"); + const differentialDetail = source("src/components/differentials/differential-detail-page.tsx"); + + for (const detailSource of [serviceDetail, formDetail, differentialDetail]) { + expect(detailSource).toContain("result.success"); + expect(detailSource).not.toMatch(/if\s*\(\s*!\s*\(\s*await accountData\.setFavourite/); + } + }); }); From de0cddb1bd728b86ec230ef3e37e70e61d6e1765 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:48:20 +0800 Subject: [PATCH 05/10] fix(ci): prettier-format account-data-provider Static PR checks failed format:check on this file; apply Prettier so CI can pass. Co-authored-by: Cursor --- src/components/account-data-provider.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/account-data-provider.tsx b/src/components/account-data-provider.tsx index d39855b5d..a04049a7a 100644 --- a/src/components/account-data-provider.tsx +++ b/src/components/account-data-provider.tsx @@ -33,8 +33,7 @@ function readDemoFavourites(): FavouritesByType { } export type FavouriteActionResult = - | { success: true } - | { success: false; reason: "unauthenticated" | "request-error"; message: string }; + { success: true } | { success: false; reason: "unauthenticated" | "request-error"; message: string }; type AccountDataContextValue = { favourites: FavouritesByType; @@ -149,8 +148,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { } const key = contentKey.trim(); - if (!key) - return { success: false, reason: "request-error" as const, message: "Invalid content key provided." }; + if (!key) return { success: false, reason: "request-error" as const, message: "Invalid content key provided." }; const previous = favourites; setFavourites((current) => ({ ...current, From 0b1687365aa4de55446ef681b84f9986e0107850 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 09:58:35 +0000 Subject: [PATCH 06/10] fix(schema): mirror account tables into schema.sql and drift manifest Add user_favourites and user_preferences tables, RLS policies, grants, and storage.objects upload revoke to the canonical schema snapshot. Regenerate supabase/drift-manifest.json so drift verification matches the post-migration live inventory. Co-authored-by: BigSimmo --- supabase/drift-manifest.json | 222 ++++++++++++++++++++++++++++++++++- supabase/schema.sql | 63 +++++++++- 2 files changed, 281 insertions(+), 4 deletions(-) diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 847477990..fad433e4f 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-19T06:37:36.934Z", + "generated_at": "2026-07-19T09:58:22.040Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "f7c2f22f13422e0d884e796c29a1aa7644b6350b073f95a7fcae0801dcce8a2f", - "replay_seconds": 28, + "schema_sha256": "73d993e84e97b036363501a78788c27444c894feb5c4901b7cbd3ccead8e38a5", + "replay_seconds": 73, "snapshot": { "views": [ { @@ -4567,6 +4567,86 @@ "reloptions": null, "rls_forced": false, "rls_enabled": true + }, + { + "acl": [ + "postgres=arwdDxtm/postgres", + "service_role=arwd/postgres" + ], + "name": "user_favourites", + "columns": [ + { + "name": "content_key", + "type": "text", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "content_type", + "type": "text", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "created_at", + "type": "timestamp with time zone", + "default": "now()", + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "user_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + } + ], + "reloptions": null, + "rls_forced": false, + "rls_enabled": true + }, + { + "acl": [ + "postgres=arwdDxtm/postgres", + "service_role=arwd/postgres" + ], + "name": "user_preferences", + "columns": [ + { + "name": "preferences", + "type": "jsonb", + "default": "'{}'::jsonb", + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "updated_at", + "type": "timestamp with time zone", + "default": "now()", + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "user_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + } + ], + "reloptions": null, + "rls_forced": false, + "rls_enabled": true } ], "indexes": [ @@ -5805,6 +5885,18 @@ "name": "storage_cleanup_jobs_status_created_idx", "table": "storage_cleanup_jobs", "def_hash": "2cbfd92ff37d036d37344943f57dd95b" + }, + { + "def": "CREATE UNIQUE INDEX user_favourites_pkey ON public.user_favourites USING btree (user_id, content_type, content_key)", + "name": "user_favourites_pkey", + "table": "user_favourites", + "def_hash": "d79eec4f3e285a2d5d777b809d518906" + }, + { + "def": "CREATE UNIQUE INDEX user_preferences_pkey ON public.user_preferences USING btree (user_id)", + "name": "user_preferences_pkey", + "table": "user_preferences", + "def_hash": "c5c62ae122cf68b698382a1a3f47c98d" } ], "policies": [ @@ -6264,6 +6356,90 @@ "permissive": "PERMISSIVE", "with_check": null }, + { + "cmd": "DELETE", + "name": "users delete own favourites", + "qual": "(( SELECT auth.uid() AS uid) = user_id)", + "roles": [ + "authenticated" + ], + "table": "user_favourites", + "schema": "public", + "permissive": "PERMISSIVE", + "with_check": null + }, + { + "cmd": "INSERT", + "name": "users insert own favourites", + "qual": null, + "roles": [ + "authenticated" + ], + "table": "user_favourites", + "schema": "public", + "permissive": "PERMISSIVE", + "with_check": "(( SELECT auth.uid() AS uid) = user_id)" + }, + { + "cmd": "SELECT", + "name": "users read own favourites", + "qual": "(( SELECT auth.uid() AS uid) = user_id)", + "roles": [ + "authenticated" + ], + "table": "user_favourites", + "schema": "public", + "permissive": "PERMISSIVE", + "with_check": null + }, + { + "cmd": "DELETE", + "name": "users delete own preferences", + "qual": "(( SELECT auth.uid() AS uid) = user_id)", + "roles": [ + "authenticated" + ], + "table": "user_preferences", + "schema": "public", + "permissive": "PERMISSIVE", + "with_check": null + }, + { + "cmd": "INSERT", + "name": "users insert own preferences", + "qual": null, + "roles": [ + "authenticated" + ], + "table": "user_preferences", + "schema": "public", + "permissive": "PERMISSIVE", + "with_check": "(( SELECT auth.uid() AS uid) = user_id)" + }, + { + "cmd": "SELECT", + "name": "users read own preferences", + "qual": "(( SELECT auth.uid() AS uid) = user_id)", + "roles": [ + "authenticated" + ], + "table": "user_preferences", + "schema": "public", + "permissive": "PERMISSIVE", + "with_check": null + }, + { + "cmd": "UPDATE", + "name": "users update own preferences", + "qual": "(( SELECT auth.uid() AS uid) = user_id)", + "roles": [ + "authenticated" + ], + "table": "user_preferences", + "schema": "public", + "permissive": "PERMISSIVE", + "with_check": "(( SELECT auth.uid() AS uid) = user_id)" + }, { "cmd": "SELECT", "name": "document storage owner read", @@ -7936,6 +8112,46 @@ "def": "CHECK ((status = ANY (ARRAY['pending'::text, 'completed'::text, 'failed'::text])))", "name": "storage_cleanup_jobs_status_check", "table": "storage_cleanup_jobs" + }, + { + "def": "CHECK (((content_key = btrim(content_key)) AND ((char_length(content_key) >= 1) AND (char_length(content_key) <= 180))))", + "name": "user_favourites_content_key_check", + "table": "user_favourites" + }, + { + "def": "CHECK ((content_type = ANY (ARRAY['service'::text, 'form'::text, 'differential'::text])))", + "name": "user_favourites_content_type_check", + "table": "user_favourites" + }, + { + "def": "PRIMARY KEY (user_id, content_type, content_key)", + "name": "user_favourites_pkey", + "table": "user_favourites" + }, + { + "def": "FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE", + "name": "user_favourites_user_id_fkey", + "table": "user_favourites" + }, + { + "def": "CHECK ((jsonb_typeof(preferences) = 'object'::text))", + "name": "user_preferences_object_check", + "table": "user_preferences" + }, + { + "def": "PRIMARY KEY (user_id)", + "name": "user_preferences_pkey", + "table": "user_preferences" + }, + { + "def": "CHECK ((pg_column_size(preferences) <= 16384))", + "name": "user_preferences_size_check", + "table": "user_preferences" + }, + { + "def": "FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE", + "name": "user_preferences_user_id_fkey", + "table": "user_preferences" } ], "storage_buckets": [ diff --git a/supabase/schema.sql b/supabase/schema.sql index afae18911..35bab0eea 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -671,6 +671,34 @@ create table if not exists public.storage_cleanup_jobs ( on delete set null ); +-- Public clinical content remains available through the server's public-read +-- routes. These tables contain only account-owned data and are never readable +-- by anonymous callers. +create table if not exists public.user_favourites ( + user_id uuid not null references auth.users(id) on delete cascade, + content_type text not null, + content_key text not null, + created_at timestamptz not null default now(), + primary key (user_id, content_type, content_key), + constraint user_favourites_content_type_check + check (content_type in ('service', 'form', 'differential')), + constraint user_favourites_content_key_check + check (content_key = btrim(content_key) and char_length(content_key) between 1 and 180) +); + +create table if not exists public.user_preferences ( + user_id uuid primary key references auth.users(id) on delete cascade, + preferences jsonb not null default '{}'::jsonb, + updated_at timestamptz not null default now(), + constraint user_preferences_object_check check (jsonb_typeof(preferences) = 'object'), + constraint user_preferences_size_check check (pg_column_size(preferences) <= 16384) +); + +revoke all on table public.user_favourites from public, anon, authenticated; +revoke all on table public.user_preferences from public, anon, authenticated; +grant select, insert, update, delete on table public.user_favourites to service_role; +grant select, insert, update, delete on table public.user_preferences to service_role; + create unique index if not exists documents_owner_content_hash_unique_idx on public.documents(owner_id, content_hash) where content_hash is not null; @@ -5267,7 +5295,9 @@ grant select, insert, update, delete on table public.api_rate_limit_subjects, public.audit_logs, public.storage_cleanup_jobs, - public.rag_retrieval_logs + public.rag_retrieval_logs, + public.user_favourites, + public.user_preferences to service_role; revoke all on public.audit_logs from anon, authenticated; @@ -5313,6 +5343,8 @@ alter table public.api_rate_limit_subjects enable row level security; alter table public.audit_logs enable row level security; alter table public.storage_cleanup_jobs enable row level security; alter table public.rag_retrieval_logs enable row level security; +alter table public.user_favourites enable row level security; +alter table public.user_preferences enable row level security; create policy "import batches owner read" on public.import_batches for select to authenticated using (owner_id = (select auth.uid())); @@ -5427,6 +5459,30 @@ create policy "storage cleanup owner read" on public.storage_cleanup_jobs for select to authenticated using ((select auth.uid()) = owner_id); +create policy "users read own favourites" on public.user_favourites + for select to authenticated + using ((select auth.uid()) = user_id); +create policy "users insert own favourites" on public.user_favourites + for insert to authenticated + with check ((select auth.uid()) = user_id); +create policy "users delete own favourites" on public.user_favourites + for delete to authenticated + using ((select auth.uid()) = user_id); + +create policy "users read own preferences" on public.user_preferences + for select to authenticated + using ((select auth.uid()) = user_id); +create policy "users insert own preferences" on public.user_preferences + for insert to authenticated + with check ((select auth.uid()) = user_id); +create policy "users update own preferences" on public.user_preferences + for update to authenticated + using ((select auth.uid()) = user_id) + with check ((select auth.uid()) = user_id); +create policy "users delete own preferences" on public.user_preferences + for delete to authenticated + using ((select auth.uid()) = user_id); + create policy "document storage owner read" on storage.objects for select to authenticated using (bucket_id = 'clinical-documents' and (storage.foldername(name))[1] = (select auth.uid())::text); @@ -5435,6 +5491,11 @@ create policy "image storage owner read" on storage.objects for select to authenticated using (bucket_id = 'clinical-images' and (storage.foldername(name))[1] = (select auth.uid())::text); +-- Uploads are accepted only by the server route after it validates the +-- administrator claim. Remove direct Data/Storage API write capability from +-- both public roles as a second enforcement layer. +revoke insert, update, delete on table storage.objects from anon, authenticated; + drop trigger if exists document_index_units_updated_at on public.document_index_units; create trigger document_index_units_updated_at before update on public.document_index_units From 553ea785d19688d5e3ec70ec23872f5136a87b1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 09:59:31 +0000 Subject: [PATCH 07/10] fix(account): add no-store cache headers to favourites reads Return Cache-Control: private, no-store on GET /api/account/favourites so signed-in saved-item lists cannot be cached across sign-out or account switches, matching the preferences endpoint. Co-authored-by: BigSimmo --- src/app/api/account/favourites/route.ts | 21 ++++++++++++++------- tests/account-access-model.test.ts | 8 ++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/app/api/account/favourites/route.ts b/src/app/api/account/favourites/route.ts index 95717f2d5..94f441765 100644 --- a/src/app/api/account/favourites/route.ts +++ b/src/app/api/account/favourites/route.ts @@ -25,13 +25,20 @@ export async function GET(request: Request) { .eq("user_id", user.id) .order("created_at", { ascending: false }); if (error) throw new Error(error.message); - return Response.json({ - favourites: (data ?? []).map((row) => ({ - contentType: row.content_type, - contentKey: row.content_key, - createdAt: row.created_at, - })), - }); + return Response.json( + { + favourites: (data ?? []).map((row) => ({ + contentType: row.content_type, + contentKey: row.content_key, + createdAt: row.created_at, + })), + }, + { + headers: { + "Cache-Control": "private, no-store", + }, + }, + ); } catch (error) { return jsonError(error); } diff --git a/tests/account-access-model.test.ts b/tests/account-access-model.test.ts index 8c8a1b9f3..35107a1a1 100644 --- a/tests/account-access-model.test.ts +++ b/tests/account-access-model.test.ts @@ -90,4 +90,12 @@ describe("public content and account authorization model", () => { expect(detailSource).not.toMatch(/if\s*\(\s*!\s*\(\s*await accountData\.setFavourite/); } }); + + it("marks account favourites reads as private and non-cacheable", () => { + const favouritesRoute = source("src/app/api/account/favourites/route.ts"); + const preferencesRoute = source("src/app/api/account/preferences/route.ts"); + + expect(favouritesRoute).toContain('"Cache-Control": "private, no-store"'); + expect(preferencesRoute).toContain('"Cache-Control": "private, no-store"'); + }); }); From 85519ec746ab1a61338eb0987ac3e82a95d02a85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 10:01:11 +0000 Subject: [PATCH 08/10] fix(viewer): gate document summarization behind administrator access Align the Answer from this document control with the administrator-only summarize API so signed-in non-admin sessions no longer see an enabled action that returns 403. Co-authored-by: BigSimmo --- src/components/DocumentViewer.tsx | 14 +++++++++++--- tests/account-access-model.test.ts | 3 +++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 69f821903..f925c140a 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2282,7 +2282,11 @@ export function DocumentViewer({ async function summarize() { if (!canSummarizeDocument) { - setSummaryError("Load a source document before summarising."); + setSummaryError( + !canUseAdministrativeApis + ? "Administrator access is required to summarise documents." + : "Load a source document before summarising.", + ); return; } if (!canUsePrivateApis) { @@ -2407,8 +2411,12 @@ export function DocumentViewer({ ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}&documentId=${encodeURIComponent(documentId)}` : documentHomeHref; const usefulPageHref = (page: number) => documentPageHref(documentId, page); - const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; - const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering"; + const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUseAdministrativeApis; + const summarizeTitle = canSummarizeDocument + ? "Answer from this document" + : !canUseAdministrativeApis + ? "Administrator access is required to answer from this document" + : "Load a source document before answering"; const pageByNumber = useMemo(() => new Map(pages.map((page) => [page.page_number, page])), [pages]); const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk])), [chunks]); const selectedPage = pageByNumber.get(activePage) ?? pages[0]; diff --git a/tests/account-access-model.test.ts b/tests/account-access-model.test.ts index 35107a1a1..ecee8ca4b 100644 --- a/tests/account-access-model.test.ts +++ b/tests/account-access-model.test.ts @@ -61,6 +61,9 @@ describe("public content and account authorization model", () => { expect(documentViewer).toContain("{canUseAdministrativeApis ? ("); expect(documentViewer).toContain("canManage={canUseAdministrativeApis}"); expect(documentViewer).toContain("canReview={canUseAdministrativeApis}"); + expect(documentViewer).toContain( + "const canSummarizeDocument = viewerState === \"ready\" && !loadingSummary && canUseAdministrativeApis", + ); }); it("does not turn an ordinary signed-in setup-status request into a server error", () => { From 2e7595d2347f0d5d9a738bd44a45a43c31444384 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 10:02:19 +0000 Subject: [PATCH 09/10] fix(scripts): add missing list-database-skills helper Implement the skills listing/check script referenced by npm run skills and npm run check:skills so the commands validate .agents/skills against the maintained database-skills.md catalog. Co-authored-by: BigSimmo --- scripts/list-database-skills.mjs | 120 +++++++++++++++++++++++++++++ tests/list-database-skills.test.ts | 39 ++++++++++ 2 files changed, 159 insertions(+) create mode 100644 scripts/list-database-skills.mjs create mode 100644 tests/list-database-skills.test.ts diff --git a/scripts/list-database-skills.mjs b/scripts/list-database-skills.mjs new file mode 100644 index 000000000..ada079035 --- /dev/null +++ b/scripts/list-database-skills.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * list-database-skills.mjs — list Database workflow skills under .agents/skills + * and verify the catalog stays in sync. + * + * npm run skills — print skill names and descriptions + * npm run check:skills — fail if filesystem skills and database-skills.md diverge + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const skillsRoot = path.join(repoRoot, ".agents", "skills"); +const catalogPath = path.join(skillsRoot, "database-skills.md"); + +function parseFrontmatter(markdown) { + const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + const fields = {}; + for (const line of match[1].split(/\r?\n/)) { + const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (field) fields[field[1]] = field[2].trim(); + } + return fields; +} + +/** Discover workflow skills from immediate child directories containing SKILL.md. */ +export function discoverSkills(root = skillsRoot, readFile = (filePath) => readFileSync(filePath, "utf8")) { + const entries = readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + return entries.map((id) => { + const skillPath = path.join(root, id, "SKILL.md"); + if (!statSync(skillPath, { throwIfNoEntry: false })?.isFile()) { + return { id, name: id, description: "", missingSkillFile: true }; + } + const frontmatter = parseFrontmatter(readFile(skillPath)); + return { + id, + name: frontmatter.name ?? id, + description: frontmatter.description ?? "", + missingSkillFile: false, + }; + }); +} + +/** Extract backtick skill ids from the maintained catalog markdown. */ +export function extractCatalogSkillIds(catalogMarkdown) { + const ids = new Set(); + for (const match of catalogMarkdown.matchAll(/`([a-z0-9][a-z0-9-]*)`/g)) { + ids.add(match[1]); + } + return [...ids].sort(); +} + +/** Return drift between discovered skills and the catalog. */ +export function findSkillDrift(skills, catalogIds) { + const discoveredIds = skills.filter((skill) => !skill.missingSkillFile).map((skill) => skill.id); + const discovered = new Set(discoveredIds); + const catalog = new Set(catalogIds); + const missingFromCatalog = discoveredIds.filter((id) => !catalog.has(id)); + const missingFromDisk = catalogIds.filter((id) => !discovered.has(id)); + const nameMismatches = skills + .filter((skill) => !skill.missingSkillFile && skill.name !== skill.id) + .map((skill) => ({ id: skill.id, name: skill.name })); + const missingSkillFiles = skills.filter((skill) => skill.missingSkillFile).map((skill) => skill.id); + return { missingFromCatalog, missingFromDisk, nameMismatches, missingSkillFiles }; +} + +function printSkills(skills) { + const available = skills.filter((skill) => !skill.missingSkillFile); + console.log(`Database skills (${available.length}):`); + for (const skill of available) { + const description = skill.description || "(no description)"; + console.log(`- ${skill.id} — ${description}`); + } +} + +function main() { + const checkMode = process.argv.includes("--check"); + const skills = discoverSkills(); + const catalogMarkdown = readFileSync(catalogPath, "utf8"); + const catalogIds = extractCatalogSkillIds(catalogMarkdown); + const drift = findSkillDrift(skills, catalogIds); + + if (!checkMode) { + printSkills(skills); + return; + } + + const problems = []; + if (drift.missingSkillFiles.length) { + problems.push(`missing SKILL.md: ${drift.missingSkillFiles.join(", ")}`); + } + if (drift.nameMismatches.length) { + problems.push( + `frontmatter name mismatch: ${drift.nameMismatches.map((item) => `${item.id} -> ${item.name}`).join(", ")}`, + ); + } + if (drift.missingFromCatalog.length) { + problems.push(`not listed in database-skills.md: ${drift.missingFromCatalog.join(", ")}`); + } + if (drift.missingFromDisk.length) { + problems.push(`catalog entries without skill directories: ${drift.missingFromDisk.join(", ")}`); + } + + if (problems.length > 0) { + console.error("skills check FAILED:"); + for (const problem of problems) console.error(`- ${problem}`); + process.exit(1); + } + + console.log(`skills check passed: ${skills.length} workflow skill(s) match database-skills.md.`); +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) main(); diff --git a/tests/list-database-skills.test.ts b/tests/list-database-skills.test.ts new file mode 100644 index 000000000..8878bdcb3 --- /dev/null +++ b/tests/list-database-skills.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { discoverSkills, extractCatalogSkillIds, findSkillDrift } from "../scripts/list-database-skills.mjs"; + +describe("list-database-skills", () => { + it("extracts frontmatter-backed skill metadata from a directory listing", () => { + const drift = findSkillDrift( + [ + { id: "database-flightplan", name: "database-flightplan", description: "Plan safe work.", missingSkillFile: false }, + { id: "workflows", name: "workflows", description: "List workflow skills.", missingSkillFile: false }, + ], + ["database-flightplan", "workflows"], + ); + expect(drift).toEqual({ + missingFromCatalog: [], + missingFromDisk: [], + nameMismatches: [], + missingSkillFiles: [], + }); + }); + + it("extracts catalog skill ids from database-skills.md", () => { + const catalog = readFileSync(new URL("../.agents/skills/database-skills.md", import.meta.url), "utf8"); + const ids = extractCatalogSkillIds(catalog); + expect(ids).toContain("database-flightplan"); + expect(ids).toContain("workflows"); + expect(ids.length).toBeGreaterThanOrEqual(8); + }); + + it("passes check mode against the committed skills catalog", () => { + const catalog = readFileSync(new URL("../.agents/skills/database-skills.md", import.meta.url), "utf8"); + const drift = findSkillDrift(discoverSkills(), extractCatalogSkillIds(catalog)); + expect(drift.missingSkillFiles).toEqual([]); + expect(drift.nameMismatches).toEqual([]); + expect(drift.missingFromCatalog).toEqual([]); + expect(drift.missingFromDisk).toEqual([]); + }); +}); From 966396a8d97abe061ca7674556c804d135c4f71a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 10:08:17 +0000 Subject: [PATCH 10/10] fix(ci): prettier-format new account and skills tests Co-authored-by: BigSimmo --- tests/account-access-model.test.ts | 2 +- tests/list-database-skills.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/account-access-model.test.ts b/tests/account-access-model.test.ts index ecee8ca4b..041e6dfdd 100644 --- a/tests/account-access-model.test.ts +++ b/tests/account-access-model.test.ts @@ -62,7 +62,7 @@ describe("public content and account authorization model", () => { expect(documentViewer).toContain("canManage={canUseAdministrativeApis}"); expect(documentViewer).toContain("canReview={canUseAdministrativeApis}"); expect(documentViewer).toContain( - "const canSummarizeDocument = viewerState === \"ready\" && !loadingSummary && canUseAdministrativeApis", + 'const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUseAdministrativeApis', ); }); diff --git a/tests/list-database-skills.test.ts b/tests/list-database-skills.test.ts index 8878bdcb3..0f826e08d 100644 --- a/tests/list-database-skills.test.ts +++ b/tests/list-database-skills.test.ts @@ -7,7 +7,12 @@ describe("list-database-skills", () => { it("extracts frontmatter-backed skill metadata from a directory listing", () => { const drift = findSkillDrift( [ - { id: "database-flightplan", name: "database-flightplan", description: "Plan safe work.", missingSkillFile: false }, + { + id: "database-flightplan", + name: "database-flightplan", + description: "Plan safe work.", + missingSkillFile: false, + }, { id: "workflows", name: "workflows", description: "List workflow skills.", missingSkillFile: false }, ], ["database-flightplan", "workflows"],