From 83235f084928e3f5c955cbc86131d33f7a908233 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:17:02 +0800 Subject: [PATCH 1/6] feat: add account controls and operational safeguards --- .agents/skills/access/SKILL.md | 13 + .agents/skills/access/agents/openai.yaml | 4 + .agents/skills/api/SKILL.md | 13 + .agents/skills/api/agents/openai.yaml | 4 + .agents/skills/audit/SKILL.md | 13 + .agents/skills/audit/agents/openai.yaml | 4 + .agents/skills/catalog.json | 31 ++ .agents/skills/clinical-change-proof/SKILL.md | 20 +- .../clinical-change-proof/agents/openai.yaml | 8 +- .agents/skills/clinical/SKILL.md | 13 + .agents/skills/clinical/agents/openai.yaml | 4 + .agents/skills/data/SKILL.md | 13 + .agents/skills/data/agents/openai.yaml | 4 + .agents/skills/database-flightplan/SKILL.md | 18 +- .../database-flightplan/agents/openai.yaml | 8 +- .agents/skills/database-skills.md | 20 +- .agents/skills/dependencies/SKILL.md | 13 + .../skills/dependencies/agents/openai.yaml | 4 + .agents/skills/deploy/SKILL.md | 13 + .agents/skills/deploy/agents/openai.yaml | 4 + .agents/skills/documents/SKILL.md | 13 + .agents/skills/documents/agents/openai.yaml | 4 + .agents/skills/drift/SKILL.md | 13 + .agents/skills/drift/agents/openai.yaml | 4 + .agents/skills/export/SKILL.md | 13 + .agents/skills/export/agents/openai.yaml | 4 + .agents/skills/fix/SKILL.md | 13 + .agents/skills/fix/agents/openai.yaml | 4 + .agents/skills/handover/SKILL.md | 13 + .agents/skills/handover/agents/openai.yaml | 4 + .agents/skills/health/SKILL.md | 13 + .agents/skills/health/agents/openai.yaml | 4 + .agents/skills/incident/SKILL.md | 13 + .agents/skills/incident/agents/openai.yaml | 4 + .agents/skills/ingest/SKILL.md | 13 + .agents/skills/ingest/agents/openai.yaml | 4 + .agents/skills/live-design-sweep/SKILL.md | 17 +- .../live-design-sweep/agents/openai.yaml | 8 +- .agents/skills/migrate/SKILL.md | 13 + .agents/skills/migrate/agents/openai.yaml | 4 + .agents/skills/operations/SKILL.md | 13 + .agents/skills/operations/agents/openai.yaml | 4 + .agents/skills/operator-closeout/SKILL.md | 23 +- .../operator-closeout/agents/openai.yaml | 8 +- .agents/skills/performance/SKILL.md | 13 + .agents/skills/performance/agents/openai.yaml | 4 + .agents/skills/plan/SKILL.md | 13 + .agents/skills/plan/agents/openai.yaml | 4 + .agents/skills/privacy/SKILL.md | 13 + .agents/skills/privacy/agents/openai.yaml | 4 + .agents/skills/rag-change-lab/SKILL.md | 16 +- .../skills/rag-change-lab/agents/openai.yaml | 8 +- .agents/skills/rag/SKILL.md | 13 + .agents/skills/rag/agents/openai.yaml | 4 + .agents/skills/recovery/SKILL.md | 13 + .agents/skills/recovery/agents/openai.yaml | 4 + .agents/skills/reindex/SKILL.md | 13 + .agents/skills/reindex/agents/openai.yaml | 4 + .agents/skills/release/SKILL.md | 13 + .agents/skills/release/agents/openai.yaml | 4 + .agents/skills/review/SKILL.md | 13 + .agents/skills/review/agents/openai.yaml | 4 + .agents/skills/run/SKILL.md | 12 + .agents/skills/run/agents/openai.yaml | 4 + .agents/skills/security/SKILL.md | 13 + .agents/skills/security/agents/openai.yaml | 4 + .agents/skills/session-lifecycle/SKILL.md | 17 +- .../session-lifecycle/agents/openai.yaml | 8 +- .agents/skills/skills/SKILL.md | 13 + .agents/skills/skills/agents/openai.yaml | 4 + .agents/skills/sources/SKILL.md | 13 + .agents/skills/sources/agents/openai.yaml | 4 + .agents/skills/task/SKILL.md | 13 + .agents/skills/task/agents/openai.yaml | 4 + .agents/skills/test/SKILL.md | 13 + .agents/skills/test/agents/openai.yaml | 4 + .agents/skills/ui/SKILL.md | 13 + .agents/skills/ui/agents/openai.yaml | 4 + .agents/skills/verify-triage-fix/SKILL.md | 21 +- .../verify-triage-fix/agents/openai.yaml | 8 +- .agents/skills/workflows/SKILL.md | 30 +- .agents/skills/workflows/agents/openai.yaml | 8 +- .env.example | 9 +- .github/workflows/ci.yml | 2 +- .github/workflows/eval-canary.yml | 54 +++- AGENTS.md | 20 +- docs/branch-review-ledger.md | 1 + docs/deployment-architecture.md | 6 +- docs/observability-slos.md | 40 ++- docs/operator-backlog.md | 14 +- docs/process-hardening.md | 2 +- docs/productivity-workflows.md | 2 + docs/site-map.md | 2 + docs/tenancy-defense-in-depth-review.md | 24 +- package.json | 3 + railway.worker.json | 3 +- scripts/check-default-acl.ts | 2 +- scripts/check-edge-functions.mjs | 18 +- scripts/check-env-parity.mjs | 23 +- scripts/generate-drift-manifest.ts | 4 +- scripts/list-database-skills.mjs | 198 ++++++++++++ scripts/productivity-core.mjs | 25 +- 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 | 82 +++-- src/components/DocumentViewer.tsx | 48 ++- src/components/account-data-provider.tsx | 198 ++++++++++++ .../favourites-command-library-page.tsx | 11 + .../global-search-shell.tsx | 18 +- .../master-search-header.tsx | 2 +- .../clinical-dashboard/settings-dialog.tsx | 49 ++- .../clinical-dashboard/use-app-preferences.ts | 203 +++++------- .../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 +++ supabase/config.toml | 3 + supabase/drift-manifest.json | 301 +++++++++++++++--- supabase/functions/ingestion-worker/auth.ts | 30 ++ supabase/functions/ingestion-worker/index.ts | 281 ++++++++++++++++ ...00_assert_postgres_default_privileges.sql} | 83 ++--- ...sert_supabase_admin_default_privileges.sql | 179 ----------- ...0_reassert_postgres_default_privileges.sql | 37 +++ ...532_repair_postgres_default_privileges.sql | 159 +++++++++ ...53533_enforce_public_title_word_scope.sql} | 16 +- ...35_user_account_data_and_admin_uploads.sql | 59 ++++ supabase/roles.sql | 67 ++-- supabase/schema.sql | 275 ++++++++-------- tests/account-access-model.test.ts | 82 +++++ tests/client-secret-surface.test.ts | 2 +- ...clinical-dashboard-merge-artifacts.test.ts | 10 +- tests/database-skills.test.ts | 74 +++++ tests/eval-canary-workflow.test.ts | 22 +- tests/forms-back-navigation.dom.test.tsx | 7 + tests/ingestion-edge-function-auth.test.ts | 32 ++ tests/private-access-routes.test.ts | 63 ++-- tests/productivity-workflow.test.ts | 29 ++ tests/railway-config.test.ts | 5 + tests/repo-hygiene.test.ts | 23 +- tests/retrieval-access-scope.test.ts | 16 +- tests/supabase-schema.test.ts | 75 ++--- tests/ui-smoke.spec.ts | 121 +++++-- tests/ui-tools.spec.ts | 36 +++ 169 files changed, 3415 insertions(+), 1179 deletions(-) create mode 100644 .agents/skills/access/SKILL.md create mode 100644 .agents/skills/access/agents/openai.yaml create mode 100644 .agents/skills/api/SKILL.md create mode 100644 .agents/skills/api/agents/openai.yaml create mode 100644 .agents/skills/audit/SKILL.md create mode 100644 .agents/skills/audit/agents/openai.yaml create mode 100644 .agents/skills/catalog.json create mode 100644 .agents/skills/clinical/SKILL.md create mode 100644 .agents/skills/clinical/agents/openai.yaml create mode 100644 .agents/skills/data/SKILL.md create mode 100644 .agents/skills/data/agents/openai.yaml create mode 100644 .agents/skills/dependencies/SKILL.md create mode 100644 .agents/skills/dependencies/agents/openai.yaml create mode 100644 .agents/skills/deploy/SKILL.md create mode 100644 .agents/skills/deploy/agents/openai.yaml create mode 100644 .agents/skills/documents/SKILL.md create mode 100644 .agents/skills/documents/agents/openai.yaml create mode 100644 .agents/skills/drift/SKILL.md create mode 100644 .agents/skills/drift/agents/openai.yaml create mode 100644 .agents/skills/export/SKILL.md create mode 100644 .agents/skills/export/agents/openai.yaml create mode 100644 .agents/skills/fix/SKILL.md create mode 100644 .agents/skills/fix/agents/openai.yaml create mode 100644 .agents/skills/handover/SKILL.md create mode 100644 .agents/skills/handover/agents/openai.yaml create mode 100644 .agents/skills/health/SKILL.md create mode 100644 .agents/skills/health/agents/openai.yaml create mode 100644 .agents/skills/incident/SKILL.md create mode 100644 .agents/skills/incident/agents/openai.yaml create mode 100644 .agents/skills/ingest/SKILL.md create mode 100644 .agents/skills/ingest/agents/openai.yaml create mode 100644 .agents/skills/migrate/SKILL.md create mode 100644 .agents/skills/migrate/agents/openai.yaml create mode 100644 .agents/skills/operations/SKILL.md create mode 100644 .agents/skills/operations/agents/openai.yaml create mode 100644 .agents/skills/performance/SKILL.md create mode 100644 .agents/skills/performance/agents/openai.yaml create mode 100644 .agents/skills/plan/SKILL.md create mode 100644 .agents/skills/plan/agents/openai.yaml create mode 100644 .agents/skills/privacy/SKILL.md create mode 100644 .agents/skills/privacy/agents/openai.yaml create mode 100644 .agents/skills/rag/SKILL.md create mode 100644 .agents/skills/rag/agents/openai.yaml create mode 100644 .agents/skills/recovery/SKILL.md create mode 100644 .agents/skills/recovery/agents/openai.yaml create mode 100644 .agents/skills/reindex/SKILL.md create mode 100644 .agents/skills/reindex/agents/openai.yaml create mode 100644 .agents/skills/release/SKILL.md create mode 100644 .agents/skills/release/agents/openai.yaml create mode 100644 .agents/skills/review/SKILL.md create mode 100644 .agents/skills/review/agents/openai.yaml create mode 100644 .agents/skills/run/SKILL.md create mode 100644 .agents/skills/run/agents/openai.yaml create mode 100644 .agents/skills/security/SKILL.md create mode 100644 .agents/skills/security/agents/openai.yaml create mode 100644 .agents/skills/skills/SKILL.md create mode 100644 .agents/skills/skills/agents/openai.yaml create mode 100644 .agents/skills/sources/SKILL.md create mode 100644 .agents/skills/sources/agents/openai.yaml create mode 100644 .agents/skills/task/SKILL.md create mode 100644 .agents/skills/task/agents/openai.yaml create mode 100644 .agents/skills/test/SKILL.md create mode 100644 .agents/skills/test/agents/openai.yaml create mode 100644 .agents/skills/ui/SKILL.md create mode 100644 .agents/skills/ui/agents/openai.yaml create mode 100644 scripts/list-database-skills.mjs 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/functions/ingestion-worker/auth.ts create mode 100644 supabase/functions/ingestion-worker/index.ts rename supabase/migrations/{20260717173000_reassert_supabase_admin_default_privileges.sql => 20260717161000_assert_postgres_default_privileges.sql} (51%) delete mode 100644 supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql create mode 100644 supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql create mode 100644 supabase/migrations/20260719053532_repair_postgres_default_privileges.sql rename supabase/migrations/{20260718223000_enforce_public_title_word_scope.sql => 20260719053533_enforce_public_title_word_scope.sql} (88%) create mode 100644 supabase/migrations/20260719064735_user_account_data_and_admin_uploads.sql create mode 100644 tests/account-access-model.test.ts create mode 100644 tests/database-skills.test.ts create mode 100644 tests/ingestion-edge-function-auth.test.ts diff --git a/.agents/skills/access/SKILL.md b/.agents/skills/access/SKILL.md new file mode 100644 index 000000000..74e603141 --- /dev/null +++ b/.agents/skills/access/SKILL.md @@ -0,0 +1,13 @@ +--- +name: access +description: Verify Database authentication, authorization, ownership, tenancy, grants, RLS, administrative boundaries, and conservative denial behavior. Use for auth changes, private routes, owner scope, cross-tenant risk, or permissions. +--- + +# Access + +1. Map actor, credential, resource owner, tenancy boundary, route guard, database policy, and service-role use. +2. Test unauthenticated, wrong-owner, non-admin, stale-session, missing-record, and cross-tenant paths. +3. Inspect RLS, grants, security-definer functions, public API keys, and server/client trust boundaries. +4. Run focused local access-model, owner-scope, and private-route tests. +5. Keep staging cross-tenant tests and live Supabase checks approval-gated. +6. Report the authorization decision matrix, denial proof, bypass risk, and unverified live boundary. diff --git a/.agents/skills/access/agents/openai.yaml b/.agents/skills/access/agents/openai.yaml new file mode 100644 index 000000000..77086bf78 --- /dev/null +++ b/.agents/skills/access/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Access" + short_description: "Verify Database auth and tenancy boundaries" + default_prompt: "Use $access to verify authentication, authorization, ownership, grants, RLS, and cross-tenant denial behavior." diff --git a/.agents/skills/api/SKILL.md b/.agents/skills/api/SKILL.md new file mode 100644 index 000000000..c9ea7d018 --- /dev/null +++ b/.agents/skills/api/SKILL.md @@ -0,0 +1,13 @@ +--- +name: api +description: Review Database route and function contracts for validation, authentication, authorization, errors, retries, idempotency, compatibility, and safe provider boundaries. Use for API route, edge function, webhook, or public contract work. +--- + +# API + +1. Identify callers, request schema, authentication, authorization, response contract, status codes, and side effects. +2. Test invalid, empty, oversized, duplicate, stale, unauthorized, timeout, retry, and dependency-failure cases. +3. Check stable error envelopes, cache behavior, rate assumptions, idempotency, and backwards compatibility. +4. Use route-unit tests and mocks locally; never send live requests without approval. +5. Verify secrets and privileged clients remain server-only. +6. Report contract changes, compatibility risk, focused proofs, and untested live integrations. diff --git a/.agents/skills/api/agents/openai.yaml b/.agents/skills/api/agents/openai.yaml new file mode 100644 index 000000000..6efeebe86 --- /dev/null +++ b/.agents/skills/api/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "API" + short_description: "Review Database API contracts and failure paths" + default_prompt: "Use $api to verify this Database route contract, validation, auth, errors, retries, and compatibility." diff --git a/.agents/skills/audit/SKILL.md b/.agents/skills/audit/SKILL.md new file mode 100644 index 000000000..c483fb934 --- /dev/null +++ b/.agents/skills/audit/SKILL.md @@ -0,0 +1,13 @@ +--- +name: audit +description: Perform a deep evidence-backed Database repository audit and produce severity-ranked, reproducible findings without mutating code unless fixes are requested. Use for repo-wide audits, risk assessments, or exhaustive review requests. +--- + +# Audit + +1. Define scope, read `docs/codex-review-protocol.md`, and inspect the review ledger. +2. Inventory architecture, trust boundaries, changed areas, tests, and documented controls. +3. Trace realistic correctness, security, privacy, clinical, data-loss, and reliability failures. +4. Prove findings with exact files, lines, triggers, impact, and focused checks. +5. Rank findings by severity and distinguish confirmed defects from residual risk. +6. Remain read-only unless the user explicitly asks for fixes; keep provider checks approval-gated. diff --git a/.agents/skills/audit/agents/openai.yaml b/.agents/skills/audit/agents/openai.yaml new file mode 100644 index 000000000..f37bcca9d --- /dev/null +++ b/.agents/skills/audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Audit" + short_description: "Audit the Database repository with concrete evidence" + default_prompt: "Use $audit to perform an evidence-backed Database audit with severity-ranked reproducible findings." diff --git a/.agents/skills/catalog.json b/.agents/skills/catalog.json new file mode 100644 index 000000000..21b4ce308 --- /dev/null +++ b/.agents/skills/catalog.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "categories": [ + { + "name": "Everyday", + "skills": ["skills", "plan", "run", "test", "fix", "review", "task", "handover", "health", "audit", "export"] + }, + { + "name": "Clinical and app", + "skills": ["clinical", "ui", "rag", "sources", "ingest", "reindex", "documents"] + }, + { + "name": "Database, API, and security", + "skills": ["migrate", "drift", "access", "security", "privacy", "api", "data"] + }, + { + "name": "Operations and release", + "skills": ["release", "deploy", "operations", "incident", "recovery", "performance", "dependencies"] + } + ], + "aliases": { + "workflows": "skills", + "database-flightplan": "plan", + "verify-triage-fix": "fix", + "clinical-change-proof": "clinical", + "live-design-sweep": "ui", + "rag-change-lab": "rag", + "operator-closeout": "operations", + "session-lifecycle": "task" + } +} diff --git a/.agents/skills/clinical-change-proof/SKILL.md b/.agents/skills/clinical-change-proof/SKILL.md index d5634bd7b..c823211fc 100644 --- a/.agents/skills/clinical-change-proof/SKILL.md +++ b/.agents/skills/clinical-change-proof/SKILL.md @@ -1,20 +1,10 @@ --- name: clinical-change-proof -description: Build evidence for Database changes affecting clinical output, ingestion, retrieval, ranking, answers, citations, source rendering, privacy, owner scoping, document access, Supabase, or production behavior. Use for implementation, review, readiness, or handoff work that must satisfy clinical governance and conservative failure requirements. +description: Compatibility alias for the clinical skill. Use only when the user explicitly invokes the older clinical-change-proof name; otherwise use clinical for safety and governance evidence. --- -# Clinical Change Proof +# Clinical Change Proof Alias -1. Generate the scoped evidence plan: - `npm run workflow:clinical-proof -- --write-evidence` -2. Read only the relevant sections of `.github/pull_request_template.md`, `docs/clinical-governance.md`, and the domain runbook selected by the change. -3. Trace the full behavior boundary: input, authorization/owner scope, retrieval or write path, output contract, source evidence, logs, and failure fallback. -4. Prove locally that: - - private or service-role data remains server-only and fail-closed; - - source-backed claims and conservative unknown/outdated behavior are preserved; - - demo data remains distinct from clinical sources; - - rollback or feature-disable behavior is documented; - - affected behavior has focused regression coverage. -5. Run the local checks in the plan, narrowest first. The offline RAG contract is mandatory when retrieval or answer behavior is involved. -6. Treat every command in the plan's `approvalRequired` list as an independent confirmation-required action, including retrieval, deployment, release, remote Git, hosted CI, Supabase, OpenAI, and production-readiness commands. Never bundle approvals, infer approval from another action, or execute one indirectly through a local gate or wrapper. -7. Complete the Clinical Governance Preflight and report evidence, gaps, rollback, and any SaMD implication. +1. Read and follow `.agents/skills/clinical/SKILL.md`. +2. Use `clinical` in recommendations and new documentation. +3. Do not maintain a second copy of the clinical proof procedure here. diff --git a/.agents/skills/clinical-change-proof/agents/openai.yaml b/.agents/skills/clinical-change-proof/agents/openai.yaml index b526d3734..b7dca1d30 100644 --- a/.agents/skills/clinical-change-proof/agents/openai.yaml +++ b/.agents/skills/clinical-change-proof/agents/openai.yaml @@ -1,4 +1,6 @@ interface: - display_name: "Clinical Change Proof" - short_description: "Build clinical governance evidence" - default_prompt: "Use $clinical-change-proof to assess this clinical change and produce the required local evidence, governance checks, and provider approval gates." + display_name: "Clinical Alias" + short_description: "Compatibility alias for Database clinical proof" + default_prompt: "Use $clinical-change-proof only as a compatibility alias for $clinical." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/clinical/SKILL.md b/.agents/skills/clinical/SKILL.md new file mode 100644 index 000000000..5b0601f54 --- /dev/null +++ b/.agents/skills/clinical/SKILL.md @@ -0,0 +1,13 @@ +--- +name: clinical +description: Assemble clinical safety, privacy, source-governance, rollback, and production-readiness evidence for Database changes. Use for answer generation, retrieval, ingestion, clinical content, source rendering, or other patient-safety-sensitive behavior. +--- + +# Clinical + +1. Run `npm run workflow:clinical-proof -- --write-evidence` for the affected paths. +2. Trace the clinical claim path from source and retrieval through generation and rendering. +3. Check conservative failure behavior, ownership, privacy, citations, metadata, and rollback. +4. Add the smallest deterministic local safety proof and run relevant offline domain checks. +5. Complete the governance checklist required by `.github/pull_request_template.md` for handoff. +6. Keep live Supabase, OpenAI, production-readiness, and provider evaluations approval-gated. diff --git a/.agents/skills/clinical/agents/openai.yaml b/.agents/skills/clinical/agents/openai.yaml new file mode 100644 index 000000000..fe23dcdc9 --- /dev/null +++ b/.agents/skills/clinical/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Clinical" + short_description: "Prove clinical safety and source governance" + default_prompt: "Use $clinical to build local clinical safety, privacy, source, rollback, and verification evidence for this change." diff --git a/.agents/skills/data/SKILL.md b/.agents/skills/data/SKILL.md new file mode 100644 index 000000000..2b9e59099 --- /dev/null +++ b/.agents/skills/data/SKILL.md @@ -0,0 +1,13 @@ +--- +name: data +description: Validate Database imports, seeds, transformations, reconciliation, deduplication, integrity, provenance, and rollback controls. Use for dataset changes, bulk operations, registry content, or data migration logic. +--- + +# Data + +1. Identify source format, trust level, schema, identifiers, ownership, transformations, and destination constraints. +2. Test malformed, missing, duplicate, conflicting, oversized, partial, and rerun inputs with local fixtures. +3. Check deterministic normalization, idempotency, transactions, reconciliation, provenance, and rollback. +4. Measure counts and invariants before and after local dry runs without exposing sensitive values. +5. Treat live imports, seeds, backfills, database writes, and production exports as approval-required. +6. Report accepted/rejected records, integrity proof, rollback, and remaining operator action. diff --git a/.agents/skills/data/agents/openai.yaml b/.agents/skills/data/agents/openai.yaml new file mode 100644 index 000000000..5fa004277 --- /dev/null +++ b/.agents/skills/data/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Data" + short_description: "Validate Database imports and data integrity" + default_prompt: "Use $data to verify this Database import, seed, transformation, or reconciliation workflow safely." diff --git a/.agents/skills/database-flightplan/SKILL.md b/.agents/skills/database-flightplan/SKILL.md index 8fd01a935..e3e578fef 100644 --- a/.agents/skills/database-flightplan/SKILL.md +++ b/.agents/skills/database-flightplan/SKILL.md @@ -1,18 +1,10 @@ --- name: database-flightplan -description: Plan safe, risk-scoped work in the Database repository by inspecting the current diff, selecting the smallest local verification ladder, and separating provider-backed checks into explicit approval gates. Use before non-trivial source, config, test, UI, database, retrieval, clinical, dependency, CI, or deployment changes, or when the user asks what checks a change needs. +description: Compatibility alias for the plan skill. Use only when the user explicitly invokes the older database-flightplan name; otherwise use plan for risk-scoped offline verification planning. --- -# Database Flightplan +# Database Flightplan Alias -1. Run the repository task-start preflight if it has not run for the current task. Inspect branch and full Git status; preserve unrelated work. -2. Generate the plan: - `npm run workflow:flightplan -- --write-evidence` - Use `--files pathA,pathB` when planning proposed paths before editing. -3. Confirm the risk classes match the actual behavior, not only filenames. Add a focused check when the change crosses an unclassified boundary. -4. Execute the narrowest relevant check first. Use `--run` only when running the full printed local/offline sequence is proportionate. -5. Never execute anything listed under `approvalRequired` without explicit user confirmation. Treat indirect provider calls the same way. -6. After fixes, run `npm run verify:pr-local` when the change is ready for handoff. Add UI or domain gates only when the plan selects them. -7. Report changed files, checks and results, checks not run, approval gates, branch state, and residual risk. - -Keep the planner authoritative for deterministic selection. Do not reproduce its path tables in the skill. +1. Read and follow `.agents/skills/plan/SKILL.md`. +2. Use `plan` in recommendations and new documentation. +3. Do not maintain a second copy of the planning procedure here. diff --git a/.agents/skills/database-flightplan/agents/openai.yaml b/.agents/skills/database-flightplan/agents/openai.yaml index f2f201b90..8c182904a 100644 --- a/.agents/skills/database-flightplan/agents/openai.yaml +++ b/.agents/skills/database-flightplan/agents/openai.yaml @@ -1,4 +1,6 @@ interface: - display_name: "Database Flightplan" - short_description: "Plan safe repo changes and verification" - default_prompt: "Use $database-flightplan to inspect the current Database repo change and produce a risk-scoped local verification and approval plan." + display_name: "Plan Alias" + short_description: "Compatibility alias for Database planning" + default_prompt: "Use $database-flightplan only as a compatibility alias for $plan." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/database-skills.md b/.agents/skills/database-skills.md index 2b6af658a..81ae496e2 100644 --- a/.agents/skills/database-skills.md +++ b/.agents/skills/database-skills.md @@ -1,20 +1,6 @@ # Database skills catalog -This repository has the following Database-specific skills. +Run `npm run skills` to render the current categories from `catalog.json` and explanations from each canonical skill's frontmatter. +Run `npm run check:skills` to prove that every canonical skill and compatibility alias has valid local metadata. -## Workflow and repo orchestration skills - -- `database-flightplan` — create a risk-scoped verification plan before non-trivial work. -- `verify-triage-fix` — diagnose and fix local verification failures with minimal repros. -- `clinical-change-proof` — assemble clinical/medical safety and production-readiness evidence. -- `live-design-sweep` — inspect the running app across routes, breakpoints, accessibility modes, and interactions. -- `rag-change-lab` — validate retrieval/ranking/grounding changes with focused offline checks. -- `operator-closeout` — turn operator/provisioning/debt tasks into an ordered, approval-gated execution plan. -- `session-lifecycle` — manage safe task start/handoff/cleanup and merge-proof transitions. -- `workflows` — this new skill: indexed reference describing each Database workflow skill and when to use it. - -## Notes - -- These are the skills currently present in `.agents/skills` for this Database worktree. -- Use these skills as lightweight orchestration and evidence-first planning helpers. -- The first step for most non-trivial tasks remains `start-codex-task.ps1` (as required by AGENTS instructions). +The `skills` skill uses this generated view, explains each unique skill in the chat, and recommends the smallest useful set for the current request. Compatibility aliases remain callable but are not counted or displayed as separate skills. diff --git a/.agents/skills/dependencies/SKILL.md b/.agents/skills/dependencies/SKILL.md new file mode 100644 index 000000000..21c0c48af --- /dev/null +++ b/.agents/skills/dependencies/SKILL.md @@ -0,0 +1,13 @@ +--- +name: dependencies +description: Maintain Database dependencies safely by checking compatible stable versions, release notes, peer and engine constraints, old API usage, lockfile integrity, security, and focused verification. Use for dependency updates or reviews. +--- + +# Dependencies + +1. Inspect branch, status, Node/npm versions, `package.json`, lockfile, npm configuration, and active repo processes. +2. Compare direct dependencies with stable compatible releases and group risky ecosystems coherently. +3. Read migration notes for framework, runtime, build, test, lint, database, and security-sensitive updates. +4. Search for old APIs and make only the smallest compatibility changes. +5. When an update is requested, regenerate the existing npm lockfile and verify install integrity, focused behavior, then broader local gates. Keep review-only requests read-only. +6. Do not use force flags, switch package managers, publish, deploy, or call provider APIs without approval. diff --git a/.agents/skills/dependencies/agents/openai.yaml b/.agents/skills/dependencies/agents/openai.yaml new file mode 100644 index 000000000..2695c35e4 --- /dev/null +++ b/.agents/skills/dependencies/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Dependencies" + short_description: "Maintain Database dependencies safely" + default_prompt: "Use $dependencies to review or update Database dependencies with compatibility, lockfile, and verification discipline." diff --git a/.agents/skills/deploy/SKILL.md b/.agents/skills/deploy/SKILL.md new file mode 100644 index 000000000..c3ab60038 --- /dev/null +++ b/.agents/skills/deploy/SKILL.md @@ -0,0 +1,13 @@ +--- +name: deploy +description: Review Database deployment configuration, environment parity, build and boot behavior, workers, migrations, health checks, and rollback readiness. Use for deployment changes, hosting configuration, runtime failures, or production setup. +--- + +# Deploy + +1. Map web, worker, database, build, start, health, environment, migration, and rollback responsibilities. +2. Inspect deployment manifests and environment contracts without printing secrets. +3. Run static config, offline build/boot, and environment-parity checks selected by risk. +4. Check ordering, zero-downtime compatibility, health timeouts, restart behavior, and rollback. +5. Treat hosted deployments, provider dashboards, production health, secrets, and live databases as approval-required. +6. Report configuration proof, blockers, rollback readiness, and exact gated deployment action. diff --git a/.agents/skills/deploy/agents/openai.yaml b/.agents/skills/deploy/agents/openai.yaml new file mode 100644 index 000000000..7a6190511 --- /dev/null +++ b/.agents/skills/deploy/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Deploy" + short_description: "Review Database deployment and rollback readiness" + default_prompt: "Use $deploy to verify Database deployment configuration, environment parity, boot behavior, and rollback readiness." diff --git a/.agents/skills/documents/SKILL.md b/.agents/skills/documents/SKILL.md new file mode 100644 index 000000000..169107a81 --- /dev/null +++ b/.agents/skills/documents/SKILL.md @@ -0,0 +1,13 @@ +--- +name: documents +description: Verify Database document access, storage, signed images, metadata, extraction, labels, downloads, and lifecycle behavior. Use for private documents, image loading, signed URLs, document routes, or storage defects. +--- + +# Documents + +1. Trace document identity, owner scope, metadata, storage key, route authorization, and rendered state. +2. Check private access, signed URL expiry, missing objects, image/page selection, downloads, deletion, and stale metadata. +3. Reproduce with local fixtures and focused route or component tests. +4. Verify no service-role secret or private object path reaches the client. +5. Treat live Supabase storage, production documents, and signed-URL calls as approval-required. +6. Report access proof, storage assumptions, failure behavior, and remaining live verification. diff --git a/.agents/skills/documents/agents/openai.yaml b/.agents/skills/documents/agents/openai.yaml new file mode 100644 index 000000000..fca3342f6 --- /dev/null +++ b/.agents/skills/documents/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Documents" + short_description: "Verify Database document and storage behavior" + default_prompt: "Use $documents to trace and verify this document access, storage, signed image, or metadata behavior." diff --git a/.agents/skills/drift/SKILL.md b/.agents/skills/drift/SKILL.md new file mode 100644 index 000000000..4546f3362 --- /dev/null +++ b/.agents/skills/drift/SKILL.md @@ -0,0 +1,13 @@ +--- +name: drift +description: Detect Database schema, migration, type, environment, and generated-artifact drift offline, while keeping live Supabase comparison approval-gated. Use when schema sources disagree or deployment drift is suspected. +--- + +# Drift + +1. Compare repository migrations, `supabase/schema.sql`, generated database types, manifests, and migration references. +2. Run static drift and consistency checks that do not contact a provider. +3. Distinguish committed schema drift, stale generated output, missing migration history, and environment-only mismatch. +4. Add a deterministic manifest or test when the mismatch can recur silently. +5. Ask before `npm run check:supabase-project`, live schema inspection, CLI linking, pulling, or diffing. +6. Report exact sources of truth, mismatch, remediation order, and remaining live comparison. diff --git a/.agents/skills/drift/agents/openai.yaml b/.agents/skills/drift/agents/openai.yaml new file mode 100644 index 000000000..72e2b2868 --- /dev/null +++ b/.agents/skills/drift/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Drift" + short_description: "Detect Database schema and migration drift" + default_prompt: "Use $drift to compare local Database schema sources and isolate drift before any approved live comparison." diff --git a/.agents/skills/export/SKILL.md b/.agents/skills/export/SKILL.md new file mode 100644 index 000000000..d16af726a --- /dev/null +++ b/.agents/skills/export/SKILL.md @@ -0,0 +1,13 @@ +--- +name: export +description: Create a review-safe Database codebase archive with a manifest while excluding secrets, dependencies, build output, caches, logs, and machine-local state. Use when the user asks to package or export the repository for review. +--- + +# Export + +1. Inspect tracked, untracked, ignored, sensitive, generated, and oversized paths without printing secrets. +2. Stage the archive outside the repository and include `EXPORT_MANIFEST.md`. +3. Include only review-relevant source, configuration, tests, and documentation. +4. Exclude `.env*`, credentials, dependencies, builds, caches, logs, browser artifacts, and local state. +5. Verify the archive opens, inspect its file list, and confirm its size before handoff. +6. Do not alter the worktree, commit, push, upload, or send the archive without explicit authorization. diff --git a/.agents/skills/export/agents/openai.yaml b/.agents/skills/export/agents/openai.yaml new file mode 100644 index 000000000..336c4097b --- /dev/null +++ b/.agents/skills/export/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Export" + short_description: "Create a safe review archive of the Database repo" + default_prompt: "Use $export to create a verified review-safe Database archive with sensitive and generated files excluded." diff --git a/.agents/skills/fix/SKILL.md b/.agents/skills/fix/SKILL.md new file mode 100644 index 000000000..9f6b1047e --- /dev/null +++ b/.agents/skills/fix/SKILL.md @@ -0,0 +1,13 @@ +--- +name: fix +description: Diagnose and repair a local Database verification failure using the smallest reproducer and safest scoped change. Use when lint, typecheck, tests, builds, browser checks, or offline evaluations fail or hang. +--- + +# Fix + +1. Capture the exact failing command, output, duration, and environment context. +2. Run `npm run workflow:triage -- --log --write-evidence` when a saved failure log exists. +3. Classify the cause as code, test, tooling, resource, race, or provider/configuration. +4. Reproduce with the narrowest deterministic check before editing. +5. Make the smallest safe fix, rerun that check, then widen proportionally. +6. Do not run provider-backed remediation without explicit approval. diff --git a/.agents/skills/fix/agents/openai.yaml b/.agents/skills/fix/agents/openai.yaml new file mode 100644 index 000000000..18d3c758c --- /dev/null +++ b/.agents/skills/fix/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fix" + short_description: "Triage and repair local Database check failures" + default_prompt: "Use $fix to reproduce, classify, and minimally repair this local Database verification failure." diff --git a/.agents/skills/handover/SKILL.md b/.agents/skills/handover/SKILL.md new file mode 100644 index 000000000..e8ee81cd7 --- /dev/null +++ b/.agents/skills/handover/SKILL.md @@ -0,0 +1,13 @@ +--- +name: handover +description: Prepare a concise evidence-backed Database handover without automatically committing, pushing, opening a PR, or calling providers. Use when work is ready for another person, task, review, or explicit upload workflow. +--- + +# Handover + +1. Inspect branch, upstream, worktrees, status, relevant diffs, and recent commits. +2. Run `npm run workflow:lifecycle -- --phase handoff --write-evidence`. +3. Run the smallest proportionate offline verification; prefer `npm run verify:pr-local` for non-trivial ready work. +4. Separate intended changes from unrelated dirty work and list generated artifacts. +5. Summarize files, checks, failures, skipped gates, risks, and the exact next action. +6. Do not commit, push, open a PR, merge, or call providers unless explicitly requested and authorized. diff --git a/.agents/skills/handover/agents/openai.yaml b/.agents/skills/handover/agents/openai.yaml new file mode 100644 index 000000000..c95392005 --- /dev/null +++ b/.agents/skills/handover/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Handover" + short_description: "Prepare a safe evidence-backed Database handover" + default_prompt: "Use $handover to prepare this Database work for handover with local proof and no automatic publishing." diff --git a/.agents/skills/health/SKILL.md b/.agents/skills/health/SKILL.md new file mode 100644 index 000000000..026c8183e --- /dev/null +++ b/.agents/skills/health/SKILL.md @@ -0,0 +1,13 @@ +--- +name: health +description: Assess local Database repository health quickly with offline checks and clear separation of code failures from environment or tooling blockers. Use for routine health checks, stale-state diagnosis, or a fast confidence snapshot. +--- + +# Health + +1. Inspect branch, status, worktrees, runtime versions, relevant logs, and active repo-owned processes. +2. Run `npm run check:runtime`, `npm run check:skills`, and the smallest check related to current changes. +3. Use `npm run verify:cheap` only when a broader offline snapshot is proportionate. +4. Classify failures as product code, test, tooling, resources, environment, or provider-gated. +5. Do not start permanent watchers or run live health endpoints without approval. +6. Return a short healthy/degraded/blocked assessment with exact evidence. diff --git a/.agents/skills/health/agents/openai.yaml b/.agents/skills/health/agents/openai.yaml new file mode 100644 index 000000000..01bbff1fb --- /dev/null +++ b/.agents/skills/health/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Health" + short_description: "Check local Database repository health quickly" + default_prompt: "Use $health to produce a fast offline health assessment for this Database repository." diff --git a/.agents/skills/incident/SKILL.md b/.agents/skills/incident/SKILL.md new file mode 100644 index 000000000..47585d704 --- /dev/null +++ b/.agents/skills/incident/SKILL.md @@ -0,0 +1,13 @@ +--- +name: incident +description: Triage recurring Database canary, CI, runtime, scheduled-task, and provider failures into reproducible causes, impact, containment, and next actions. Use for regressions, outages, flaky jobs, alerts, or repeated nightly or weekly failures. +--- + +# Incident + +1. Build a timeline from local logs, saved artifacts, workflow definitions, and known-good comparisons. +2. Classify the failure as deterministic code, fixture/data drift, schedule, concurrency, environment, quota, provider, or observability. +3. Reproduce offline with the smallest fixture, parser test, or triage artifact before editing. +4. Implement only safe local containment or correction and verify the exact failure path. +5. Do not query GitHub, hosted CI, Supabase, OpenAI, production logs, or providers without approval. +6. Report impact, cause confidence, fix, proof, remaining uncertainty, and gated live confirmation. diff --git a/.agents/skills/incident/agents/openai.yaml b/.agents/skills/incident/agents/openai.yaml new file mode 100644 index 000000000..97d61bd71 --- /dev/null +++ b/.agents/skills/incident/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Incident" + short_description: "Triage recurring Database operational failures" + default_prompt: "Use $incident to reconstruct and reproduce this Database canary, CI, runtime, or scheduled failure offline first." diff --git a/.agents/skills/ingest/SKILL.md b/.agents/skills/ingest/SKILL.md new file mode 100644 index 000000000..c58362879 --- /dev/null +++ b/.agents/skills/ingest/SKILL.md @@ -0,0 +1,13 @@ +--- +name: ingest +description: Triage and verify Database document ingestion, extraction, queueing, retries, workers, batches, and failure recovery. Use for uploads, ingestion jobs, extraction defects, worker reliability, or queue behavior. +--- + +# Ingest + +1. Map the local path from upload and validation through queue, worker, extraction, persistence, and status reporting. +2. Reproduce with fixtures, unit tests, or mocked/local queue state before considering live services. +3. Check idempotency, retry limits, partial failure, cancellation, owner scope, file safety, and observability. +4. Run the smallest ingestion or edge-function test plus production-readiness planning. +5. Treat workers, imports, live buckets, Supabase functions, and production queues as approval-required. +6. Report the failed stage, recovery safety, data-integrity risk, and exact gated next command. diff --git a/.agents/skills/ingest/agents/openai.yaml b/.agents/skills/ingest/agents/openai.yaml new file mode 100644 index 000000000..cb0827299 --- /dev/null +++ b/.agents/skills/ingest/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Ingest" + short_description: "Triage Database ingestion and worker pipelines" + default_prompt: "Use $ingest to trace and verify this Database ingestion, extraction, queue, or worker issue safely." diff --git a/.agents/skills/live-design-sweep/SKILL.md b/.agents/skills/live-design-sweep/SKILL.md index 40dfe469c..d66e90fc4 100644 --- a/.agents/skills/live-design-sweep/SKILL.md +++ b/.agents/skills/live-design-sweep/SKILL.md @@ -1,17 +1,10 @@ --- name: live-design-sweep -description: Inspect and improve the running Database application across routes, responsive breakpoints, keyboard interaction, accessibility modes, and shared UI surfaces. Use for design reviews, UI polish, browser QA, screenshots, mobile checks, accessibility work, or requests to find and fix visual or interaction defects across the app. +description: Compatibility alias for the ui skill. Use only when the user explicitly invokes the older live-design-sweep name; otherwise use ui for live route and accessibility verification. --- -# Live Design Sweep +# Live Design Sweep Alias -1. Run `npm run workflow:design-sweep` to print the proof contract. -2. Run `npm run ensure` before opening the app. Use only the identity-verified URL it prints. -3. Derive route coverage from `docs/site-map.md` and changed navigation. Inspect representative content-rich, empty, loading, error, dialog, and long-scroll states. -4. Cover 320, 390, 639, 768, 1440, and 1920 px where applicable. Check horizontal overflow, single-scrollport ownership, sticky/fixed collisions, tap targets, wrapping, and safe-area behavior. -5. Check keyboard order, visible focus, dialog focus trapping and restoration, accessible names, contrast, reduced motion, forced colors, zoom, and screen-reader semantics. -6. Record reproducible defects with route, viewport, trigger, expected behavior, and screenshot or test evidence. Fix shared primitives before duplicating route-specific patches. -7. Add the smallest regression proof, then run focused Playwright, accessibility, `verify:ui`, and `verify:cheap` in that order as warranted. -8. Write durable route/breakpoint coverage to the task report or `docs/archive/design-qa.md` when the pass is broad. - -Do not claim external design fidelity unless a target design or screenshot was supplied. +1. Read and follow `.agents/skills/ui/SKILL.md`. +2. Use `ui` in recommendations and new documentation. +3. Do not maintain a second copy of the design verification procedure here. diff --git a/.agents/skills/live-design-sweep/agents/openai.yaml b/.agents/skills/live-design-sweep/agents/openai.yaml index 6202e84bf..6e37b3575 100644 --- a/.agents/skills/live-design-sweep/agents/openai.yaml +++ b/.agents/skills/live-design-sweep/agents/openai.yaml @@ -1,4 +1,6 @@ interface: - display_name: "Live Design Sweep" - short_description: "Inspect and polish every live route" - default_prompt: "Use $live-design-sweep to inspect the running Database app across routes and breakpoints, fix confirmed UI issues, and verify accessibility." + display_name: "UI Alias" + short_description: "Compatibility alias for Database UI verification" + default_prompt: "Use $live-design-sweep only as a compatibility alias for $ui." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/migrate/SKILL.md b/.agents/skills/migrate/SKILL.md new file mode 100644 index 000000000..1ccca258e --- /dev/null +++ b/.agents/skills/migrate/SKILL.md @@ -0,0 +1,13 @@ +--- +name: migrate +description: Review and prepare Database schema migrations for safety, reversibility, privileges, RLS, locking, data preservation, and schema consistency. Use for new or changed Supabase migrations, functions, grants, policies, or schema changes. +--- + +# Migrate + +1. Inspect migration order, current schema snapshots, generated types, dependent queries, and rollback expectations. +2. Review destructive operations, locks, defaults, nullability, backfills, function security, grants, and RLS. +3. Prefer additive, idempotent, staged changes with explicit validation and rollback. +4. Run static migration, grant, owner-scope, type, and focused unit checks offline. +5. Do not apply, reset, repair, link, or compare against live Supabase without explicit approval. +6. Report forward plan, rollback, data risk, compatibility window, and gated live proof. diff --git a/.agents/skills/migrate/agents/openai.yaml b/.agents/skills/migrate/agents/openai.yaml new file mode 100644 index 000000000..92dd29ec3 --- /dev/null +++ b/.agents/skills/migrate/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate" + short_description: "Review safe Database schema migrations" + default_prompt: "Use $migrate to review this Database migration for data safety, privileges, RLS, compatibility, and rollback." diff --git a/.agents/skills/operations/SKILL.md b/.agents/skills/operations/SKILL.md new file mode 100644 index 000000000..daa50e87c --- /dev/null +++ b/.agents/skills/operations/SKILL.md @@ -0,0 +1,13 @@ +--- +name: operations +description: Turn pending Database operator, provisioning, configuration, and provider work into a deduplicated dependency-ordered batch with explicit approvals and evidence. Use for operator debt, manual runbooks, or deferred external actions. +--- + +# Operations + +1. Run `npm run workflow:operator-closeout -- --write-evidence`. +2. Inventory pending actions from docs, evidence, logs, and current change without executing them. +3. Deduplicate by outcome and order prerequisites, local proof, approval, execution, verification, and rollback. +4. Separate local/offline actions from GitHub, Supabase, OpenAI, hosting, credentials, and production work. +5. Ask for approval only when the batch is precise enough to execute safely. +6. Record owner, command, expected result, evidence, rollback, and unresolved dependency for each item. diff --git a/.agents/skills/operations/agents/openai.yaml b/.agents/skills/operations/agents/openai.yaml new file mode 100644 index 000000000..0c3a05b3d --- /dev/null +++ b/.agents/skills/operations/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Operations" + short_description: "Batch Database operator work safely" + default_prompt: "Use $operations to deduplicate and order pending Database operator work with explicit approval gates and rollback." diff --git a/.agents/skills/operator-closeout/SKILL.md b/.agents/skills/operator-closeout/SKILL.md index 30f81466c..622847994 100644 --- a/.agents/skills/operator-closeout/SKILL.md +++ b/.agents/skills/operator-closeout/SKILL.md @@ -1,23 +1,10 @@ --- name: operator-closeout -description: Convert pending Database operator, deployment, migration, secret, live-service, and governance debt into a current, deduplicated, dependency-ordered, approval-gated execution batch with pre-state, post-state, rollback, and evidence requirements. Use when closing operator backlog items, preparing launch actions, or reconciling confirmation-required follow-ups. +description: Compatibility alias for the operations skill. Use only when the user explicitly invokes the older operator-closeout name; otherwise use operations for approval-gated operator work. --- -# Operator Closeout +# Operator Closeout Alias -1. Inventory candidates with: - `npm run workflow:operator-closeout -- --write-evidence` -2. Treat the output as discovery, not truth. Verify each candidate against its linked runbook and current local state; remove completed, superseded, duplicate, or stale items. -3. Group remaining actions by provider and blast radius. Order prerequisites, read-only probes, reversible writes, irreversible writes, and post-verification. -4. For every action record: - - exact target and environment; - - external effect and credentials required; - - pre-state query or evidence; - - exact command or dashboard action; - - success condition and post-state proof; - - rollback or recovery path; - - expected cost or downtime. -5. Ask for explicit approval for the concrete batch. Approval for one provider or command does not authorize another. -6. Execute approved actions one at a time, stop on unexpected state, and update durable operator documentation only with observed evidence. - -The planner is intentionally plan-only. Never add automatic provider execution to it. +1. Read and follow `.agents/skills/operations/SKILL.md`. +2. Use `operations` in recommendations and new documentation. +3. Do not maintain a second copy of the operator procedure here. diff --git a/.agents/skills/operator-closeout/agents/openai.yaml b/.agents/skills/operator-closeout/agents/openai.yaml index 90a997884..99b443529 100644 --- a/.agents/skills/operator-closeout/agents/openai.yaml +++ b/.agents/skills/operator-closeout/agents/openai.yaml @@ -1,4 +1,6 @@ interface: - display_name: "Operator Closeout" - short_description: "Turn operator debt into safe batches" - default_prompt: "Use $operator-closeout to inventory pending operator actions, deduplicate them, and prepare a safe approval-gated execution batch." + display_name: "Operations Alias" + short_description: "Compatibility alias for Database operator work" + default_prompt: "Use $operator-closeout only as a compatibility alias for $operations." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/performance/SKILL.md b/.agents/skills/performance/SKILL.md new file mode 100644 index 000000000..9bb348025 --- /dev/null +++ b/.agents/skills/performance/SKILL.md @@ -0,0 +1,13 @@ +--- +name: performance +description: Profile Database query, retrieval, rendering, bundle, worker, queue, memory, and capacity bottlenecks with reproducible local evidence. Use for latency, throughput, resource, scaling, or bundle-budget work. +--- + +# Performance + +1. Define the user-visible metric, baseline, workload, environment, and target before optimizing. +2. Use deterministic local fixtures and existing profiling or budget tools first. +3. Isolate CPU, memory, I/O, query, network, bundle, rendering, concurrency, and cache effects. +4. Make one scoped change at a time and compare before/after distributions, not a single run. +5. Keep live query profiling, provider latency, production traffic, and external load tests approval-gated. +6. Report methodology, variance, improvement, tradeoffs, and remaining capacity risk. diff --git a/.agents/skills/performance/agents/openai.yaml b/.agents/skills/performance/agents/openai.yaml new file mode 100644 index 000000000..0d9238963 --- /dev/null +++ b/.agents/skills/performance/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Performance" + short_description: "Profile Database performance with local evidence" + default_prompt: "Use $performance to baseline, isolate, and verify this Database performance issue with reproducible local evidence." diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md new file mode 100644 index 000000000..7d369c713 --- /dev/null +++ b/.agents/skills/plan/SKILL.md @@ -0,0 +1,13 @@ +--- +name: plan +description: Plan safe risk-scoped Database work by inspecting the current change, selecting the smallest local verification ladder, and separating provider-backed checks into explicit approval gates. Use before non-trivial changes or when asked what checks are needed. +--- + +# Plan + +1. Complete the task-start preflight and preserve unrelated work. +2. Run `npm run workflow:flightplan -- --write-evidence`; add `--files pathA,pathB` for proposed paths. +3. Confirm the detected risk classes match behavior, not only filenames. +4. Start with the narrowest local check and widen only when warranted. +5. Never execute anything under `approvalRequired` without explicit confirmation. +6. Report the planned checks, approval gates, evidence path, and residual risk. diff --git a/.agents/skills/plan/agents/openai.yaml b/.agents/skills/plan/agents/openai.yaml new file mode 100644 index 000000000..2a4920a92 --- /dev/null +++ b/.agents/skills/plan/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Plan" + short_description: "Plan risk-scoped offline Database verification" + default_prompt: "Use $plan to classify this Database change and choose the smallest safe offline verification ladder." diff --git a/.agents/skills/privacy/SKILL.md b/.agents/skills/privacy/SKILL.md new file mode 100644 index 000000000..a2deaefc1 --- /dev/null +++ b/.agents/skills/privacy/SKILL.md @@ -0,0 +1,13 @@ +--- +name: privacy +description: Review Database sensitive-data collection, ownership, retention, logging, caching, export, deletion, telemetry, and client exposure. Use for privacy changes, clinical queries, user data, logs, analytics, or data-subject operations. +--- + +# Privacy + +1. Inventory sensitive inputs, derived data, identifiers, storage locations, logs, caches, exports, and recipients. +2. Trace collection purpose, minimization, owner scope, retention, deletion, and failure paths. +3. Check client bundles, errors, telemetry, query logs, temporary files, and generated artifacts for exposure. +4. Add focused local proofs for redaction, denial, deletion, or non-persistence behavior. +5. Do not inspect live records, production logs, Supabase, or providers without explicit approval. +6. Report data flows, controls, gaps, retention assumptions, and remaining governance decisions. diff --git a/.agents/skills/privacy/agents/openai.yaml b/.agents/skills/privacy/agents/openai.yaml new file mode 100644 index 000000000..d72f0f498 --- /dev/null +++ b/.agents/skills/privacy/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Privacy" + short_description: "Review Database sensitive-data handling" + default_prompt: "Use $privacy to trace sensitive Database data through collection, storage, logs, export, retention, and deletion." diff --git a/.agents/skills/rag-change-lab/SKILL.md b/.agents/skills/rag-change-lab/SKILL.md index c25003a1e..8a6aab6f8 100644 --- a/.agents/skills/rag-change-lab/SKILL.md +++ b/.agents/skills/rag-change-lab/SKILL.md @@ -1,16 +1,10 @@ --- name: rag-change-lab -description: Validate Database retrieval, ranking, chunking, query classification, source selection, citations, answer synthesis, grounding, and RAG privacy changes with focused tests and provider-free golden evaluation before requesting live evaluation approval. Use for RAG implementation, refactoring, debugging, regression analysis, or merge-readiness work. +description: Compatibility alias for the rag skill. Use only when the user explicitly invokes the older rag-change-lab name; otherwise use rag for offline-first retrieval and answer validation. --- -# RAG Change Lab +# RAG Change Lab Alias -1. Generate the lab plan: - `npm run workflow:rag-lab -- --write-evidence` -2. Read `docs/retrieval-quality-runbook.md` and only the change-relevant RAG architecture or threat-model sections. -3. Define the invariant before editing: retrieval recall/ranking, owner scope, grounding, citation correctness, unsupported-query behavior, latency, cache invalidation, or prompt-injection resistance. -4. Run the closest unit/property tests first, followed by `npm run eval:rag:offline`. Inspect cases and metrics rather than relying only on exit status. -5. Make minimal changes and compare against the accepted baseline. Treat recall, MRR, grounded support, citation failure, numeric grounding, source governance, and strategy mix as separate signals. -6. Run `verify:cheap` after focused proof. Use `verify:pr-local` for handoff. -7. Stop before `eval:retrieval:quality`, `eval:rag`, `eval:quality`, or any live corpus/provider operation. Present the exact command, expected cost/effect, required environment, baseline, and success threshold for explicit approval. -8. Report whether remaining uncertainty is code, corpus, owner configuration, live-provider variance, or environment. +1. Read and follow `.agents/skills/rag/SKILL.md`. +2. Use `rag` in recommendations and new documentation. +3. Do not maintain a second copy of the RAG validation procedure here. diff --git a/.agents/skills/rag-change-lab/agents/openai.yaml b/.agents/skills/rag-change-lab/agents/openai.yaml index dd40ad7a9..30261416f 100644 --- a/.agents/skills/rag-change-lab/agents/openai.yaml +++ b/.agents/skills/rag-change-lab/agents/openai.yaml @@ -1,4 +1,6 @@ interface: - display_name: "RAG Change Lab" - short_description: "Validate retrieval and answer changes" - default_prompt: "Use $rag-change-lab to select and run the safe offline checks for this RAG change and prepare any live evaluation approval request." + display_name: "RAG Alias" + short_description: "Compatibility alias for Database RAG validation" + default_prompt: "Use $rag-change-lab only as a compatibility alias for $rag." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/rag/SKILL.md b/.agents/skills/rag/SKILL.md new file mode 100644 index 000000000..e12a54df2 --- /dev/null +++ b/.agents/skills/rag/SKILL.md @@ -0,0 +1,13 @@ +--- +name: rag +description: Validate Database retrieval, classification, ranking, grounding, citations, and answer behavior offline before preparing any live evaluation approval gate. Use for RAG changes, golden regressions, search quality, or answer-quality work. +--- + +# RAG + +1. Run `npm run workflow:rag-lab -- --write-evidence` for the affected paths. +2. Compare fixtures, manifests, tests, and prior local artifacts before changing behavior. +3. Reproduce regressions with the smallest deterministic offline case. +4. Run focused retrieval tests, `npm run check:rag:fixtures`, and `npm run eval:rag:offline` as selected. +5. Preserve source governance, owner scope, conservative fallback, and rollback behavior. +6. Ask before live retrieval, answer, Supabase, OpenAI, or provider-backed evaluation. diff --git a/.agents/skills/rag/agents/openai.yaml b/.agents/skills/rag/agents/openai.yaml new file mode 100644 index 000000000..e8340c5f4 --- /dev/null +++ b/.agents/skills/rag/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "RAG" + short_description: "Validate Database RAG behavior offline first" + default_prompt: "Use $rag to reproduce and verify this retrieval or answer change offline before any live evaluation." diff --git a/.agents/skills/recovery/SKILL.md b/.agents/skills/recovery/SKILL.md new file mode 100644 index 000000000..5dcc1a2af --- /dev/null +++ b/.agents/skills/recovery/SKILL.md @@ -0,0 +1,13 @@ +--- +name: recovery +description: Plan Database restore, rollback, queue recovery, reconciliation, backup validation, and disaster-recovery proof without modifying live systems automatically. Use for failed jobs, corrupted state, rollback planning, or continuity exercises. +--- + +# Recovery + +1. Define the affected data, service, owner scope, last known-good point, recovery objective, and acceptable loss window. +2. Inspect local recovery scripts, migrations, queue state models, backups documentation, and reconciliation checks. +3. Prefer dry runs, fixtures, manifests, counts, and idempotent recovery steps. +4. Specify stop conditions, checkpoints, rollback, post-recovery integrity, and audit evidence. +5. Treat live restores, queue mutation, cleanup, Supabase, hosting, and production operations as approval-required. +6. Deliver an ordered recovery runbook and identify every unproven assumption. diff --git a/.agents/skills/recovery/agents/openai.yaml b/.agents/skills/recovery/agents/openai.yaml new file mode 100644 index 000000000..d7b6a3e79 --- /dev/null +++ b/.agents/skills/recovery/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Recovery" + short_description: "Plan safe Database recovery and rollback" + default_prompt: "Use $recovery to build a safe Database restore, rollback, reconciliation, and verification runbook." diff --git a/.agents/skills/reindex/SKILL.md b/.agents/skills/reindex/SKILL.md new file mode 100644 index 000000000..b91c9b49c --- /dev/null +++ b/.agents/skills/reindex/SKILL.md @@ -0,0 +1,13 @@ +--- +name: reindex +description: Plan and verify Database index generations, backfills, promotions, cleanup, health checks, and rollback without touching live data automatically. Use for reindex jobs, generation changes, index migrations, or backfill operations. +--- + +# Reindex + +1. Identify the index generation, affected owners/documents, state transitions, and rollback boundary. +2. Inspect reindex code, migrations, fixtures, health checks, and cleanup behavior offline. +3. Check idempotency, resumability, concurrency, partial promotion, stale generations, and owner isolation. +4. Add or run the smallest deterministic generation-state proof. +5. Treat reindex, backfill, cleanup, promotion, health, and Supabase commands as approval-required unless proven purely local. +6. Produce an ordered runbook with preconditions, checkpoints, rollback, reconciliation, and evidence. diff --git a/.agents/skills/reindex/agents/openai.yaml b/.agents/skills/reindex/agents/openai.yaml new file mode 100644 index 000000000..5576fb00f --- /dev/null +++ b/.agents/skills/reindex/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Reindex" + short_description: "Plan safe Database reindex and backfill work" + default_prompt: "Use $reindex to plan and verify this index generation, backfill, promotion, or cleanup workflow safely." diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 000000000..896cc1716 --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,13 @@ +--- +name: release +description: Assemble Database release-readiness evidence, risk-scoped local checks, governance proof, rollback notes, and approval-gated final verification. Use for release, merge readiness, production handoff, or go-live confidence. +--- + +# Release + +1. Inspect intended diff, branch state, review ledger, release notes, migrations, configuration, and rollback. +2. Run the flight plan and complete the smallest focused checks before `npm run verify:pr-local`. +3. Include UI, clinical, RAG, migration, privacy, or deployment proof only when selected by risk. +4. Verify required governance and handoff documentation is complete. +5. Do not run `verify:release`, hosted CI, GitHub, Supabase, OpenAI, or deployment commands without approval. +6. Return a ready/not-ready decision with passed, failed, skipped, and gated checks. diff --git a/.agents/skills/release/agents/openai.yaml b/.agents/skills/release/agents/openai.yaml new file mode 100644 index 000000000..09dbc2388 --- /dev/null +++ b/.agents/skills/release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release" + short_description: "Assemble Database release-readiness evidence" + default_prompt: "Use $release to assess Database release readiness with local proof, rollback notes, and explicit approval gates." diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md new file mode 100644 index 000000000..4ce0b4c30 --- /dev/null +++ b/.agents/skills/review/SKILL.md @@ -0,0 +1,13 @@ +--- +name: review +description: Review the current Database diff, branch, or explicitly approved PR target for high-confidence correctness, security, privacy, clinical, and reliability defects. Use for code review, diff review, or readiness feedback. +--- + +# Review + +1. Read `docs/codex-review-protocol.md` and `docs/branch-review-ledger.md` when present. +2. Resolve the local target SHA and check whether the same scope was already reviewed. +3. Inspect changed behavior and realistic failure paths; prioritize reproducible P0-P2 findings. +4. Cite exact files and lines, trigger, impact, and the smallest proof or fix. +5. Do not call GitHub or hosted CI without explicit approval. +6. Record the completed local review in the ledger when repository instructions require it. diff --git a/.agents/skills/review/agents/openai.yaml b/.agents/skills/review/agents/openai.yaml new file mode 100644 index 000000000..5122fa971 --- /dev/null +++ b/.agents/skills/review/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Review" + short_description: "Review Database changes for concrete high-risk defects" + default_prompt: "Use $review to inspect this Database change and report high-confidence findings with exact evidence." diff --git a/.agents/skills/run/SKILL.md b/.agents/skills/run/SKILL.md new file mode 100644 index 000000000..11a854b49 --- /dev/null +++ b/.agents/skills/run/SKILL.md @@ -0,0 +1,12 @@ +--- +name: run +description: Start or verify the correct local Database application with repository identity checks and return its actual URL. Use when the user says run, asks for the local app, or needs browser work against this project. +--- + +# Run + +1. Run the task-start preflight if needed and inspect active repo-owned server state. +2. Execute `npm run ensure`; do not assume ports 3000, 3001, or 3002. +3. Trust only a URL whose `/api/local-project-id` identity check matches this project. +4. Do not stop or modify another project's server. +5. Return the verified URL briefly and report any environment blocker. diff --git a/.agents/skills/run/agents/openai.yaml b/.agents/skills/run/agents/openai.yaml new file mode 100644 index 000000000..26bf3ea60 --- /dev/null +++ b/.agents/skills/run/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Run" + short_description: "Start the identity-checked local Database app" + default_prompt: "Use $run to start or verify the local Database app and return its confirmed URL." diff --git a/.agents/skills/security/SKILL.md b/.agents/skills/security/SKILL.md new file mode 100644 index 000000000..7cb2d2328 --- /dev/null +++ b/.agents/skills/security/SKILL.md @@ -0,0 +1,13 @@ +--- +name: security +description: Find and validate concrete Database security risks across secrets, input handling, authentication, authorization, storage, dependencies, and trust boundaries. Use for security review, threat analysis, or confirmed finding remediation. +--- + +# Security + +1. Define the scoped assets, actors, trust boundaries, entry points, and attacker-controlled inputs. +2. Search for realistic exploit paths involving auth, injection, SSRF, file handling, secrets, storage, RLS, and dependencies. +3. Prove each finding with changed code, a safe local test, or deterministic static evidence. +4. Rank by impact and likelihood; avoid speculative style findings. +5. Apply only minimal, testable fixes when requested and keep live/provider validation approval-gated. +6. Report fixed, open, false-positive, and residual risks separately. diff --git a/.agents/skills/security/agents/openai.yaml b/.agents/skills/security/agents/openai.yaml new file mode 100644 index 000000000..8892d4c4b --- /dev/null +++ b/.agents/skills/security/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Security" + short_description: "Find concrete Database security risks" + default_prompt: "Use $security to inspect this Database surface for reproducible security risks and minimal safe fixes." diff --git a/.agents/skills/session-lifecycle/SKILL.md b/.agents/skills/session-lifecycle/SKILL.md index 8d89067ab..efbe19ee6 100644 --- a/.agents/skills/session-lifecycle/SKILL.md +++ b/.agents/skills/session-lifecycle/SKILL.md @@ -1,17 +1,10 @@ --- name: session-lifecycle -description: Manage Database repository work safely from task start through verification, handoff, merge proof, and conservative worktree or branch cleanup. Use when starting a task, checking session state, preparing completed work for handoff, confirming a squash merge landed, or cleaning up only proven-redundant task state. +description: Compatibility alias for the task skill. Use only when the user explicitly invokes the older session-lifecycle name; otherwise use task for safe lifecycle transitions. --- -# Session Lifecycle +# Session Lifecycle Alias -1. Select the phase and generate its plan: - `npm run workflow:lifecycle -- --phase ` -2. At task start, run the mandatory task-start preflight command configured by the active host or root `AGENTS.md`, then read applicable repo instructions and preserve all existing work. On this Windows Codex installation, resolve the installation-managed script from the user profile (for example, `& (Join-Path $env:USERPROFILE '.codex\scripts\start-codex-task.ps1') -TaskSlug `); it is intentionally not repo-local. Do not use the shared stash across worktrees. -3. During work, recheck branch and status before edits and before handoff. Use `database-flightplan` for non-trivial change verification. -4. At handoff, stage explicit coherent paths only, verify locally, and inspect the staged diff. Commit, push, PR creation, hosted CI, and merge remain authorization-bound. -5. After a squash merge, verify the reviewed content against fetched `origin/main`; do not rely on ancestry alone. Check for late orphaned commits before cleanup. -6. Remove a worktree or branch only after exact path/ref resolution, clean-state proof, and content-equivalence evidence. Preserve dirty, active, ambiguous, open-PR, or patch-unique work. -7. Record the outcome in `docs/branch-review-ledger.md` when the repo review protocol requires it. - -The older `.claude/skills/newtask`, `handoff`, and `prlanded` remain compatibility surfaces. Use this skill as the agent-neutral orchestration contract. +1. Read and follow `.agents/skills/task/SKILL.md`. +2. Use `task` in recommendations and new documentation. +3. Do not maintain a second copy of the lifecycle procedure here. diff --git a/.agents/skills/session-lifecycle/agents/openai.yaml b/.agents/skills/session-lifecycle/agents/openai.yaml index ff66a9a4f..e717eaccb 100644 --- a/.agents/skills/session-lifecycle/agents/openai.yaml +++ b/.agents/skills/session-lifecycle/agents/openai.yaml @@ -1,4 +1,6 @@ interface: - display_name: "Session Lifecycle" - short_description: "Manage safe task start through cleanup" - default_prompt: "Use $session-lifecycle to inspect and advance this Database task through start, verification, handoff, merge proof, or safe cleanup." + display_name: "Task Alias" + short_description: "Compatibility alias for Database task lifecycle" + default_prompt: "Use $session-lifecycle only as a compatibility alias for $task." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/skills/SKILL.md b/.agents/skills/skills/SKILL.md new file mode 100644 index 000000000..3378b50d5 --- /dev/null +++ b/.agents/skills/skills/SKILL.md @@ -0,0 +1,13 @@ +--- +name: skills +description: List every unique Database-specific skill with a clear explanation, hide compatibility aliases, and recommend the smallest useful set for the current request. Use when the user asks what skills exist, what they do, or which workflow to choose. +--- + +# Skills + +1. Run `npm run skills` so the validated catalog, not chat memory, is authoritative. +2. Present every canonical skill exactly once under its catalog category with its plain-language purpose. +3. Do not count compatibility aliases as unique skills. Mention an alias only when it helps the user migrate an older command. +4. Recommend the smallest high-yield set for the current request and explain the order in one sentence. +5. Run `npm run check:skills` when catalog integrity is part of the task. +6. Never execute another skill merely because it was listed; follow the normal scope and approval rules. diff --git a/.agents/skills/skills/agents/openai.yaml b/.agents/skills/skills/agents/openai.yaml new file mode 100644 index 000000000..07e6b6df1 --- /dev/null +++ b/.agents/skills/skills/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Skills" + short_description: "List and recommend every unique Database skill" + default_prompt: "Use $skills to list all unique Database skills, explain each clearly, and recommend the smallest useful set for my request." diff --git a/.agents/skills/sources/SKILL.md b/.agents/skills/sources/SKILL.md new file mode 100644 index 000000000..4d4294bc1 --- /dev/null +++ b/.agents/skills/sources/SKILL.md @@ -0,0 +1,13 @@ +--- +name: sources +description: Audit Database source metadata, citations, approvals, labels, provenance, rendering, and release-governance coverage. Use for citation defects, source-governance changes, document labels, public promotion, or clinical evidence traceability. +--- + +# Sources + +1. Trace source identity from ingestion metadata through retrieval, answer citations, and UI rendering. +2. Check provenance, approval state, labels, dates, ownership, public visibility, and missing-metadata behavior. +3. Use focused local tests and fixture checks before broader governance scripts. +4. Treat scripts that query live Supabase or production-like data as approval-required. +5. Verify conservative behavior when a source is absent, stale, private, rejected, or ambiguous. +6. Report coverage, debt, release impact, rollback, and any live audit that still needs approval. diff --git a/.agents/skills/sources/agents/openai.yaml b/.agents/skills/sources/agents/openai.yaml new file mode 100644 index 000000000..bd10ec390 --- /dev/null +++ b/.agents/skills/sources/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sources" + short_description: "Audit Database citations and source governance" + default_prompt: "Use $sources to audit source metadata, citations, approvals, provenance, and release-governance coverage." diff --git a/.agents/skills/task/SKILL.md b/.agents/skills/task/SKILL.md new file mode 100644 index 000000000..48f63bbd7 --- /dev/null +++ b/.agents/skills/task/SKILL.md @@ -0,0 +1,13 @@ +--- +name: task +description: Manage Database task lifecycle transitions safely, including start, status, handoff, landed proof, and cleanup planning. Use when beginning work, checking task state, preparing handoff, proving integration, or closing a session. +--- + +# Task + +1. Use the required task-start script before repository changes. +2. Run `npm run workflow:lifecycle -- --phase --write-evidence`. +3. Inspect branch, upstream, worktrees, status, and operation markers for the selected phase. +4. Preserve dirty, ambiguous, active, or unmerged work. +5. Keep fetch, push, PR, merge, and provider checks approval-gated. +6. Report the exact state transition proved and any action still requiring confirmation. diff --git a/.agents/skills/task/agents/openai.yaml b/.agents/skills/task/agents/openai.yaml new file mode 100644 index 000000000..87cabe46d --- /dev/null +++ b/.agents/skills/task/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Task" + short_description: "Manage safe Database task lifecycle transitions" + default_prompt: "Use $task to inspect and manage the requested Database task lifecycle phase safely." diff --git a/.agents/skills/test/SKILL.md b/.agents/skills/test/SKILL.md new file mode 100644 index 000000000..662f763fe --- /dev/null +++ b/.agents/skills/test/SKILL.md @@ -0,0 +1,13 @@ +--- +name: test +description: Choose and run the smallest relevant local Database test, reproduce failures deterministically, and widen verification only when needed. Use for test requests, behavioral proofs, or focused regression coverage. +--- + +# Test + +1. Inspect the touched behavior and existing nearby tests before selecting a command. +2. Prefer a single Vitest or Playwright target; use `npm run test:focused -- --files ` only for safe source-only changes. +3. Add the smallest deterministic regression proof when behavior is unprotected. +4. Run one heavy command at a time and rerun the smallest failing check after each fix. +5. Keep live/provider-backed tests approval-gated. +6. Report exact commands, exit codes, coverage limits, and tests not run. diff --git a/.agents/skills/test/agents/openai.yaml b/.agents/skills/test/agents/openai.yaml new file mode 100644 index 000000000..bd52a5f10 --- /dev/null +++ b/.agents/skills/test/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Test" + short_description: "Run the smallest useful local Database test" + default_prompt: "Use $test to select and run the smallest deterministic local proof for this Database behavior." diff --git a/.agents/skills/ui/SKILL.md b/.agents/skills/ui/SKILL.md new file mode 100644 index 000000000..6ba765344 --- /dev/null +++ b/.agents/skills/ui/SKILL.md @@ -0,0 +1,13 @@ +--- +name: ui +description: Inspect and verify the live Database interface across routes, breakpoints, interactions, accessibility modes, reduced motion, and forced colors. Use for UI, frontend, routing, styling, responsive, browser, or accessibility work. +--- + +# UI + +1. Read the relevant Next.js guide under `node_modules/next/dist/docs/` before code changes. +2. Run `npm run workflow:design-sweep -- --write-evidence` and then `npm run ensure`. +3. Verify project identity before browser work; do not assume a port. +4. Inspect affected routes at phone and desktop widths plus keyboard, focus, reduced-motion, and forced-colors states. +5. Add the smallest focused browser proof, then use `npm run verify:ui` when proportionate. +6. Report routes, viewports, interactions, accessibility evidence, and residual visual risk. diff --git a/.agents/skills/ui/agents/openai.yaml b/.agents/skills/ui/agents/openai.yaml new file mode 100644 index 000000000..06567acee --- /dev/null +++ b/.agents/skills/ui/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "UI" + short_description: "Sweep live Database UI and accessibility states" + default_prompt: "Use $ui to inspect and verify this Database interface across routes, breakpoints, interactions, and accessibility modes." diff --git a/.agents/skills/verify-triage-fix/SKILL.md b/.agents/skills/verify-triage-fix/SKILL.md index 670f26004..fb0509547 100644 --- a/.agents/skills/verify-triage-fix/SKILL.md +++ b/.agents/skills/verify-triage-fix/SKILL.md @@ -1,21 +1,10 @@ --- name: verify-triage-fix -description: Diagnose and fix local Database repository verification failures using the smallest reproducible target, failure classification, known-flake evidence, and narrow reruns before broader gates. Use when lint, typecheck, Vitest, Playwright, build, offline RAG, workflow tooling, or another local check fails or appears hung. +description: Compatibility alias for the fix skill. Use only when the user explicitly invokes the older verify-triage-fix name; otherwise use fix for local verification failure triage. --- -# Verify Triage Fix +# Verify Triage Fix Alias -1. Capture the exact failed command, exit code, complete actionable error, and relevant artifact path. Do not combine more checks until the failure is understood. -2. Classify a saved log with: - `npm run workflow:triage -- --log ` - Without `--log`, the planner uses `.local/workflow-last-failure.json` when present. -3. Verify the classification: - - regression: reproduce the smallest test, typecheck target, or build surface; - - environment: inspect runtime, paths, dependencies, process ownership, and timeouts; - - provider/configuration: stop and request approval or missing configuration; - - known flake: prove the signature matches `tests/flake-ledger.json` before treating it as such. -4. Change only the smallest confirmed cause. Do not hide failures by weakening assertions, increasing timeouts blindly, or broad refactoring. -5. Rerun the smallest failing target after each fix. Widen to `verify:cheap`, `verify:ui`, or `verify:pr-local` only after it passes. -6. Distinguish code defects, pre-existing failures, environment blockers, and provider gates in the final report. - -Never use provider access to diagnose a local failure without explicit confirmation. +1. Read and follow `.agents/skills/fix/SKILL.md`. +2. Use `fix` in recommendations and new documentation. +3. Do not maintain a second copy of the triage procedure here. diff --git a/.agents/skills/verify-triage-fix/agents/openai.yaml b/.agents/skills/verify-triage-fix/agents/openai.yaml index 20ea968cd..111717db8 100644 --- a/.agents/skills/verify-triage-fix/agents/openai.yaml +++ b/.agents/skills/verify-triage-fix/agents/openai.yaml @@ -1,4 +1,6 @@ interface: - display_name: "Verify Triage Fix" - short_description: "Diagnose and fix verification failures" - default_prompt: "Use $verify-triage-fix to diagnose the current local verification failure, fix the smallest confirmed cause, and rerun focused checks." + display_name: "Fix Alias" + short_description: "Compatibility alias for Database failure triage" + default_prompt: "Use $verify-triage-fix only as a compatibility alias for $fix." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/workflows/SKILL.md b/.agents/skills/workflows/SKILL.md index 3cbe11c14..c059742b0 100644 --- a/.agents/skills/workflows/SKILL.md +++ b/.agents/skills/workflows/SKILL.md @@ -1,30 +1,10 @@ --- name: workflows -description: List all Database workflow skills, summarize their purpose, and recommend the right workflow for the current change. +description: Compatibility alias for the skills skill. Use only when the user explicitly invokes the older workflows name; otherwise use skills for the complete unique Database skill catalog. --- -# Database Workflows +# Workflows Alias -## What this skill covers - -Use this skill when you need a quick index of Database-specific workflow skills and a reminder of what each one validates. - -## Available workflow skills - -- `database-flightplan`: Risk classification and local/offline verification planning for non-trivial changes. -- `verify-triage-fix`: Stepwise local failure triage for lint/typecheck/tests/build/playwright/offline checks. -- `clinical-change-proof`: Clinical, answer-governance, safety, privacy, and source-governance evidence planning. -- `live-design-sweep`: App-wide live route/design/accessibility verification across breakpoints and interaction states. -- `rag-change-lab`: Offline evaluation and migration-proofing for retrieval, classification, ranking, and grounding changes. -- `operator-closeout`: Conversion of pending deployment/operator/provisioning tasks into dependency-ordered execution batches. -- `session-lifecycle`: Start-to-handoff-to-cleanup orchestration with review/merge safety checks. - -## Recommended usage pattern - -1. Start with `database-flightplan` for non-trivial edits. -2. Use `verify-triage-fix` only when checks fail or hang. -3. Add `clinical-change-proof` for clinical retrieval, output, or source-governance risk. -4. Use `live-design-sweep` for UI/routing/interaction defects. -5. Use `rag-change-lab` for RAG behavior work. -6. Use `operator-closeout` for deferred operator or provider-work batches. -7. Use `session-lifecycle` for session-level handoff and cleanup. +1. Read and follow `.agents/skills/skills/SKILL.md`. +2. Use `skills` in recommendations and new documentation. +3. Do not present `workflows` as an additional unique skill. diff --git a/.agents/skills/workflows/agents/openai.yaml b/.agents/skills/workflows/agents/openai.yaml index cb9580560..ca456238a 100644 --- a/.agents/skills/workflows/agents/openai.yaml +++ b/.agents/skills/workflows/agents/openai.yaml @@ -1,4 +1,6 @@ interface: - display_name: "Database Workflows" - short_description: "Index Database workflow skills and choose the right one" - default_prompt: "Use $workflows to list all Database workflow skills with a concise explanation of what each does and when to run it." + display_name: "Workflows Alias" + short_description: "Compatibility alias for the Database skills catalog" + default_prompt: "Use $workflows only as a compatibility alias for $skills." +policy: + allow_implicit_invocation: false 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee23dd4d2..b23439155 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,7 +348,7 @@ jobs: db_container="$(docker ps --format '{{.Names}}' | grep '^supabase_db_' | head -n 1)" test -n "${db_container}" docker exec -i "${db_container}" \ - psql -U supabase_admin -d postgres -v ON_ERROR_STOP=1 \ + psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ < supabase/roles.sql - name: Verify Migration Replay diff --git a/.github/workflows/eval-canary.yml b/.github/workflows/eval-canary.yml index 7253081dd..9d651dfe9 100644 --- a/.github/workflows/eval-canary.yml +++ b/.github/workflows/eval-canary.yml @@ -1,4 +1,4 @@ -# Nightly production eval canary. See docs/observability-slos.md §3. +# Weekly production eval canary. See docs/observability-slos.md §3. # # Runs the golden retrieval eval (which PR CI can never run — it needs live # Supabase + OpenAI keys) plus a small answer-quality subset against the live @@ -6,7 +6,7 @@ # scheduled failures. # # The schedule only fires from the default branch. After merging, trigger one -# workflow_dispatch run and confirm it is green before trusting the nightly +# workflow_dispatch run and confirm it is green before trusting the weekly # cadence. name: Eval Canary @@ -18,8 +18,8 @@ on: required: false default: "8" schedule: - # 18:00 UTC = 02:00 Australia/Perth — off-peak for clinicians. - - cron: "0 18 * * *" + # Sunday 18:00 UTC = Monday 02:00 Australia/Perth — off-peak for clinicians. + - cron: "0 18 * * 0" concurrency: group: eval-canary @@ -60,11 +60,13 @@ jobs: steps: - name: Checkout + id: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Preflight required secrets + id: preflight run: | missing="" [ -z "$SUPABASE_SERVICE_ROLE_KEY" ] && missing="$missing SUPABASE_SERVICE_ROLE_KEY" @@ -75,6 +77,7 @@ jobs: fi - name: Setup Node.js + id: setup_node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: ".nvmrc" @@ -82,15 +85,22 @@ jobs: cache-dependency-path: package-lock.json - name: Install dependencies + id: install run: npm ci - name: Guard Supabase project identity + id: project_guard run: npm run check:supabase-project - name: Golden retrieval eval (live corpus) - run: npm run eval:retrieval:quality -- --fail-on-threshold + id: golden_retrieval + run: | + mkdir -p .local/eval-canary + set -o pipefail + npm run eval:retrieval:quality -- --fail-on-threshold 2>&1 | tee .local/eval-canary/golden-retrieval.log - name: Answer-quality subset (live generation) + id: answer_quality env: ANSWER_CASE_LIMIT: ${{ github.event.inputs.answer_case_limit || '8' }} # Canary-only latency allowance: cross-region runner -> Sydney Supabase @@ -101,20 +111,48 @@ jobs: echo "answer_case_limit must be an integer from 1 to 100" >&2 exit 2 fi - npm run eval:quality -- --rag-only --limit "$ANSWER_CASE_LIMIT" --fail-on-threshold + mkdir -p .local/eval-canary + set -o pipefail + npm run eval:quality -- --rag-only --limit "$ANSWER_CASE_LIMIT" --fail-on-threshold 2>&1 | tee .local/eval-canary/answer-quality.log - name: Open or update canary failure issue if: failure() && github.event_name == 'schedule' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | + const fs = require("node:fs"); + const path = require("node:path"); + const { pathToFileURL } = require("node:url"); + const { analyzeFailureText } = await import(pathToFileURL(`${process.env.GITHUB_WORKSPACE}/scripts/productivity-core.mjs`).href); const label = "eval-canary"; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const logPaths = [ + ".local/eval-canary/golden-retrieval.log", + ".local/eval-canary/answer-quality.log", + ].map((item) => path.join(process.env.GITHUB_WORKSPACE, item)); + const failureText = logPaths + .filter((item) => fs.existsSync(item)) + .map((item) => fs.readFileSync(item, "utf8")) + .join("\n"); + const diagnosis = analyzeFailureText(failureText); + const stepOutcomes = { + checkout: "${{ steps.checkout.outcome }}", + preflight: "${{ steps.preflight.outcome }}", + setup_node: "${{ steps.setup_node.outcome }}", + install: "${{ steps.install.outcome }}", + project_guard: "${{ steps.project_guard.outcome }}", + golden_retrieval: "${{ steps.golden_retrieval.outcome }}", + answer_quality: "${{ steps.answer_quality.outcome }}", + }; const body = [ - `Nightly eval canary failed on ${new Date().toISOString()}.`, + `Weekly eval canary failed on ${new Date().toISOString()}.`, "", `Run: ${runUrl}`, "", + `Failure class: ${diagnosis.category} (${diagnosis.confidence})`, + `Reason: ${diagnosis.reason}`, + `Step outcomes: ${JSON.stringify(stepOutcomes)}`, + "", "Triage order (docs/observability-slos.md §3): inspect the failed step first.", "Resolve provider quota/auth/config failures before rerunning; for completed evals,", "check hybrid_rpc_errors and `npm run check:indexing`, then investigate corpus or code drift.", @@ -137,7 +175,7 @@ jobs: await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - title: "Eval canary failure: nightly evaluation did not complete", + title: "Eval canary failure: weekly evaluation did not complete", labels: [label], body, }); diff --git a/AGENTS.md b/AGENTS.md index 0e1ca29ca..7f86db724 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -321,15 +321,17 @@ After completing `upload`, summarize the current branch and worktree state, whet ## Repository productivity skills -Automatically apply repo-local skills under `.agents/skills/` when their descriptions match the user's request. They are thin orchestration layers over `scripts/productivity-workflow.mjs`; do not copy their procedures into this file. - -- `database-flightplan`: plan risk-scoped verification before non-trivial changes. -- `verify-triage-fix`: diagnose and repair local verification failures with the smallest reproducer. -- `clinical-change-proof`: assemble clinical, privacy, source, and rollback evidence. -- `live-design-sweep`: inspect the running app across routes, breakpoints, and accessibility modes. -- `rag-change-lab`: validate retrieval and answer changes offline first, then prepare live-eval approval gates. -- `operator-closeout`: turn pending operator debt into a deduplicated, approval-gated batch. -- `session-lifecycle`: manage safe start, handoff, merge proof, and cleanup transitions. +Automatically apply repo-local skills under `.agents/skills/` when their descriptions match the user's request. Run `npm run skills` for the validated catalog of 32 canonical single-word skills and `npm run check:skills` to verify catalog integrity. The older long names remain compatibility aliases and must not be counted as unique skills. + +The foundational orchestration skills are: + +- `plan`: plan risk-scoped verification before non-trivial changes. +- `fix`: diagnose and repair local verification failures with the smallest reproducer. +- `clinical`: assemble clinical, privacy, source, and rollback evidence. +- `ui`: inspect the running app across routes, breakpoints, and accessibility modes. +- `rag`: validate retrieval and answer changes offline first, then prepare live-eval approval gates. +- `operations`: turn pending operator debt into a deduplicated, approval-gated batch. +- `task`: manage safe start, handoff, merge proof, and cleanup transitions. Run the matching planner command in `docs/productivity-workflows.md` without side effects by default. Add `-- --run` only to execute its local/offline checks. The workflow engine must never execute commands listed under `approvalRequired`. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 92274cb19..8896a0d8c 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -620,3 +620,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-18 | PR #871 / codex/design-audit-final-pr-20260718 | bb85b546e + ledger closeout | final current-main design/accessibility audit integration, hosted review resolution, and PR-readiness review | No remaining high-confidence P0-P2 defect after current-main reconciliation, two independent read-only reviews, and remediation of every confirmed review finding. The follow-ups made client demo state fail closed in production for both prototype items and set suggestions, separated local no-auth upload capability from Favourites demo treatment, kept unavailable Favourites controls focusable with accessible reasons, restored visible skip-target focus, limited Favourites selection to the wide layout that exposes its workspace, aligned upload/index semantics with each responsive rendering mode, exposed filtered Tools results as a labelled group, and kept Therapy Compass in an honest loading state until its catalogue resolves. The audit also distinguishes completed browser assertions from an incomplete valid document-source redirect case. External design-target fidelity remains unverified without an approved target. | Canonical local PR verification completed through runtime, changed-file formatting, full ESLint, TypeScript, full Vitest, webpack production build/client-secret scan, and offline RAG fixtures. Focused Therapy, navigation/auth, demo-boundary, and unavailable-control regressions passed after correcting one test-order issue; the final demo/upload boundary selection passed 4/4; scoped zero-warning ESLint, changed-file formatting, and `git diff --check` passed. The full local Chromium sweep completed 236/237 with one hydration-timing failure: the app-mode test clicked before React attached its handler. The existing handler-readiness pattern was applied; focused rerun attempts were then lock-blocked by another registered worktree, so exact-head hosted Chromium remains the required proof. Initial hosted checks, including Production UI, passed before these final follow-ups. No OpenAI, Supabase, production-data, deployment, or live clinical workflow ran. | | 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (PR #896, plan Phase 5; content commit + this ledger follow-up) | a6c2b4e92374e9002fb00c547eb5677d01ce538c | Design-polish sweep: audit-then-fix (plan Phase 5, final phase) | Audit on post-#890 main: three strict design guards clean; full re-run of the 07-token-adoption-audit grep method shows all July 3 debt resolved (M1–M3 done, L4 reduced to the deliberate theme-aware `ring-white/N dark:ring-white/10` glass idiom, L5/L7 gone; production hex all legitimate print/brand/console/comment classes); 43-capture live sweep across 15 routes × desktop/phone + 320px spots + dark/reduced-motion/forced-colors spots found 0 overflow and 0 console errors. Three defects found and fixed: (1) forced-colors solid-button labels rendered as blank Canvas-on-Canvas backplate boxes (axe-invisible) — command controls flattened to the native HCM ButtonFace/ButtonText pairing and accent glyph tokens flipped to ButtonText inside the existing forced-colors block, regression-locked by a new ui-accessibility test; (2) tools desktop 6-up quick-action rail truncated card titles at 1440×1000 — card metrics tightened, all six titles verified unclipped; (3) privacy page rendered "systemand" from a JSX newline-adjacent-to-tag drop — explicit space, locked by a privacy-ui assertion. Dated July 18 run appended to docs/redesign/07-token-adoption-audit.md (archived design-qa.md not resurrected). | Guards + focused vitest 14/14; `verify:cheap` chain green to the known container-only pdf-extraction-budget artifact (2806/2809); `verify:ui` 220 passed/2 failed (the two long-baselined container artifacts, hosted-CI-green through #826/#835/#872/#890); `test:e2e:accessibility` 8/8 incl. the new forced-colors token test; production build + client-bundle secret scan passed; `check:bundle-budget` within tolerance vs the Phase 4 ratchet (1290.6 vs 1278.6 KiB baseline); `verify:pr-local` runtime/format/lint/typecheck/build/rag-fixtures green with the same sole unit-suite artifact. `verify:release` not run (provider-backed; awaits explicit confirmation). No provider-backed checks run. | | 2026-07-19 | all remote feature branches and registered worktrees against `origin/main` through PR #899 | 8242fa63d5f5b79fc770c9ae4f633e3a784b80e1 | branch/worktree cleanup, useful-work recovery, and protected-main merge closure | Deleted 122 stale or closed remote feature refs with exact SHA leases; four additional merged PR branches were removed by the protected-main PR workflow. Removed 32 obsolete, superseded, or merge-proven worktree registrations. Recovered useful dirty RAG work into PR #901 (deterministic and opt-in semantic reranking) and PR #902 (retrieval phase latency telemetry), preserved follow-up decisions in `docs/process-hardening.md`, and recovered four missing historical review rows. PRs #897, #899, #901, and #902 are merged with green exact-head checks and zero unresolved review threads. A detached full-repo-review worktree is deliberately retained because its ownership/activity could not be safely disproved; one unregistered `node_modules` junction residue is also retained because deletion was denied by local safety policy. | Fresh fetch/prune; full GitHub PR/check/thread inventory; `git worktree list --porcelain`; cherry-pick-aware right-only logs; exact leased remote deletes; exact-old-value local ref deletes; clean-worktree, path, and merged-PR proof before every removal. PR #899 local proof: focused Vitest 31/31, changed-file ESLint, `verify:cheap` 317 files / 2,879 tests, and `verify:ui` 239/239; exact-head hosted checks all passed. PR #901 local proof: `verify:cheap` 316 files / 2,870 tests; PR #902 focused Vitest 8/8 plus ESLint and typecheck. No OpenAI, Supabase, live clinical, deployment, or production-data workflow ran; provider-backed semantic canary evaluation remains approval-gated. | +| 2026-07-19 | main / `codex/supabase-database-review` | 4034d2e60ebb6616130ff17bf3cb69368f36f8f6 + reviewed working diff | live `Clinical KB Database` security, migration, schema-drift, integrity, and performance review against current repo | Confirmed and remediated a P1 privacy defect: 601 private-document title-vocabulary rows were reachable by the service-role query corrector; the live public-only sync/backfill now reports zero private or out-of-scope rows. Applied the committed retrieval-count bound, audit-metadata minimization, registry cleanup/index, public-title corrector, and atomic summary-rate-limit migrations. The missing FK and registry indexes are present and no invalid indexes remain. A second P1 was found in the untracked live `ingestion-worker`: gateway JWT verification accepted any project JWT before privileged direct-Postgres job processing. Recovered the deployed source into the repo, restricted it to POST plus a gateway-verified `service_role` claim, expanded the Deno checker to every tracked Edge Function, and deployed exact-matching v13 with JWT verification enabled. Review also exposed a repo mirror/test gap: the count-clamp migration was not reflected in `schema.sql`; the branch now mirrors it and locks both sources in the focused test. Remaining hosted blocker: `postgres` cannot assume managed `supabase_admin`, so the fail-closed default-ACL migrations and final title-word constraint/trigger migration remain unapplied; the intentional service-role-only table still produces one INFO no-policy advisor. | Supabase connector project identity, migration and Edge Function inventory, full drift snapshot comparison, security/performance advisors, catalog integrity/ACL/index queries, Vault JWT-role compatibility check, post-apply invariants, exact deployed-source hashes, and unauthenticated live rejection (401); focused retrieval/schema/drift Vitest 82/83 with only manifest freshness failing; Edge/retrieval auth 9/9; Deno check for both functions; offline RAG 36 cases / 294 tests; function-grant guard; scoped ESLint, Prettier, and `git diff --check`. `check:supabase-project` was attempted but stopped before provider contact because local project env vars are unset. `drift:manifest` was blocked because Docker Desktop could not start and was cleaned up. `verify:cheap`, `verify:pr-local`, production-readiness, OpenAI, hosted CI, broader deployment, and commit/push were not run. | diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index 914ad0f73..78d157bbe 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -232,7 +232,9 @@ check and watch patterns rather than relying on dashboard defaults. Ship the existing worker as a container (`Dockerfile.worker`: Node 24 + a prebuilt esbuild bundle over production-only `node_modules` + Tesseract + a Python venv with `worker/python/requirements.txt`) and run **one -always-on worker instance** co-located in Railway Singapore (`worker` service). +always-on worker instance** co-located in Railway Singapore (`worker` service), +using Railway's `ALWAYS` restart policy so repeated bootstrap failures cannot +exhaust a finite retry allowance and leave the queue undrained. The `indexing-v3-agent` Edge Function **stays** in its current role as the cron-triggered completion/repair gate — the two are complementary, not alternatives. The worker service selects its Dockerfile via the @@ -301,7 +303,7 @@ Operational rules that follow: - **Worker death costs at most one stale window of latency** for the in-flight job and zero data loss: all artifact writes are idempotent per generation/chunk-key, and completion is gated by the strict completion RPCs - plus the edge agent. Railway's restart-on-failure brings the worker back and + plus the edge agent. Railway's always-restart policy brings the worker back and it reclaims stale jobs automatically. - **Backlog improvement (not in this change):** a heartbeat that refreshes `locked_at` could ride the existing throttled progress updates diff --git a/docs/observability-slos.md b/docs/observability-slos.md index 5b9790a8e..166371dd7 100644 --- a/docs/observability-slos.md +++ b/docs/observability-slos.md @@ -1,7 +1,7 @@ # Observability & SLOs Service-level objectives for the Clinical KB answer pipeline, the alert -thresholds attached to them, and the nightly production eval canary that turns +thresholds attached to them, and the weekly production eval canary that turns the golden eval into a standing guard. Written 2026-07-06. Context: this repo's defining failure mode is **silent degradation** — hybrid @@ -105,7 +105,7 @@ group by 1 order by 2 desc; ``` Complementary standing checks: `search_schema_health()` via -`npm run check:indexing` (fails closed on RPC regression) and the nightly eval +`npm run check:indexing` (fails closed on RPC regression) and the weekly eval canary below (fails closed on quality regression). ### Reliability — degraded/source-only answer rate @@ -125,19 +125,20 @@ recent pre-flag provider failures remain visible until they age out. Keep `degra the broader source-only UI state and `fallback_reason` as diagnostic detail; neither is narrow enough for provider health on its own. -## 3. Nightly production eval canary +## 3. Weekly production eval canary -`.github/workflows/eval-canary.yml` — scheduled nightly (18:00 UTC = 02:00 -Australia/Perth) plus `workflow_dispatch` for on-demand runs. +`.github/workflows/eval-canary.yml` — scheduled weekly on Sunday at 18:00 UTC +(Monday 02:00 Australia/Perth) plus `workflow_dispatch` for on-demand runs. What it does, in order: 1. `npm run check:supabase-project` — hard guard that the configured env points at `sjrfecxgysukkwxsowpy` and nothing else. 2. `npm run eval:retrieval:quality -- --fail-on-threshold` — the golden - retrieval eval (34 cases incl. forced-vector probes) against the live + retrieval eval (36 committed cases plus any captured cases, including + forced-vector probes) against the live corpus. This is the eval CI never runs on PRs (it needs live Supabase + - OpenAI keys); the canary makes it a standing nightly guard instead of a + OpenAI keys); the canary makes it a standing weekly guard instead of a manual pre-merge step that can be skipped. 3. `npm run eval:quality -- --rag-only --limit 8 --fail-on-threshold` — a small answer-quality subset (grounding, citations, unsupported-correctness) @@ -145,33 +146,38 @@ What it does, in order: Failing loudly: -- Any threshold failure fails the workflow run (red nightly badge, email per +- Any threshold failure fails the workflow run (red scheduled-run badge, email per GitHub notification settings). - On scheduled failures the workflow **opens a GitHub issue** labeled `eval-canary` (or comments on the existing open one), so a regression creates a durable, assignable artifact rather than a missed notification. +- The failure comment records every step outcome plus a deterministic failure + class derived from the captured eval logs. A completed non-zero + `failed_cases` summary is classified as a probable regression; raw provider + failures and the all-cases/no-retrieval-layers signature are classified as + provider or live-configuration failures. -Required repo secrets: `SUPABASE_SERVICE_ROLE_KEY`, `OPENAI_API_KEY`, -`E2E_USER_EMAIL` (resolved to the eval owner via `RAG_EVAL_OWNER_EMAIL`). -The workflow preflights these and fails with an explicit message when absent. +Required repo secrets: `SUPABASE_SERVICE_ROLE_KEY` and `OPENAI_API_KEY`. The workflow preflights these and fails with an explicit message when absent. Operational notes: - The canary reads live shared corpus state; a pass is a snapshot, and a failure can be corpus-state-dependent (see the clozapine-wcc history). - Triage order: rerun via `workflow_dispatch` → check `hybrid_rpc_errors` and - `check:indexing` → only then bisect code. + Triage order: inspect the recorded failure class and failed step → resolve + provider/configuration failures or inspect `hybrid_rpc_errors` and + `check:indexing` → only then bisect code. A `workflow_dispatch` rerun and all + live checks require explicit provider approval. - Forced-vector golden cases (`forceEmbedding`) run after many text-fast-path cases; the workflow sets `RAG_EVAL_CASE_DELAY_MS` and `RAG_EVAL_FORCE_EMBEDDING_DELAY_MS` so embedding calls do not exhaust the - nightly OpenAI rate limit mid-run. + scheduled run's OpenAI rate limit mid-run. - Evals write telemetry rows (`rag_queries`) but mutate no content. -- Cost bound: ~34 retrieval cases (embedding calls only on forced-vector - probes) + 8 generated answers per night. +- Cost bound: 36 committed retrieval cases plus any captured cases (embedding + calls only on forced-vector probes) + 8 generated answers per week. - **The schedule only runs from `main`.** After merging, trigger one `workflow_dispatch` run and confirm it goes green before trusting the - nightly cadence (repo gate for this workflow). + weekly cadence (repo gate for this workflow). ## 4. Degradation counters on `/api/health` (shipped) diff --git a/docs/operator-backlog.md b/docs/operator-backlog.md index ee79a9dfe..ad0007009 100644 --- a/docs/operator-backlog.md +++ b/docs/operator-backlog.md @@ -41,13 +41,13 @@ Findings inventory for handover: [audit-handover-2026-07-14.md](audit-handover-2 Each environment gets **separate** service-role + OpenAI keys (per-env blast radius). Placement is a dashboard/CLI action, never committed. -| Secret / config | Status | Where | Notes | -| ------------------------------------------ | ---------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RAG_QUERY_HASH_SECRET` (prod) | 🔎 verify | Railway runtime secret | GitHub repo secret present since 2026-07-10 (CI green); confirm the SAME value is set in Railway runtime. PIA-2 fail-closed guard requires it at boot (min 16 chars) | -| `HEALTH_DEEP_PROBE_SECRET` (prod + GitHub) | ⏳ pending | Railway runtime + GitHub repo secret | Needed to enable ops-digest. NOT currently a repo secret. Must be set in BOTH Railway (so the app authorizes the deep probe) AND as a GitHub secret with the same value, then uncomment the ops-digest cron | -| `SUPABASE_SERVICE_ROLE_KEY` (per env) | ⏳ pending | Railway runtime secret | accepts the `sb_secret_…` key | -| `OPENAI_API_KEY` (per env) | ⏳ pending | Railway runtime secret | `RAG_PROVIDER_MODE=auto` | -| OpenAI DPA / ZDR execution | ⏳ pending | OpenAI account + legal | app endpoints are ZDR-eligible; execution is operator + legal — see [openai-cross-border-basis.md](openai-cross-border-basis.md) | +| Secret / config | Status | Where | Notes | +| ------------------------------------------ | ---------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RAG_QUERY_HASH_SECRET` (prod) | 🔎 verify | Railway runtime secret | GitHub repo secret present since 2026-07-10 (CI green); confirm the SAME value is set in Railway runtime. PIA-2 fail-closed guard requires it at boot (min 16 chars) | +| `HEALTH_DEEP_PROBE_SECRET` (prod + GitHub) | ⚠️ partial | Railway runtime + GitHub repo secret | Railway production was set and the authorized deep probe returned healthy on 2026-07-19. GitHub remains pending: set the same value as a repo secret, set `PROD_HEALTH_URL`, then enable the ops-digest schedule. | +| `SUPABASE_SERVICE_ROLE_KEY` (per env) | ⏳ pending | Railway runtime secret | accepts the `sb_secret_…` key | +| `OPENAI_API_KEY` (per env) | ⏳ pending | Railway runtime secret | `RAG_PROVIDER_MODE=auto` | +| OpenAI DPA / ZDR execution | ⏳ pending | OpenAI account + legal | app endpoints are ZDR-eligible; execution is operator + legal — see [openai-cross-border-basis.md](openai-cross-border-basis.md) | ## Disaster-recovery re-creation (does NOT survive a schema restore) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index c02853b1e..6256d49cd 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -326,7 +326,7 @@ hybrid:10}`, all 10 forced-embedding vector cases passed (`force_embedding_failu ## 2026-07-13 audit remediation batch (branch claude/audit-remediation-2026-07-13) - **Lexical retrieval rewrite (audit finding 1):** `20260713100000_index_friendly_lexical_retrieval.sql` splits `match_document_chunks_text`'s OR-across-relations candidate search into two GIN-index probes unioned by chunk id (same contract, same scores; the `_v2` wrapper inherits the speedup). Parity + plan + timing harness: `scripts/sql/lexical-rpc-parity-check.sql` (scratch databases only; run it against the drift-manifest container kept with `--keep`). Re-run it whenever either lexical body changes. -- **supabase_admin default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies the intended defaults before migrations on fresh databases. Supabase CLI reads that file as the unprivileged local `postgres` role, so the migration-replay job starts an empty emulator without migrations, applies the file once as `supabase_admin`, and then applies every migration with `supabase migration up --local` in the same fresh database. This preserves the database-scoped defaults without granting reserved role membership. `20260717161000_assert_supabase_admin_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260717173000_reassert_supabase_admin_default_privileges.sql` repeats that fail-closed postcondition after the later performance migrations and must remain the final migration. Existing hosted databases do not receive `roles.sql` through ordinary migration deployment; after an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. The future-object probe in `20260713102000_revoke_supabase_admin_default_privileges.sql` remains useful in environments that can assume `supabase_admin`. +- **Hosted migration-role default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies secure `postgres` defaults before fresh local migration replay. `20260717161000_assert_postgres_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260717173000_reassert_postgres_default_privileges.sql` repeats the fail-closed postcondition, and `20260719053532_repair_postgres_default_privileges.sql` is the forward-only hosted repair immediately before the final title-word scope migration. All active revokes and assertions target objects created by `postgres` in `public`; service-role grants remain least-privilege. Older applied migration history is immutable. After an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. - **Legacy rag query text scrub (audit finding 5):** `20260713103000_scrub_legacy_rag_query_text.sql` performs four redaction/deletion operations for pre-HMAC plaintext query text: (1) scrubs `rag_queries.query` rows not matching `redacted-query:%`, replacing them with salted `redacted-query:legacy:` placeholders; (2) scrubs both `rag_query_misses.query` and `rag_query_misses.normalized_query` not matching `redacted-query:%`; (3) scrubs both `rag_retrieval_logs.query` and `rag_retrieval_logs.normalized_query` not matching `redacted-query:%` (nullable); (4) deletes `rag_response_cache` rows where `normalized_query` does not match `redacted-cache:%` (cache entries, not re-keyed). **Operator verification after live apply:** for each affected table/operation, count rows not matching the expected redacted pattern (expect 0 unless `RAG_PERSIST_RAW_QUERY_TEXT` is deliberately enabled): `select count(*) from rag_queries where query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_query_misses where query not like 'redacted-query:%' or normalized_query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_retrieval_logs where query not like 'redacted-query:%' or (normalized_query is not null and normalized_query not like 'redacted-query:%');` (expect 0), `select count(*) from rag_response_cache where normalized_query not like 'redacted-cache:%';` (expect 0). The migration includes a post-apply assertion block that enforces these exact checks and fails the migration if any unscrubbed/undeleted rows remain. - Remaining operator items from the 2026-07-13 audit (confirmation-required, not automated): apply this batch's migrations live, re-run Supabase advisors, trigger the Eval Canary and require two consecutive green scheduled runs, repair the invalid `storage.idx_objects_bucket_id_name_lower` index via dashboard/support, and dedupe response-cache purge cron jobs if `cron.job` shows duplicates. diff --git a/docs/productivity-workflows.md b/docs/productivity-workflows.md index 112a66e8e..ae9ae4831 100644 --- a/docs/productivity-workflows.md +++ b/docs/productivity-workflows.md @@ -1,5 +1,7 @@ # Productivity workflows +The repository also exposes a validated catalog of 32 single-word Database skills. Run `npm run skills` to list them by category, or `npm run check:skills` to verify that the catalog, skill folders, descriptions, and compatibility aliases agree. + The repository exposes seven offline-first workflow planners. Each planner inspects the current change through `scripts/ci-change-scope.mjs`, prints a minimal local verification sequence, and separates provider-backed commands into an explicit approval section. | Command | Purpose | 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/docs/tenancy-defense-in-depth-review.md b/docs/tenancy-defense-in-depth-review.md index 9549f09bf..dc6ee7d0f 100644 --- a/docs/tenancy-defense-in-depth-review.md +++ b/docs/tenancy-defense-in-depth-review.md @@ -124,14 +124,14 @@ transcripts; this is the consolidated verdict. ### Mutations · signed URLs · upload (highest blast radius) -| Route · method | Verdict | Owner mechanism | -| ------------------------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET /api/documents/[id]/signed-url` | ✅ verified-scoped | `withOwnerReadScope` on doc **before** `createSignedUrl`; `storage_path` from owner-verified row ([signed-url/route.ts:40-51](src/app/api/documents/[id]/signed-url/route.ts)) | -| `GET /api/images/[id]/signed-url` | ✅ verified-scoped | image has no `owner_id`; tenancy via parent-document `withOwnerReadScope` ([images/[id]/signed-url/route.ts:49-55](src/app/api/images/[id]/signed-url/route.ts)) | -| `POST /api/documents/[id]/reindex` | ✅ verified-scoped | `requireAuthenticatedUser` + `.eq('owner_id',user.id)`; every state write re-scoped ([reindex/route.ts:110-255](src/app/api/documents/[id]/reindex/route.ts)) | -| `POST /api/documents/bulk` | ✅ verified-scoped | pre-scoping select `.eq('owner_id',user.id).in('id',ids)`; body ids intersected with ownership ([bulk/route.ts:127-204](src/app/api/documents/bulk/route.ts)) | -| `POST /api/documents/bulk/reindex` | ✅ verified-scoped | pre-scoping select `.eq('owner_id',user.id)`; per-doc writes re-scoped ([bulk/reindex/route.ts:101-247](src/app/api/documents/bulk/reindex/route.ts)) | -| `POST /api/upload` | ✅ verified-scoped | `owner_id` = session id, or configured `PUBLIC_WORKSPACE_OWNER_ID` only if public uploads enabled, else 503 ([upload/route.ts:94-97](src/app/api/upload/route.ts)); operator note TEN-N3 | +| Route · method | Verdict | Owner mechanism | +| ------------------------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /api/documents/[id]/signed-url` | ✅ verified-scoped | `withOwnerReadScope` on doc **before** `createSignedUrl`; `storage_path` from owner-verified row ([signed-url/route.ts:40-51](src/app/api/documents/[id]/signed-url/route.ts)) | +| `GET /api/images/[id]/signed-url` | ✅ verified-scoped | image has no `owner_id`; tenancy via parent-document `withOwnerReadScope` ([images/[id]/signed-url/route.ts:49-55](src/app/api/images/[id]/signed-url/route.ts)) | +| `POST /api/documents/[id]/reindex` | ✅ verified-scoped | `requireAuthenticatedUser` + `.eq('owner_id',user.id)`; every state write re-scoped ([reindex/route.ts:110-255](src/app/api/documents/[id]/reindex/route.ts)) | +| `POST /api/documents/bulk` | ✅ verified-scoped | pre-scoping select `.eq('owner_id',user.id).in('id',ids)`; body ids intersected with ownership ([bulk/route.ts:127-204](src/app/api/documents/bulk/route.ts)) | +| `POST /api/documents/bulk/reindex` | ✅ verified-scoped | pre-scoping select `.eq('owner_id',user.id)`; per-doc writes re-scoped ([bulk/reindex/route.ts:101-247](src/app/api/documents/bulk/reindex/route.ts)) | +| `POST /api/upload` | ✅ admin-only | Validated Supabase session plus immutable `app_metadata.site_role = administrator`; uploaded rows remain scoped to that administrator until publication review promotes them ([upload/route.ts](src/app/api/upload/route.ts)) | ### Search @@ -336,11 +336,9 @@ new entry must be added here and to the list in the guard, or the regression tes only caller passes `user.id` ([summarize/route.ts:34](src/app/api/documents/[id]/summarize/route.ts)), so no live exploit — but make the parameter required (or fail closed) so a future caller can't reintroduce a gap. -- **TEN-N3 (operator note, intended):** with `NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED=true`, all anonymous - uploads are pooled under the single configured `PUBLIC_WORKSPACE_OWNER_ID` - ([upload/route.ts:94](src/app/api/upload/route.ts)) — anonymous X's upload is visible to anonymous Y - and the workspace owner. This is the documented public-workspace model, not a private-row A→B leak, - but operators enabling public uploads should understand it. +- **TEN-N3 (resolved):** the public-workspace upload path was removed. Anonymous and ordinary + authenticated users cannot upload; both the server route and Storage table privileges enforce the + administrator-only boundary. --- diff --git a/package.json b/package.json index 68251c959..33afa51b9 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,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", @@ -165,6 +166,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/railway.worker.json b/railway.worker.json index 9bf05e65e..db76164c5 100644 --- a/railway.worker.json +++ b/railway.worker.json @@ -25,8 +25,7 @@ ] }, "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 10, + "restartPolicyType": "ALWAYS", "multiRegionConfig": { "asia-southeast1-eqsg3a": { "numReplicas": 1 diff --git a/scripts/check-default-acl.ts b/scripts/check-default-acl.ts index 7edff312b..2d68019f9 100644 --- a/scripts/check-default-acl.ts +++ b/scripts/check-default-acl.ts @@ -5,7 +5,7 @@ function argument(name: string, fallback: string) { return index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : fallback; } -const roleName = argument("--role", "supabase_admin"); +const roleName = argument("--role", "postgres"); const schemaName = argument("--schema", "public"); if (!process.argv.includes("--confirm-provider-read")) { throw new Error("Refusing provider-backed ACL verification without --confirm-provider-read."); diff --git a/scripts/check-edge-functions.mjs b/scripts/check-edge-functions.mjs index 860b2a0aa..265c5a9a9 100644 --- a/scripts/check-edge-functions.mjs +++ b/scripts/check-edge-functions.mjs @@ -1,6 +1,22 @@ import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; -const args = ["check", "--node-modules-dir=false", "supabase/functions/indexing-v3-agent/index.ts"]; +const root = process.cwd(); +const functionsRoot = resolve(root, "supabase/functions"); +const entrypoints = readdirSync(functionsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(functionsRoot, entry.name, "index.ts")) + .filter((entrypoint) => existsSync(entrypoint)) + .map((entrypoint) => relative(root, entrypoint).replaceAll("\\", "/")) + .sort(); + +if (entrypoints.length === 0) { + console.error("[check:edge:functions] No supabase/functions/*/index.ts entrypoints were found."); + process.exit(1); +} + +const args = ["check", "--node-modules-dir=false", "--no-lock", ...entrypoints]; const deno = spawnSync("deno", ["--version"], { encoding: "utf8" }); const denoVersionLine = deno.stdout?.split("\n")[0]?.trim() ?? ""; const denoVersionMatch = denoVersionLine.match(/^deno\s+(\d+)\./); diff --git a/scripts/check-env-parity.mjs b/scripts/check-env-parity.mjs index f5e8a28b3..a2600f614 100644 --- a/scripts/check-env-parity.mjs +++ b/scripts/check-env-parity.mjs @@ -26,7 +26,7 @@ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); // Vars that MUST be supplied as deployment/CI secrets (never committed). Each is // asserted to exist in the canonical name set below, so this list cannot silently // drift from the schema. -const EXPECTED_SECRETS = [ +export const EXPECTED_GITHUB_SECRETS = [ "SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY", "RAG_QUERY_HASH_SECRET", @@ -35,6 +35,13 @@ const EXPECTED_SECRETS = [ "E2E_USER_PASSWORD", ]; +export const EXPECTED_RAILWAY_SECRETS = [ + "SUPABASE_SERVICE_ROLE_KEY", + "OPENAI_API_KEY", + "RAG_QUERY_HASH_SECRET", + "HEALTH_DEEP_PROBE_SECRET", +]; + /** Zod schema keys from env.ts: lines shaped like ` NAME: z.…`. */ export function parseEnvSchemaNames(envTsText) { return [...envTsText.matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*:\s*z\./gm)].map((m) => m[1]); @@ -81,7 +88,8 @@ function main() { const problems = []; // Self-consistency: every expected secret must be a name the app/CI actually knows. - const unknownExpected = EXPECTED_SECRETS.filter((name) => !canonical.has(name)); + const expectedSecrets = new Set([...EXPECTED_GITHUB_SECRETS, ...EXPECTED_RAILWAY_SECRETS]); + const unknownExpected = [...expectedSecrets].filter((name) => !canonical.has(name)); if (unknownExpected.length > 0) { problems.push( `Expected-secret names not found in env.ts/check-ci-env (typo or drift): ${unknownExpected.join(", ")}`, @@ -89,11 +97,12 @@ function main() { } console.log(`Known env names: ${canonical.size} (env.ts schema + check-ci-env).`); - console.log(`Expected secrets: ${EXPECTED_SECRETS.join(", ")}`); + console.log(`Expected GitHub secrets: ${EXPECTED_GITHUB_SECRETS.join(", ")}`); + console.log(`Expected Railway secrets: ${EXPECTED_RAILWAY_SECRETS.join(", ")}`); - for (const [flag, enabled, label, getter] of [ - ["--gh", useGh, "GitHub secrets", ghSecretNames], - ["--railway", useRailway, "Railway variables", railwayVarNames], + for (const [flag, enabled, label, getter, sourceExpectedSecrets] of [ + ["--gh", useGh, "GitHub secrets", ghSecretNames, EXPECTED_GITHUB_SECRETS], + ["--railway", useRailway, "Railway variables", railwayVarNames, EXPECTED_RAILWAY_SECRETS], ]) { if (!enabled) { console.log(`(${label}: skipped — pass ${flag} to check; names only, no values)`); @@ -109,7 +118,7 @@ function main() { const { missingSecrets, unknownLive } = computeParity({ canonical: [...canonical], liveNames, - expectedSecrets: EXPECTED_SECRETS, + expectedSecrets: sourceExpectedSecrets, }); console.log(`\n${label}: ${liveNames.length} names.`); if (missingSecrets.length > 0) problems.push(`${label}: missing expected secret(s): ${missingSecrets.join(", ")}`); diff --git a/scripts/generate-drift-manifest.ts b/scripts/generate-drift-manifest.ts index 74f0d2448..632c4f72c 100644 --- a/scripts/generate-drift-manifest.ts +++ b/scripts/generate-drift-manifest.ts @@ -95,8 +95,8 @@ async function main() { sql, ); - console.log("Applying role bootstrap (supabase_admin)…"); - psql("supabase_admin", rolesSql); + console.log("Applying role bootstrap (postgres)…"); + psql("postgres", rolesSql); console.log("Applying storage scaffold (supabase_admin)…"); psql("supabase_admin", scaffoldSql); console.log("Replaying supabase/schema.sql from scratch (postgres)…"); diff --git a/scripts/list-database-skills.mjs b/scripts/list-database-skills.mjs new file mode 100644 index 000000000..a0d4c766e --- /dev/null +++ b/scripts/list-database-skills.mjs @@ -0,0 +1,198 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +export const repositoryRoot = path.resolve(scriptDirectory, ".."); +export const skillsRoot = path.join(repositoryRoot, ".agents", "skills"); +export const catalogPath = path.join(skillsRoot, "catalog.json"); + +function wordCount(value) { + return String(value || "") + .trim() + .split(/\s+/) + .filter(Boolean).length; +} + +function readAgentMetadata(metadataFile) { + const content = fs.readFileSync(metadataFile, "utf8"); + return { + content, + shortDescription: content.match(/short_description:\s*"([^"]+)"/)?.[1] || "", + defaultPrompt: content.match(/default_prompt:\s*"([^"]+)"/)?.[1] || "", + }; +} + +function readFrontmatter(skillFile) { + const content = fs.readFileSync(skillFile, "utf8"); + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) throw new Error(`Missing YAML frontmatter: ${skillFile}`); + + const values = {}; + for (const line of match[1].split(/\r?\n/)) { + const separator = line.indexOf(":"); + if (separator < 1) continue; + values[line.slice(0, separator).trim()] = line.slice(separator + 1).trim(); + } + return values; +} + +export function loadSkillCatalog(file = catalogPath) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +export function discoverSkillDefinitions(root = skillsRoot) { + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const skillFile = path.join(root, entry.name, "SKILL.md"); + if (!fs.existsSync(skillFile)) return null; + const frontmatter = readFrontmatter(skillFile); + return { directory: entry.name, name: frontmatter.name, description: frontmatter.description }; + }) + .filter(Boolean) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function validateSkillCatalog(catalog = loadSkillCatalog(), discovered = discoverSkillDefinitions()) { + const errors = []; + if (catalog.version !== 1) errors.push(`Unsupported catalog version: ${catalog.version}`); + const categories = Array.isArray(catalog.categories) ? catalog.categories : []; + if (categories.length === 0) errors.push("Catalog must contain at least one category"); + for (const category of categories) { + if (typeof category.name !== "string" || !category.name.trim()) errors.push("Catalog category is missing a name"); + if (!Array.isArray(category.skills) || category.skills.length === 0) { + errors.push(`Catalog category has no skills: ${category.name || "unnamed"}`); + } + } + + const categoryNames = categories.map((category) => category.name); + const canonical = categories.flatMap((category) => + (Array.isArray(category.skills) ? category.skills : []).map((name) => ({ name, category: category.name })), + ); + const canonicalNames = canonical.map((skill) => skill.name); + const aliases = Object.entries(catalog.aliases || {}); + const aliasNames = aliases.map(([alias]) => alias); + const aliasTargets = new Map(aliases); + const discoveredByName = new Map(discovered.map((skill) => [skill.name, skill])); + const discoveredNames = discovered.map((skill) => skill.name); + + for (const [label, names] of [ + ["category", categoryNames], + ["canonical skill", canonicalNames], + ["alias", aliasNames], + ["discovered skill", discoveredNames], + ]) { + const duplicates = names.filter((name, index) => names.indexOf(name) !== index); + if (duplicates.length) errors.push(`Duplicate ${label} names: ${[...new Set(duplicates)].join(", ")}`); + } + + for (const skill of canonical) { + if (!/^[a-z0-9-]+$/.test(skill.name)) errors.push(`Invalid canonical skill name: ${skill.name}`); + const discoveredSkill = discoveredByName.get(skill.name); + if (!discoveredSkill) errors.push(`Missing canonical skill folder: ${skill.name}`); + else if (discoveredSkill.directory !== skill.name) { + errors.push(`Canonical skill directory mismatch: ${skill.name} is in ${discoveredSkill.directory}`); + } else { + skill.description = discoveredSkill.description; + } + } + + for (const [alias, target] of aliases) { + if (!canonicalNames.includes(target)) errors.push(`Alias ${alias} targets missing canonical skill ${target}`); + if (canonicalNames.includes(alias)) errors.push(`Alias duplicates canonical skill: ${alias}`); + if (!discoveredByName.has(alias)) errors.push(`Missing compatibility alias folder: ${alias}`); + } + + const allowed = new Set([...canonicalNames, ...aliasNames]); + for (const skill of discovered) { + if (!allowed.has(skill.name)) errors.push(`Uncatalogued Database skill: ${skill.name}`); + if (skill.name !== skill.directory) + errors.push(`Skill name/directory mismatch: ${skill.directory} declares ${skill.name}`); + if (!skill.description?.trim()) errors.push(`Missing skill description: ${skill.name}`); + if (wordCount(skill.description) > 60) errors.push(`Skill description exceeds 60 words: ${skill.name}`); + + const skillFile = path.join(skillsRoot, skill.directory, "SKILL.md"); + const skillContent = fs.readFileSync(skillFile, "utf8"); + const lineCount = skillContent.split(/\r?\n/).length; + const metadataFile = path.join(skillsRoot, skill.directory, "agents", "openai.yaml"); + if (!fs.existsSync(metadataFile)) { + errors.push(`Missing agents/openai.yaml: ${skill.name}`); + continue; + } + + const metadata = readAgentMetadata(metadataFile); + if (metadata.shortDescription.length < 25 || metadata.shortDescription.length > 64) { + errors.push(`Invalid short_description length for ${skill.name}: ${metadata.shortDescription.length}`); + } + if (!metadata.defaultPrompt.includes(`$${skill.name}`)) { + errors.push(`default_prompt does not mention $${skill.name}`); + } + + const aliasTarget = aliasTargets.get(skill.name); + if (aliasTarget) { + if (!skillContent.includes(`.agents/skills/${aliasTarget}/SKILL.md`)) { + errors.push(`Alias ${skill.name} does not redirect to ${aliasTarget}`); + } + if (lineCount > 15) errors.push(`Alias skill is too long: ${skill.name} (${lineCount} lines)`); + if (skillContent.includes("npm run")) errors.push(`Alias duplicates executable procedure: ${skill.name}`); + if (!metadata.content.includes("allow_implicit_invocation: false")) { + errors.push(`Alias allows implicit invocation: ${skill.name}`); + } + } else { + if (lineCount > 30) errors.push(`Canonical skill is too long: ${skill.name} (${lineCount} lines)`); + if (wordCount(skillContent) > 220) errors.push(`Canonical skill exceeds 220 words: ${skill.name}`); + } + } + + return { errors, canonical, aliases, discovered }; +} + +export function summarizeSkillDescription(description) { + return String(description || "") + .split(/\.\s+Use\b/, 1)[0] + .trim() + .replace(/\.$/, ""); +} + +export function renderSkillCatalog(catalog = loadSkillCatalog(), discovered = discoverSkillDefinitions()) { + const validation = validateSkillCatalog(catalog, discovered); + if (validation.errors.length) throw new Error(validation.errors.join("\n")); + + const descriptions = new Map(validation.canonical.map((skill) => [skill.name, skill.description])); + const lines = [`Database skills (${validation.canonical.length})`, ""]; + for (const category of catalog.categories) { + lines.push(category.name); + for (const name of category.skills) { + lines.push(`- ${name} — ${summarizeSkillDescription(descriptions.get(name))}.`); + } + lines.push(""); + } + return lines.join("\n").trimEnd(); +} + +function run(argv = process.argv.slice(2)) { + const catalog = loadSkillCatalog(); + const validation = validateSkillCatalog(catalog); + if (validation.errors.length) { + console.error(validation.errors.map((error) => `- ${error}`).join("\n")); + process.exitCode = 1; + return; + } + + if (argv.includes("--json")) { + console.log(JSON.stringify({ count: validation.canonical.length, categories: catalog.categories }, null, 2)); + return; + } + if (argv.includes("--check")) { + console.log( + `Database skill catalog valid: ${validation.canonical.length} canonical skills, ${validation.aliases.length} aliases.`, + ); + return; + } + console.log(renderSkillCatalog(catalog)); +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) run(); diff --git a/scripts/productivity-core.mjs b/scripts/productivity-core.mjs index 43f634bdc..1f51d2a93 100644 --- a/scripts/productivity-core.mjs +++ b/scripts/productivity-core.mjs @@ -276,13 +276,29 @@ export function buildWorkflowPlan(workflow, files = [], options = {}) { export function analyzeFailureText(text = "", knownFlakes = []) { const value = String(text); const lower = value.toLowerCase(); + const goldenCaseCount = Number.parseInt(lower.match(/(?:^|\n)\s*cases=(\d+)/)?.[1] ?? "", 10); + const goldenFailureCount = Number.parseInt(lower.match(/(?:^|\n)\s*failed_cases=(\d+)/)?.[1] ?? "", 10); + const retrievalLayers = lower.match(/(?:^|\n)\s*retrieval_layer_counts=(\{[^\n]*\})/)?.[1]; const knownFlake = knownFlakes.find((entry) => entry.pattern && lower.includes(String(entry.pattern).toLowerCase())); if (knownFlake) return { category: "known-flake", confidence: "high", reason: knownFlake.id || knownFlake.pattern }; if (/module_not_found|cannot find module|enoent|not recognized as an internal|command not found/.test(lower)) { return { category: "environment", confidence: "high", reason: "Missing executable, module, or path." }; } if ( - /missing.*(?:api[_ -]?key|secret|credential)|(?:api[_ -]?key|secret|credential)\s+(?:is\s+)?missing|unauthorized|forbidden|quota|rate limit|supabase project/.test( + Number.isInteger(goldenCaseCount) && + goldenCaseCount > 0 && + goldenFailureCount === goldenCaseCount && + retrievalLayers === "{}" + ) { + return { + category: "provider-or-configuration", + confidence: "high", + reason: + "Every golden case failed without any retrieval layer; verify eval owner, corpus scope, and live retrieval health.", + }; + } + if ( + /missing.*(?:api[_ -]?key|secret|credential)|(?:api[_ -]?key|secret|credential)\s+(?:is\s+)?missing|unauthorized|forbidden|unregistered api key|insufficient[_ -]?quota|quota|billing|\b429\b|rate[_ -]?limit|too many requests|supabase project/.test( lower, ) ) { @@ -292,6 +308,13 @@ export function analyzeFailureText(text = "", knownFlakes = []) { reason: "Credentials, authorization, quota, or live-provider configuration.", }; } + if (Number.isInteger(goldenFailureCount) && goldenFailureCount > 0) { + return { + category: "probable-regression", + confidence: "high", + reason: `A completed golden evaluation reported ${goldenFailureCount} failed case(s).`, + }; + } if (/timed? out|timeout|etimedout|browser has been closed|worker.*exited/.test(lower)) { return { category: "environment-or-timeout", 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 a78a3c7f2..bf44b236d 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 1a5fb8219..71b26ebb2 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..4b839f05e 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 { @@ -511,6 +507,7 @@ export function ClinicalDashboard({ // overlay); other modes keep the phone-only collapse, so the reporter only // widens past the phone media gate while in answer mode. const phoneScrollHide = useScrollHideReporter(false, searchMode === "answer"); + const [bottomComposerHidden, setBottomComposerHidden] = useState(false); const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => @@ -866,8 +863,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 +876,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 +925,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 +969,7 @@ export function ClinicalDashboard({ document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth", block: "start" }); }, 0); }, - [closeDashboardTransientSurfaces], + [canUseAdministrativeApis, closeDashboardTransientSurfaces], ); useEffect(() => { @@ -1561,7 +1571,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 +2857,7 @@ export function ClinicalDashboard({ } function openUploadDrawer() { - if (!canUsePrivateApis) { + if (!canUseAdministrativeApis) { openDocumentsDrawer("library"); setActionNotice({ tone: "warning", @@ -3239,6 +3250,20 @@ export function ClinicalDashboard({ const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted; const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); + const visibleMobileComposerReserve = + searchMode === "answer" + ? answerFollowUpSuggestions.length > 0 + ? "calc(7.5rem + var(--safe-area-bottom))" + : "calc(5.25rem + var(--safe-area-bottom))" + : differentialsCompareAddonActive + ? "calc(8.75rem + var(--safe-area-bottom))" + : compactMobileBottomSearch + ? "calc(5rem + var(--safe-area-bottom))" + : "calc(5.25rem + var(--safe-area-bottom))"; + // Browser safe areas protect the visible interactive dock. Once it has + // scrolled away, reserving Safari's translucent toolbar inset would leave a + // large blank band instead of allowing ordinary content to paint beneath it. + const mobileComposerReserve = bottomComposerHidden ? "0.75rem" : visibleMobileComposerReserve; const renderDegradedNotice = () => ( @@ -3519,6 +3545,7 @@ export function ClinicalDashboard({ ? { strategy: "overlay", allBreakpoints: true, scrollHidden: phoneScrollHide.hidden } : { strategy: "collapse", scrollHidden: phoneScrollHide.hidden } } + onBottomComposerHiddenChange={setBottomComposerHidden} />
reserves room for it. Keep that geometry stable - // while the dock translates off-screen: changing the flex item's - // margin alters its client height and clamps scrollTop near the - // bottom, feeding a false upward movement into hide-on-scroll. - answerFollowUpSuggestions.length > 0 - ? "mb-[calc(7.5rem+env(safe-area-inset-bottom))] sm:mb-24" - : "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-24" + : // Keep the phone scrollport edge-to-edge and reserve the visible + // dock inside its scrollable content. Padding can collapse when the + // dock hides without exposing the app-shell background; the + // bottom-clamp guard in use-hide-on-scroll prevents false reveals. + "max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24" : hasMobileBottomSearch - ? compactMobileBottomSearch - ? differentialsCompareAddonActive - ? "mb-[calc(8.75rem+env(safe-area-inset-bottom))] sm:mb-0" - : "mb-[calc(5rem+env(safe-area-inset-bottom))] sm:mb-0" - : // Mode homes keep the composer in the hero (in-flow at every - // width), so phones need no bottom-dock clearance on them. - compactMobileModeHome || showDesktopHomeComposer - ? "mb-0" - : "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-0" + ? // Mode homes keep the composer in the hero (in-flow at every + // width), so phones need no bottom-dock clearance on them. + compactMobileModeHome || showDesktopHomeComposer + ? "mb-0" + : "max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-0" : "mb-0", )} > @@ -4022,7 +4042,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..a66ee865a 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,23 +2655,33 @@ export function DocumentViewer({ Add to scope -
- - Admin controls - - -
+ {canUseAdministrativeApis ? ( +
+ + Admin controls + + +
+ ) : null} ) : null} -
+
{downloadError ? ( {downloadError} @@ -2980,14 +2994,14 @@ export function DocumentViewer({ /> ) : null} - {canUsePrivateApis ? ( + {canUseAdministrativeApis ? (
Document tools
- {canUsePrivateApis && tableFacts.length ? ( + {canUseAdministrativeApis && tableFacts.length ? (
Table tools @@ -3024,7 +3038,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..94c9f9bea --- /dev/null +++ b/src/components/account-data-provider.tsx @@ -0,0 +1,198 @@ +"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 AccountDataContext = createContext(null); + +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() { + const context = useContext(AccountDataContext); + if (!context) throw new Error("useAccountData must be used within AccountDataProvider."); + return context; +} 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} +
- 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)) && ( @@ -681,10 +678,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} /> = [ - { 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, -}; - const storageKey = "clinical-kb-preferences"; const changeEvent = "clinical-kb-preferences-change"; @@ -96,48 +39,6 @@ let inMemoryFallback: AppPreferences | null = null; let cachedRaw: string | null = null; let cachedValue: AppPreferences = DEFAULT_PREFERENCES; -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), - }; -} - function readStored(): AppPreferences { let raw: string | null = null; try { @@ -206,21 +107,73 @@ export function applyPreferenceSideEffects(preferences: AppPreferences) { } export function useAppPreferences() { + const { status: authStatus, authorizationHeader, authEpoch, markSessionExpired } = useAuthSession(); 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; + } + await fetch("/api/account/preferences", { + method: "PUT", + headers: { "Content-Type": "application/json", ...authorizationHeader }, + body: JSON.stringify(getSnapshot()), + signal: controller.signal, + }); + }) + .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 af44bd416..3f48c8eea 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -319,7 +319,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/config.toml b/supabase/config.toml index 268d42797..75daa99c9 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,2 +1,5 @@ [functions.indexing-v3-agent] verify_jwt = false + +[functions.ingestion-worker] +verify_jwt = true diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index bbc83f94e..6b783cf95 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-18T14:23:15.039Z", + "generated_at": "2026-07-19T10:21:34.307Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "3a2bd3f0de8dd1d16857100556c9bee604b063eedcb9eb5e7ab959e4799d28f0", - "replay_seconds": 20, + "schema_sha256": "f4b18b6fc86646a43daba7b73ef0c61a52ede0ac43bfeef5d73a56779a238871", + "replay_seconds": 75, "snapshot": { "views": [ { @@ -15,7 +15,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "api_rate_limit_subjects", "columns": [ @@ -67,7 +67,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "api_rate_limits", "columns": [ @@ -119,7 +119,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "audit_logs", "columns": [ @@ -187,7 +187,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "clinical_registry_record_sources", "columns": [ @@ -247,7 +247,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "clinical_registry_records", "columns": [ @@ -507,7 +507,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "differential_records", "columns": [ @@ -655,7 +655,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_chunks", "columns": [ @@ -824,7 +824,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_embedding_fields", "columns": [ @@ -935,7 +935,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_images", "columns": [ @@ -1168,7 +1168,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_index_quality", "columns": [ @@ -1284,7 +1284,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_index_units", "columns": [ @@ -1480,7 +1480,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_labels", "columns": [ @@ -1577,7 +1577,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_memory_cards", "columns": [ @@ -1749,7 +1749,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_pages", "columns": [ @@ -1822,7 +1822,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_publication_approvals", "columns": [ @@ -1906,7 +1906,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_sections", "columns": [ @@ -2062,7 +2062,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_summaries", "columns": [ @@ -2170,7 +2170,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_table_facts", "columns": [ @@ -2315,7 +2315,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_title_words", "columns": [ @@ -2343,7 +2343,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "documents", "columns": [ @@ -2531,7 +2531,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "image_caption_cache", "columns": [ @@ -2615,7 +2615,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "import_batches", "columns": [ @@ -2747,7 +2747,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "indexing_v3_agent_jobs", "columns": [ @@ -2871,7 +2871,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "ingestion_job_stages", "columns": [ @@ -2987,7 +2987,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "ingestion_jobs", "columns": [ @@ -3127,7 +3127,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "medication_records", "columns": [ @@ -3291,7 +3291,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_aliases", "columns": [ @@ -3383,7 +3383,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_answer_feedback", "columns": [ @@ -3483,7 +3483,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_queries", "columns": [ @@ -3559,7 +3559,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_query_misses", "columns": [ @@ -3779,7 +3779,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_response_cache", "columns": [ @@ -3879,7 +3879,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_retrieval_logs", "columns": [ @@ -4123,7 +4123,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_visual_eval_cases", "columns": [ @@ -4231,7 +4231,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_visual_eval_runs", "columns": [ @@ -4315,7 +4315,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "source_review_events", "columns": [ @@ -4431,7 +4431,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "storage_cleanup_jobs", "columns": [ @@ -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", @@ -6567,7 +6743,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "a6f0e7c27da08e47e2c01dce4ab5d58d", + "def_hash": "f0835bb6f4314fb9704af9ff56950d58", "signature": "public.default_privileges_status(text,text)" }, { @@ -6742,7 +6918,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "4bf81d62c0953a8d11c80c37de6045e7", + "def_hash": "3d99483e01a5c93374408b9e585d3962", "signature": "public.match_document_chunks_text_v2(text,integer,uuid[],uuid,boolean)" }, { @@ -6806,7 +6982,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "38282636c4c22a4f01974a943aa20d42", + "def_hash": "b72c524f3be13ec1a950cc30e922ec78", "signature": "public.match_document_index_units_hybrid_v2(extensions.vector,text,integer,double precision,uuid[],uuid,boolean)" }, { @@ -6899,7 +7075,6 @@ }, { "acl": [ - "=X/postgres", "postgres=X/postgres", "service_role=X/postgres" ], @@ -7937,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/functions/ingestion-worker/auth.ts b/supabase/functions/ingestion-worker/auth.ts new file mode 100644 index 000000000..aec9c87a3 --- /dev/null +++ b/supabase/functions/ingestion-worker/auth.ts @@ -0,0 +1,30 @@ +type JwtPayload = { + role?: unknown; +}; + +function decodeJwtPayload(token: string): JwtPayload | null { + const parts = token.split("."); + if (parts.length !== 3 || !parts[1]) return null; + + try { + const base64 = parts[1] + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(parts[1].length / 4) * 4, "="); + const payload = JSON.parse(atob(base64)) as unknown; + return payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as JwtPayload) : null; + } catch { + return null; + } +} + +/** + * The Edge gateway verifies the JWT before the request reaches this function + * (`verify_jwt = true`). This second authorization gate restricts the + * privileged direct-Postgres worker to the service-role cron token. + */ +export function hasServiceRoleAuthorization(authorization: string | null): boolean { + const token = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim(); + if (!token) return false; + return decodeJwtPayload(token)?.role === "service_role"; +} diff --git a/supabase/functions/ingestion-worker/index.ts b/supabase/functions/ingestion-worker/index.ts new file mode 100644 index 000000000..7617ea7af --- /dev/null +++ b/supabase/functions/ingestion-worker/index.ts @@ -0,0 +1,281 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import postgres from "npm:postgres@3.4.7"; + +import { hasServiceRoleAuthorization } from "./auth.ts"; + +declare const Supabase: { + ai: { + Session: new (model: string) => { + run(input: string, options: { mean_pool: boolean; normalize: boolean }): Promise; + }; + }; +}; + +type ClaimedJob = { + id: string; + document_id: string; + batch_id: string | null; + attempt_count: number; + max_attempts: number; + documents: { + id: string; + owner_id: string | null; + title: string | null; + metadata: Record | null; + }; +}; + +const SUPABASE_DB_URL = Deno.env.get("SUPABASE_DB_URL"); +if (!SUPABASE_DB_URL) throw new Error("SUPABASE_DB_URL is required"); + +const sql = postgres(SUPABASE_DB_URL, { + max: 3, + idle_timeout: 20, + connect_timeout: 10, +}); + +const embeddingModel = new Supabase.ai.Session("gte-small"); + +function normalizeText(input: string): string { + return input.replace(/\s+/g, " ").trim(); +} + +async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function generateEmbedding(text: string): Promise { + const vector = await embeddingModel.run(text, { mean_pool: true, normalize: true }); + if (!Array.isArray(vector)) throw new Error("Embedding model returned non-array output"); + return vector as number[]; +} + +async function buildSummary(documentId: string): Promise<{ summary: string; sourceChunkIds: string[] }> { + const chunks = await sql< + { + id: string; + content: string; + chunk_index: number; + }[] + >` + select id, content, chunk_index + from public.document_chunks + where document_id = ${documentId}::uuid + order by chunk_index asc + limit 8 + `; + + if (chunks.length === 0) { + return { + summary: "No chunk content available for this document yet.", + sourceChunkIds: [], + }; + } + + const merged = normalizeText(chunks.map((c) => c.content ?? "").join(" ")); + const summary = merged.length > 1600 ? `${merged.slice(0, 1600)}...` : merged; + + return { + summary, + sourceChunkIds: chunks.map((c) => c.id), + }; +} + +async function upsertDocumentSummary(job: ClaimedJob): Promise { + const docId = job.document_id; + const ownerId = job.documents.owner_id; + + const existing = await sql<{ id: string }[]>` + select id + from public.document_summaries + where document_id = ${docId}::uuid + limit 1 + `; + + if (existing.length > 0) { + const row = await sql<{ summary: string | null }[]>` + select summary + from public.document_summaries + where document_id = ${docId}::uuid + limit 1 + `; + + return normalizeText(row[0]?.summary ?? ""); + } + + const { summary, sourceChunkIds } = await buildSummary(docId); + + await sql` + insert into public.document_summaries ( + document_id, + owner_id, + summary, + source_chunk_ids, + model, + metadata, + generated_at + ) values ( + ${docId}::uuid, + ${ownerId}::uuid, + ${summary}, + ${sourceChunkIds}::uuid[], + ${"gte-small-heuristic-summary-v1"}, + ${JSON.stringify({ generated_by: "ingestion-worker", mode: "backfill" })}::jsonb, + now() + ) + on conflict (document_id) + do update set + summary = excluded.summary, + source_chunk_ids = excluded.source_chunk_ids, + model = excluded.model, + metadata = excluded.metadata, + generated_at = now(), + updated_at = now() + `; + + return summary; +} + +async function upsertEmbeddingFields(job: ClaimedJob, summaryText: string): Promise { + const docId = job.document_id; + const ownerId = job.documents.owner_id; + const title = normalizeText(job.documents.title ?? ""); + const summary = normalizeText(summaryText); + + const entries = [ + { fieldType: "document_title", content: title.length > 0 ? title : "Untitled document" }, + { fieldType: "document_summary", content: summary.length > 0 ? summary : "Summary unavailable" }, + ]; + + await sql` + delete from public.document_embedding_fields + where document_id = ${docId}::uuid + and field_type = any(${entries.map((e) => e.fieldType)}::text[]) + `; + + for (const entry of entries) { + const embedding = await generateEmbedding(entry.content); + const contentHash = await sha256Hex(entry.content); + + await sql` + insert into public.document_embedding_fields ( + owner_id, + document_id, + source_chunk_id, + field_type, + content, + embedding, + metadata, + content_hash + ) values ( + ${ownerId}::uuid, + ${docId}::uuid, + null, + ${entry.fieldType}, + ${entry.content}, + ${JSON.stringify(embedding)}::vector, + ${JSON.stringify({ generated_by: "ingestion-worker", mode: "backfill" })}::jsonb, + ${contentHash} + ) + `; + } +} + +async function markEnrichmentMetadata(documentId: string): Promise { + await sql` + update public.documents + set + metadata = coalesce(metadata, '{}'::jsonb) + || jsonb_build_object( + 'enrichment_status', 'completed', + 'rag_enrichment_updated_at', now() + ), + updated_at = now() + where id = ${documentId}::uuid + `; +} + +async function processJob(job: ClaimedJob): Promise { + const summary = await upsertDocumentSummary(job); + await upsertEmbeddingFields(job, summary); + await markEnrichmentMetadata(job.document_id); + + await sql` + select public.complete_ingestion_job( + ${job.id}::uuid, + ${job.document_id}::uuid, + ${job.batch_id}::uuid, + ${"indexed + enrichment backfill"} + ) + `; +} + +Deno.serve(async (req: Request) => { + try { + if (req.method !== "POST") { + return new Response("Method not allowed", { status: 405, headers: { Allow: "POST" } }); + } + if (!hasServiceRoleAuthorization(req.headers.get("authorization"))) { + return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(req.url); + const limitRaw = url.searchParams.get("limit") ?? "10"; + const limit = Number.isFinite(Number(limitRaw)) ? Math.max(1, Math.min(50, Number(limitRaw))) : 10; + const workerId = `edge-ingestion-worker-${crypto.randomUUID()}`; + + const claimed = await sql` + select * + from public.claim_ingestion_jobs(${workerId}, ${limit}, 45) + `; + + if (claimed.length === 0) { + return Response.json({ ok: true, claimed: 0, processed: 0, failed: 0 }); + } + + let processed = 0; + let failed = 0; + const failures: Array<{ job_id: string; document_id: string; error: string }> = []; + + for (const job of claimed) { + try { + await processJob(job); + processed += 1; + } catch (error) { + failed += 1; + const message = error instanceof Error ? error.message : JSON.stringify(error); + failures.push({ job_id: job.id, document_id: job.document_id, error: message }); + + const shouldRetry = job.attempt_count < job.max_attempts; + + await sql` + select public.fail_or_retry_ingestion_job( + ${job.id}::uuid, + ${job.document_id}::uuid, + ${job.batch_id}::uuid, + ${shouldRetry}, + ${"indexed"}, + ${"enrichment backfill failed"}, + ${message}, + ${new Date(Date.now() + 60_000).toISOString()}::timestamptz + ) + `; + } + } + + return Response.json({ + ok: true, + claimed: claimed.length, + processed, + failed, + failures, + }); + } catch (error) { + const message = error instanceof Error ? error.message : JSON.stringify(error); + return Response.json({ ok: false, error: message }, { status: 500 }); + } +}); diff --git a/supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql b/supabase/migrations/20260717161000_assert_postgres_default_privileges.sql similarity index 51% rename from supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql rename to supabase/migrations/20260717161000_assert_postgres_default_privileges.sql index b8ee1c8c8..3d3ee4172 100644 --- a/supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql +++ b/supabase/migrations/20260717161000_assert_postgres_default_privileges.sql @@ -1,11 +1,10 @@ --- Reassert the fail-closed default-ACL postcondition after the later --- performance migrations so this safety check remains the final migration. --- Catalog-level, fail-closed verification for future objects created by --- supabase_admin. A missing pg_default_acl row must be interpreted through --- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions. +-- Catalog-level, fail-closed verification for future objects created by the +-- hosted migration role. A missing pg_default_acl row must be interpreted +-- through acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on +-- functions. create or replace function public.default_privileges_status( - p_role_name text default 'supabase_admin', + p_role_name text default 'postgres', p_schema_name text default 'public' ) returns jsonb @@ -66,7 +65,7 @@ begin order by object_type, grantee, privilege_type), '{}'::text[] ), - coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), coalesce(bool_or(is_grantable), false) into v_entries, v_has_unexpected_grantee, v_has_grantable from exploded; @@ -126,60 +125,38 @@ revoke all on function public.default_privileges_status(text, text) grant execute on function public.default_privileges_status(text, text) to service_role; +-- Global revokes remove built-in/default grants that per-schema ACLs cannot +-- subtract. Explicit application grants remain scoped to schema public. +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + do $$ declare v_status jsonb; begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - begin - -- Local/Superuser-capable environments can assume the target role even - -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE - -- directly. Hosted environments that cannot assume it fall through to the - -- catalog assertion and block with operator instructions. - execute 'set local role supabase_admin'; - -- Revokes must be global: per-schema ACLs cannot subtract privileges from - -- built-in or previously granted global defaults. - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - execute 'reset role'; - exception when insufficient_privilege then - begin execute 'reset role'; exception when others then null; end; - raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user; - end; - - v_status := public.default_privileges_status('supabase_admin', 'public'); + v_status := public.default_privileges_status('postgres', 'public'); if not coalesce((v_status->>'safe')::boolean, false) then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; migration blocked.', + message = 'Unsafe postgres default privileges in schema public; migration blocked.', detail = v_status::text, - hint = E'Run these six statements as supabase_admin, then retry the migration:\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;'; + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; end if; end; $$; diff --git a/supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql b/supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql deleted file mode 100644 index ea4e91025..000000000 --- a/supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql +++ /dev/null @@ -1,179 +0,0 @@ --- Catalog-level, fail-closed verification for future objects created by --- supabase_admin. A missing pg_default_acl row must be interpreted through --- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions. - -create or replace function public.default_privileges_status( - p_role_name text default 'supabase_admin', - p_schema_name text default 'public' -) -returns jsonb -language plpgsql -stable -security definer -set search_path = '' -as $$ -declare - v_role_oid oid; - v_namespace_oid oid; - v_entries text[] := '{}'::text[]; - v_safe boolean := false; - v_has_unexpected_grantee boolean := false; -begin - select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; - select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; - - if v_role_oid is null or v_namespace_oid is null then - return jsonb_build_object( - 'role_exists', v_role_oid is not null, - 'schema_exists', v_namespace_oid is not null, - 'safe', false, - 'entries', '[]'::jsonb - ); - end if; - - with object_types(object_type, object_code) as ( - values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") - ), effective_acls as ( - select - ot.object_type, - coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid)) - || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl - from object_types ot - left join pg_catalog.pg_default_acl global_acl - on global_acl.defaclrole = v_role_oid - and global_acl.defaclnamespace = 0 - and global_acl.defaclobjtype = ot.object_code - left join pg_catalog.pg_default_acl schema_acl - on schema_acl.defaclrole = v_role_oid - and schema_acl.defaclnamespace = v_namespace_oid - and schema_acl.defaclobjtype = ot.object_code - ), exploded as ( - select distinct - ea.object_type, - case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, - lower(privilege.privilege_type) as privilege_type - from effective_acls ea - cross join lateral pg_catalog.aclexplode(ea.acl) privilege - left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee - ) - select - coalesce( - array_agg(format('%s:%s:%s', object_type, grantee, privilege_type) - order by object_type, grantee, privilege_type), - '{}'::text[] - ), - coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false) - into v_entries, v_has_unexpected_grantee - from exploded; - - v_safe := - not v_has_unexpected_grantee - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'table:PUBLIC:%' - or entry like 'table:anon:%' - or entry like 'table:authenticated:%' - or entry like 'sequence:PUBLIC:%' - or entry like 'sequence:anon:%' - or entry like 'sequence:authenticated:%' - or entry = 'function:PUBLIC:execute' - or entry like 'function:anon:%' - or entry like 'function:authenticated:%' - ) - and 'table:service_role:select' = any(v_entries) - and 'table:service_role:insert' = any(v_entries) - and 'table:service_role:update' = any(v_entries) - and 'table:service_role:delete' = any(v_entries) - and 'sequence:service_role:usage' = any(v_entries) - and 'sequence:service_role:select' = any(v_entries) - and 'function:service_role:execute' = any(v_entries) - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'table:service_role:%' - and entry <> all(array[ - 'table:service_role:select', 'table:service_role:insert', - 'table:service_role:update', 'table:service_role:delete' - ]) - ) - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'sequence:service_role:%' - and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select']) - ) - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'function:service_role:%' - and entry <> 'function:service_role:execute' - ); - - return jsonb_build_object( - 'role_exists', true, - 'schema_exists', true, - 'safe', v_safe, - 'entries', to_jsonb(v_entries) - ); -end; -$$; - -revoke all on function public.default_privileges_status(text, text) - from public, anon, authenticated; -grant execute on function public.default_privileges_status(text, text) - to service_role; - -do $$ -declare - v_status jsonb; -begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - begin - -- Local/Superuser-capable environments can assume the target role even - -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE - -- directly. Hosted environments that cannot assume it fall through to the - -- catalog assertion and block with operator instructions. - execute 'set local role supabase_admin'; - -- Revokes must be global: per-schema ACLs cannot subtract privileges from - -- built-in or previously granted global defaults. - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - execute 'reset role'; - exception when insufficient_privilege then - begin execute 'reset role'; exception when others then null; end; - raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user; - end; - - v_status := public.default_privileges_status('supabase_admin', 'public'); - if not coalesce((v_status->>'safe')::boolean, false) then - raise exception using - errcode = '42501', - message = 'Unsafe supabase_admin default privileges; migration blocked.', - detail = v_status::text, - hint = E'Run these six statements as supabase_admin, then retry the migration:\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;'; - end if; -end; -$$; diff --git a/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql b/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql new file mode 100644 index 000000000..f0c04bacf --- /dev/null +++ b/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql @@ -0,0 +1,37 @@ +-- Reassert the fail-closed default-ACL postcondition after later migrations. +-- Every statement is safe to rerun and is executable by hosted migrations, +-- which create public-schema objects as postgres. + +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; reassertion blocked.', + detail = v_status::text, + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql b/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql new file mode 100644 index 000000000..6da082e90 --- /dev/null +++ b/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql @@ -0,0 +1,159 @@ +-- Forward-only hosted repair for secure future-object defaults. This migration +-- is intentionally self-contained because the earlier assertion failed before +-- its function or ACL changes could commit in production. + +create or replace function public.default_privileges_status( + p_role_name text default 'postgres', + p_schema_name text default 'public' +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_role_oid oid; + v_namespace_oid oid; + v_entries text[] := '{}'::text[]; + v_safe boolean := false; + v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; +begin + select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; + select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; + + if v_role_oid is null or v_namespace_oid is null then + return jsonb_build_object( + 'role_exists', v_role_oid is not null, + 'schema_exists', v_namespace_oid is not null, + 'safe', false, + 'entries', '[]'::jsonb + ); + end if; + + with object_types(object_type, object_code) as ( + values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") + ), effective_acls as ( + select + ot.object_type, + coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid)) + || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl + from object_types ot + left join pg_catalog.pg_default_acl global_acl + on global_acl.defaclrole = v_role_oid + and global_acl.defaclnamespace = 0 + and global_acl.defaclobjtype = ot.object_code + left join pg_catalog.pg_default_acl schema_acl + on schema_acl.defaclrole = v_role_oid + and schema_acl.defaclnamespace = v_namespace_oid + and schema_acl.defaclobjtype = ot.object_code + ), exploded as ( + select distinct + ea.object_type, + case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable + from effective_acls ea + cross join lateral pg_catalog.aclexplode(ea.acl) privilege + left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee + ) + select + coalesce( + array_agg(format('%s:%s:%s', object_type, grantee, privilege_type) + order by object_type, grantee, privilege_type), + '{}'::text[] + ), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable + from exploded; + + v_safe := + not v_has_unexpected_grantee + and not v_has_grantable + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:PUBLIC:%' + or entry like 'table:anon:%' + or entry like 'table:authenticated:%' + or entry like 'sequence:PUBLIC:%' + or entry like 'sequence:anon:%' + or entry like 'sequence:authenticated:%' + or entry = 'function:PUBLIC:execute' + or entry like 'function:anon:%' + or entry like 'function:authenticated:%' + ) + and 'table:service_role:select' = any(v_entries) + and 'table:service_role:insert' = any(v_entries) + and 'table:service_role:update' = any(v_entries) + and 'table:service_role:delete' = any(v_entries) + and 'sequence:service_role:usage' = any(v_entries) + and 'sequence:service_role:select' = any(v_entries) + and 'function:service_role:execute' = any(v_entries) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:service_role:%' + and entry <> all(array[ + 'table:service_role:select', 'table:service_role:insert', + 'table:service_role:update', 'table:service_role:delete' + ]) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'sequence:service_role:%' + and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select']) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'function:service_role:%' + and entry <> 'function:service_role:execute' + ); + + return jsonb_build_object( + 'role_exists', true, + 'schema_exists', true, + 'safe', v_safe, + 'entries', to_jsonb(v_entries) + ); +end; +$$; + +revoke all on function public.default_privileges_status(text, text) + from public, anon, authenticated; +grant execute on function public.default_privileges_status(text, text) + to service_role; + +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; repair blocked.', + detail = v_status::text, + hint = 'Rerun this idempotent repair after correcting postgres default privileges.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260718223000_enforce_public_title_word_scope.sql b/supabase/migrations/20260719053533_enforce_public_title_word_scope.sql similarity index 88% rename from supabase/migrations/20260718223000_enforce_public_title_word_scope.sql rename to supabase/migrations/20260719053533_enforce_public_title_word_scope.sql index 3d4afb75f..4e133f1f6 100644 --- a/supabase/migrations/20260718223000_enforce_public_title_word_scope.sql +++ b/supabase/migrations/20260719053533_enforce_public_title_word_scope.sql @@ -142,25 +142,19 @@ revoke execute on function public.correct_clinical_query_terms(text, real) grant execute on function public.correct_clinical_query_terms(text, real) to service_role; --- 20260717173000 establishes this as the fail-closed default-ACL invariant. --- This newer migration creates a function, so reassert that the invariant still --- holds before the transaction can commit. +-- The immediately preceding repair establishes this fail-closed default-ACL +-- invariant. This migration creates a function, so reassert it before commit. do $$ declare v_status jsonb; begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - v_status := public.default_privileges_status('supabase_admin', 'public'); + v_status := public.default_privileges_status('postgres', 'public'); if not coalesce((v_status->>'safe')::boolean, false) then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; title-word privacy migration blocked.', + message = 'Unsafe postgres default privileges in schema public; title-word privacy migration blocked.', detail = v_status::text, - hint = 'Reapply the default-privilege remediation in migration 20260717173000, then retry.'; + hint = 'Reapply migration 20260719053532_repair_postgres_default_privileges, then retry.'; end if; end; $$; 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/supabase/roles.sql b/supabase/roles.sql index c153381ec..ff730d548 100644 --- a/supabase/roles.sql +++ b/supabase/roles.sql @@ -1,8 +1,6 @@ --- Bootstrap safe supabase_admin future-object defaults before migrations in --- fresh local databases. The Supabase CLI reads this file as `postgres`, which --- cannot alter the reserved supabase_admin role. Local replay therefore applies --- this file once as supabase_admin before reset; subsequent CLI reads verify the --- durable catalog postcondition and become no-ops. +-- Bootstrap secure future-object defaults for migrations and dashboard SQL +-- that create objects as postgres. The Supabase CLI and hosted migration +-- runner can execute this file without assuming another role. do $$ declare @@ -11,39 +9,33 @@ declare v_entries text[] := '{}'::text[]; v_safe boolean := false; v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; begin - select oid into v_role_oid from pg_catalog.pg_roles where rolname = 'supabase_admin'; + select oid into v_role_oid from pg_catalog.pg_roles where rolname = 'postgres'; select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = 'public'; if v_role_oid is null or v_namespace_oid is null then - raise exception 'supabase_admin and public schema are required for default-privilege bootstrap'; + raise exception 'postgres role and public schema are required for default-privilege bootstrap'; end if; - begin - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - exception when insufficient_privilege then - null; - end; + alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; + alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; + alter default privileges for role postgres in schema public + grant execute on functions to service_role; with object_types(object_type, object_code) as ( values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") @@ -65,7 +57,8 @@ begin select distinct ea.object_type, case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, - lower(privilege.privilege_type) as privilege_type + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable from effective_acls ea cross join lateral pg_catalog.aclexplode(ea.acl) privilege left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee @@ -76,12 +69,14 @@ begin order by object_type, grantee, privilege_type), '{}'::text[] ), - coalesce(bool_or(grantee not in ('supabase_admin', 'postgres', 'service_role')), false) - into v_entries, v_has_unexpected_grantee + coalesce(bool_or(grantee not in ('postgres', 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable from exploded; v_safe := not v_has_unexpected_grantee + and not v_has_grantable and not exists ( select 1 from unnest(v_entries) entry where entry like 'table:PUBLIC:%' @@ -123,7 +118,7 @@ begin if not v_safe then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; bootstrap must run as supabase_admin.', + message = 'Unsafe postgres default privileges in schema public.', detail = to_jsonb(v_entries)::text; end if; end; diff --git a/supabase/schema.sql b/supabase/schema.sql index 86c77674c..02d214c5f 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -4987,37 +4987,26 @@ alter default privileges for role postgres in schema public alter default privileges for role postgres in schema public grant execute on functions to service_role; --- Default privileges are keyed to the creating role, so the postgres block --- above does not cover objects created by supabase_admin (dashboard SQL --- editor, platform tooling). Guarded because only a superuser or a member of --- supabase_admin may alter its defaults (2026-07-13 audit, finding 7). -do $$ -begin - if not exists (select 1 from pg_roles where rolname = 'supabase_admin') then - raise warning 'role supabase_admin does not exist; default-privilege hardening skipped'; - return; - end if; - - begin - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from anon, authenticated; - alter default privileges for role supabase_admin in schema public - revoke usage, select on sequences from anon, authenticated; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - exception - when insufficient_privilege then - raise warning 'cannot alter default privileges for supabase_admin as %; ' - 'operator follow-up required: run the six ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin ' - 'statements from migration 20260713102000 via the Supabase dashboard SQL editor', current_user; - end; -end $$; +-- Reassert the creating role's future-object defaults. Global revokes remove +-- built-in/default grants; application grants stay scoped to schema public. +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; -- Performance remediation: registry projection lifecycle cleanup. create index if not exists documents_registry_projection_lookup_idx @@ -7549,7 +7538,13 @@ stable set search_path = public, extensions, pg_temp as $$ select * - from public.match_document_chunks_text_scoped($1, $2, $3, $4, $5); + from public.match_document_chunks_text_scoped( + $1, + least(greatest(coalesce($2, 12), 1), 96), + $3, + $4, + $5 + ); $$; create or replace function public.match_document_chunks_hybrid_v2( @@ -7726,7 +7721,15 @@ stable set search_path = public, extensions, pg_temp as $$ select * - from public.match_document_index_units_hybrid_scoped($1, $2, $3, $4, $5, $6, $7); + from public.match_document_index_units_hybrid_scoped( + $1, + $2, + least(greatest(coalesce($3, 24), 1), 96), + $4, + $5, + $6, + $7 + ); $$; create or replace function public.match_document_memory_cards_hybrid_v3( @@ -8229,11 +8232,11 @@ revoke all on function public.retry_ingestion_job_if_idle(uuid, uuid, timestampt grant execute on function public.retry_ingestion_job_if_idle(uuid, uuid, timestamptz, integer, timestamptz, timestamptz) to service_role; -- Catalog-level, fail-closed verification for future objects created by --- supabase_admin. A missing pg_default_acl row must be interpreted through +-- postgres. A missing pg_default_acl row must be interpreted through -- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions. create or replace function public.default_privileges_status( - p_role_name text default 'supabase_admin', + p_role_name text default 'postgres', p_schema_name text default 'public' ) returns jsonb @@ -8248,6 +8251,7 @@ declare v_entries text[] := '{}'::text[]; v_safe boolean := false; v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; begin select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; @@ -8281,7 +8285,8 @@ begin select distinct ea.object_type, case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, - lower(privilege.privilege_type) as privilege_type + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable from effective_acls ea cross join lateral pg_catalog.aclexplode(ea.acl) privilege left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee @@ -8292,12 +8297,14 @@ begin order by object_type, grantee, privilege_type), '{}'::text[] ), - coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false) - into v_entries, v_has_unexpected_grantee + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable from exploded; v_safe := not v_has_unexpected_grantee + and not v_has_grantable and not exists ( select 1 from unnest(v_entries) entry where entry like 'table:PUBLIC:%' @@ -8354,59 +8361,85 @@ do $$ declare v_status jsonb; begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - begin - -- Local/Superuser-capable environments can assume the target role even - -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE - -- directly. Hosted environments that cannot assume it fall through to the - -- catalog assertion and block with operator instructions. - execute 'set local role supabase_admin'; - -- Revokes must be global: per-schema ACLs cannot subtract privileges from - -- built-in or previously granted global defaults. - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - execute 'reset role'; - exception when insufficient_privilege then - begin execute 'reset role'; exception when others then null; end; - raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user; - end; - - v_status := public.default_privileges_status('supabase_admin', 'public'); + alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; + alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; + alter default privileges for role postgres in schema public + grant execute on functions to service_role; + + v_status := public.default_privileges_status('postgres', 'public'); if not coalesce((v_status->>'safe')::boolean, false) then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; migration blocked.', + message = 'Unsafe postgres default privileges in schema public; migration blocked.', detail = v_status::text, - hint = E'Run these six statements as supabase_admin, then retry the migration:\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;'; + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; end if; end; $$; + +-- Account-owned application data. Public content remains anonymous-readable +-- through server routes; favourites and preferences require an authenticated +-- owner at both the API and RLS layers. +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); + +revoke insert, update, delete on table storage.objects from anon, authenticated; drop trigger if exists clinical_registry_records_delete_cleanup on public.clinical_registry_records; create trigger clinical_registry_records_delete_cleanup after delete on public.clinical_registry_records @@ -8589,11 +8622,11 @@ grant execute on function public.consume_summary_rate_limits_atomic( ) to service_role; -- Catalog-level, fail-closed verification for future objects created by --- supabase_admin. A missing pg_default_acl row must be interpreted through +-- postgres. A missing pg_default_acl row must be interpreted through -- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions. create or replace function public.default_privileges_status( - p_role_name text default 'supabase_admin', + p_role_name text default 'postgres', p_schema_name text default 'public' ) returns jsonb @@ -8654,7 +8687,7 @@ begin order by object_type, grantee, privilege_type), '{}'::text[] ), - coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), coalesce(bool_or(is_grantable), false) into v_entries, v_has_unexpected_grantee, v_has_grantable from exploded; @@ -8718,56 +8751,32 @@ do $$ declare v_status jsonb; begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - begin - -- Local/Superuser-capable environments can assume the target role even - -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE - -- directly. Hosted environments that cannot assume it fall through to the - -- catalog assertion and block with operator instructions. - execute 'set local role supabase_admin'; - -- Revokes must be global: per-schema ACLs cannot subtract privileges from - -- built-in or previously granted global defaults. - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - execute 'reset role'; - exception when insufficient_privilege then - begin execute 'reset role'; exception when others then null; end; - raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user; - end; - - v_status := public.default_privileges_status('supabase_admin', 'public'); + alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; + alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; + alter default privileges for role postgres in schema public + grant execute on functions to service_role; + + v_status := public.default_privileges_status('postgres', 'public'); if not coalesce((v_status->>'safe')::boolean, false) then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; migration blocked.', + message = 'Unsafe postgres default privileges in schema public; reassertion blocked.', detail = v_status::text, - hint = E'Run these six statements as supabase_admin, then retry the migration:\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;'; + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; end if; end; $$; 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/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/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index 90cf1df20..63e3d08c7 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -29,6 +29,7 @@ const globalSearchShellSource = readFileSync( "utf8", ); const clinicalDashboardSource = readFileSync(resolve(process.cwd(), "src/components/ClinicalDashboard.tsx"), "utf8"); +const documentViewerSource = readFileSync(resolve(process.cwd(), "src/components/DocumentViewer.tsx"), "utf8"); const globalStylesSource = readFileSync(resolve(process.cwd(), "src/app/globals.css"), "utf8"); type FoundDeclaration = { source: string }; @@ -86,11 +87,18 @@ describe("ClinicalDashboard merge-artifact guards", () => { expect(globalStylesSource).not.toMatch(/^\s*-webkit-backdrop-filter\s*:/m); }); - it("reserves phone space for the fixed mode-home composer", () => { + it("releases the Safari toolbar reserve only after phone composers hide", () => { expect(globalSearchShellSource).toContain("const visibleMobileComposerReserve = !shouldShowSearchComposer"); expect(globalSearchShellSource).toContain("const mobileComposerReserve = bottomComposerHidden"); + expect(globalSearchShellSource).toContain('bottomComposerHidden ? "0.75rem"'); + expect(globalSearchShellSource).not.toContain("max(0.75rem, env(safe-area-inset-bottom))"); expect(globalSearchShellSource).not.toContain("const mobileComposerReserve = !reservesFloatingComposer"); expect(globalSearchShellSource).not.toContain("const mobileComposerReserve = phoneScrollHide.hidden"); + expect(clinicalDashboardSource).toContain('bottomComposerHidden ? "0.75rem"'); + expect(clinicalDashboardSource).toContain('"max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24"'); + expect(documentViewerSource).toContain('data-testid="document-viewer-content"'); + expect(documentViewerSource).toContain('"max-sm:pb-3"'); + expect(documentViewerSource).toContain('"max-sm:pb-[calc(9rem+var(--safe-area-bottom))]"'); }); it("does not reintroduce the obsolete output-mode copy helper", () => { diff --git a/tests/database-skills.test.ts b/tests/database-skills.test.ts new file mode 100644 index 000000000..7f0d1e33d --- /dev/null +++ b/tests/database-skills.test.ts @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + discoverSkillDefinitions, + loadSkillCatalog, + renderSkillCatalog, + skillsRoot, + validateSkillCatalog, +} from "../scripts/list-database-skills.mjs"; + +describe("Database skill catalog", () => { + it("contains every canonical skill exactly once and validates every alias", () => { + const catalog = loadSkillCatalog(); + const result = validateSkillCatalog(); + + expect(result.errors).toEqual([]); + expect(result.canonical).toHaveLength(32); + expect(new Set(result.canonical.map((skill) => skill.name))).toHaveProperty("size", 32); + expect(result.aliases).toHaveLength(8); + for (const category of catalog.categories) { + expect(category.skills.every((skill: unknown) => typeof skill === "string")).toBe(true); + } + }); + + it("discovers each declared skill from its folder metadata", () => { + const discovered = discoverSkillDefinitions(); + + expect(discovered).toHaveLength(40); + for (const skill of discovered) { + const metadataPath = path.join(skillsRoot, skill.name, "agents", "openai.yaml"); + expect(skill.directory).toBe(skill.name); + expect(skill.description).toBeTruthy(); + expect(fs.existsSync(metadataPath)).toBe(true); + + const metadata = fs.readFileSync(metadataPath, "utf8"); + const shortDescription = metadata.match(/short_description:\s*"([^"]+)"/)?.[1] || ""; + const defaultPrompt = metadata.match(/default_prompt:\s*"([^"]+)"/)?.[1] || ""; + expect(shortDescription.length).toBeGreaterThanOrEqual(25); + expect(shortDescription.length).toBeLessThanOrEqual(64); + expect(defaultPrompt).toContain(`$${skill.name}`); + } + }); + + it("keeps canonical skills compact and compatibility aliases as one-hop redirects", () => { + const result = validateSkillCatalog(); + + for (const skill of result.canonical) { + const content = fs.readFileSync(path.join(skillsRoot, skill.name, "SKILL.md"), "utf8"); + expect(content.split(/\r?\n/).length).toBeLessThanOrEqual(30); + expect(content.split(/\s+/).filter(Boolean).length).toBeLessThanOrEqual(220); + } + + for (const [alias, target] of result.aliases) { + const content = fs.readFileSync(path.join(skillsRoot, alias, "SKILL.md"), "utf8"); + const metadata = fs.readFileSync(path.join(skillsRoot, alias, "agents", "openai.yaml"), "utf8"); + expect(content).toContain(`.agents/skills/${target}/SKILL.md`); + expect(content.split(/\r?\n/).length).toBeLessThanOrEqual(15); + expect(content).not.toContain("npm run"); + expect(metadata).toContain("allow_implicit_invocation: false"); + } + }); + + it("renders canonical skills by category without duplicating compatibility aliases", () => { + const catalog = loadSkillCatalog(); + const rendered = renderSkillCatalog(catalog); + + expect(rendered).toContain("Database skills (32)"); + expect(rendered).toContain("- skills — List every unique Database-specific skill with a clear explanation"); + expect(rendered).not.toContain("- workflows —"); + for (const category of catalog.categories) expect(rendered).toContain(category.name); + }); +}); diff --git a/tests/eval-canary-workflow.test.ts b/tests/eval-canary-workflow.test.ts index b41c83891..69b40d2d1 100644 --- a/tests/eval-canary-workflow.test.ts +++ b/tests/eval-canary-workflow.test.ts @@ -4,6 +4,12 @@ import { describe, expect, it } from "vitest"; const workflow = readFileSync(new URL("../.github/workflows/eval-canary.yml", import.meta.url), "utf8"); describe("eval canary workflow input", () => { + it("runs weekly on Monday morning in Australia/Perth", () => { + expect(workflow).toContain('- cron: "0 18 * * 0"'); + expect(workflow).not.toContain('- cron: "0 18 * * *"'); + expect(workflow).toContain("Sunday 18:00 UTC = Monday 02:00 Australia/Perth"); + }); + it("validates the dispatch limit outside shell source and passes it as one quoted argument", () => { expect(workflow).toContain("ANSWER_CASE_LIMIT: ${{ github.event.inputs.answer_case_limit || '8' }}"); expect(workflow).toContain('[[ ! "$ANSWER_CASE_LIMIT" =~ ^[0-9]+$ ]]'); @@ -13,11 +19,23 @@ describe("eval canary workflow input", () => { }); it("distinguishes provider outages from retrieval regressions in the failure issue", () => { - expect(workflow).toContain('title: "Eval canary failure: nightly evaluation did not complete"'); + expect(workflow).toContain('title: "Eval canary failure: weekly evaluation did not complete"'); expect(workflow).toContain("Resolve provider quota/auth/config failures before rerunning"); expect(workflow).toContain( "Do not bisect or revert code until provider health and the failure class are confirmed", ); - expect(workflow).not.toContain("Eval canary regression: nightly golden eval failed"); + expect(workflow).not.toContain("Eval canary regression:"); + }); + + it("captures eval logs and adds deterministic failure classification to the issue", () => { + expect(workflow).toContain("set -o pipefail"); + expect(workflow).toContain("tee .local/eval-canary/golden-retrieval.log"); + expect(workflow).toContain("tee .local/eval-canary/answer-quality.log"); + expect(workflow).toContain( + "await import(pathToFileURL(`${process.env.GITHUB_WORKSPACE}/scripts/productivity-core.mjs`).href)", + ); + expect(workflow).toContain("analyzeFailureText(failureText)"); + expect(workflow).toContain("`Failure class: ${diagnosis.category} (${diagnosis.confidence})`"); + expect(workflow).toContain("`Step outcomes: ${JSON.stringify(stepOutcomes)}`"); }); }); diff --git a/tests/forms-back-navigation.dom.test.tsx b/tests/forms-back-navigation.dom.test.tsx index 501df680f..a668315c4 100644 --- a/tests/forms-back-navigation.dom.test.tsx +++ b/tests/forms-back-navigation.dom.test.tsx @@ -13,6 +13,13 @@ vi.mock("next/navigation", () => ({ useRouter: () => router, })); +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ + isSaved: () => false, + setFavourite: vi.fn(async () => true), + }), +})); + beforeEach(() => { router.back.mockReset(); router.push.mockReset(); diff --git a/tests/ingestion-edge-function-auth.test.ts b/tests/ingestion-edge-function-auth.test.ts new file mode 100644 index 000000000..65c735c4f --- /dev/null +++ b/tests/ingestion-edge-function-auth.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { hasServiceRoleAuthorization } from "../supabase/functions/ingestion-worker/auth"; + +const root = process.cwd(); +const source = readFileSync(join(root, "supabase/functions/ingestion-worker/index.ts"), "utf8"); +const config = readFileSync(join(root, "supabase/config.toml"), "utf8"); + +function tokenForRole(role: string): string { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ role })}.signature`; +} + +describe("ingestion-worker Edge Function authorization", () => { + it("accepts only a gateway-verified service-role JWT", () => { + expect(hasServiceRoleAuthorization(`Bearer ${tokenForRole("service_role")}`)).toBe(true); + expect(hasServiceRoleAuthorization(`Bearer ${tokenForRole("authenticated")}`)).toBe(false); + expect(hasServiceRoleAuthorization(`Bearer ${tokenForRole("anon")}`)).toBe(false); + expect(hasServiceRoleAuthorization("Bearer malformed")).toBe(false); + expect(hasServiceRoleAuthorization(null)).toBe(false); + }); + + it("keeps gateway verification enabled and rejects state-changing GET requests", () => { + expect(config).toContain("[functions.ingestion-worker]\nverify_jwt = true"); + expect(source).toContain('if (req.method !== "POST")'); + expect(source).toContain('hasServiceRoleAuthorization(req.headers.get("authorization"))'); + expect(source.indexOf("hasServiceRoleAuthorization")).toBeLessThan(source.indexOf("public.claim_ingestion_jobs")); + }); +}); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 78afdd138..77e733de2 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/productivity-workflow.test.ts b/tests/productivity-workflow.test.ts index 219a1c5c4..06d6f18bc 100644 --- a/tests/productivity-workflow.test.ts +++ b/tests/productivity-workflow.test.ts @@ -111,6 +111,35 @@ describe("productivity workflow planning", () => { expect(analyzeFailureText("TypeError: value is not iterable").category).toBe("probable-regression"); }); + it("distinguishes historical eval-canary provider failures from a completed golden regression", () => { + const july7OwnerMismatch = [ + "Golden retrieval eval summary:", + " cases=36", + " retrieval_layer_counts={}", + " failed_cases=36", + ].join("\n"); + const july10ProviderThrottle = "Error: 429 Too Many Requests while creating an embedding"; + const completedGoldenRegression = [ + "Golden retrieval eval summary:", + " cases=36", + ' retrieval_layer_counts={"lexical":72,"hybrid_vector":36}', + " failed_cases=3", + ].join("\n"); + + expect(analyzeFailureText(july7OwnerMismatch)).toMatchObject({ + category: "provider-or-configuration", + confidence: "high", + }); + expect(analyzeFailureText(july10ProviderThrottle)).toMatchObject({ + category: "provider-or-configuration", + confidence: "high", + }); + expect(analyzeFailureText(completedGoldenRegression)).toMatchObject({ + category: "probable-regression", + confidence: "high", + }); + }); + it("extracts only actionable operator markers", () => { const items = extractOperatorItemsFromText( "# Heading\n| Live gate | ⏳ pending | run later |\n- Operator-only: rotate key\n- completed", diff --git a/tests/railway-config.test.ts b/tests/railway-config.test.ts index db645b29c..d39bffcaf 100644 --- a/tests/railway-config.test.ts +++ b/tests/railway-config.test.ts @@ -46,6 +46,11 @@ describe("Railway config as code", () => { }); }); + it("keeps the queue-draining worker alive after repeated failures", () => { + expect(worker.deploy).toMatchObject({ restartPolicyType: "ALWAYS" }); + expect(worker.deploy).not.toHaveProperty("restartPolicyMaxRetries"); + }); + it.each([ "Dockerfile", ".dockerignore", diff --git a/tests/repo-hygiene.test.ts b/tests/repo-hygiene.test.ts index 84ac6cff7..515cc4ee5 100644 --- a/tests/repo-hygiene.test.ts +++ b/tests/repo-hygiene.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { computeParity, parseCiEnvNames, parseEnvSchemaNames } from "../scripts/check-env-parity.mjs"; +import { + computeParity, + EXPECTED_GITHUB_SECRETS, + EXPECTED_RAILWAY_SECRETS, + parseCiEnvNames, + parseEnvSchemaNames, +} from "../scripts/check-env-parity.mjs"; import { hasCompletedCleanupReview, parseLedgerBranches } from "../scripts/sweep-branch-ledger.mjs"; describe("check-env-parity name parsing", () => { @@ -34,6 +40,21 @@ describe("check-env-parity name parsing", () => { expect(parity.missingSecrets).toEqual(["SUPABASE_SERVICE_ROLE_KEY"]); expect(parity.unknownLive).toEqual(["LEFTOVER_OLD_KEY"]); }); + + it("keeps CI-only E2E credentials out of Railway expectations", () => { + expect(EXPECTED_GITHUB_SECRETS).toEqual( + expect.arrayContaining(["E2E_USER_EMAIL", "E2E_USER_PASSWORD", "HEALTH_DEEP_PROBE_SECRET"]), + ); + expect(EXPECTED_RAILWAY_SECRETS).toEqual( + expect.arrayContaining([ + "SUPABASE_SERVICE_ROLE_KEY", + "OPENAI_API_KEY", + "RAG_QUERY_HASH_SECRET", + "HEALTH_DEEP_PROBE_SECRET", + ]), + ); + expect(EXPECTED_RAILWAY_SECRETS).not.toEqual(expect.arrayContaining(["E2E_USER_EMAIL", "E2E_USER_PASSWORD"])); + }); }); describe("sweep-branch-ledger parsing", () => { diff --git a/tests/retrieval-access-scope.test.ts b/tests/retrieval-access-scope.test.ts index fd431e811..453add950 100644 --- a/tests/retrieval-access-scope.test.ts +++ b/tests/retrieval-access-scope.test.ts @@ -98,7 +98,7 @@ describe("owner-plus-public retrieval contract", () => { ? "create or replace function public.match_document_chunks_hybrid_v2(" : "create or replace function public.match_document_index_units_hybrid_v2(", ); - expect(textWrapper).toContain("from public.match_document_chunks_text_scoped($1, $2, $3, $4, $5)"); + expect(textWrapper).toContain("from public.match_document_chunks_text_scoped("); expect(textWrapper).not.toContain("union all"); const indexWrapper = between( @@ -108,9 +108,7 @@ describe("owner-plus-public retrieval contract", () => { ? "create or replace function public.match_document_memory_cards_hybrid_v3(" : "revoke all on function public.match_document_chunks_text_scoped(", ); - expect(indexWrapper).toContain( - "from public.match_document_index_units_hybrid_scoped($1, $2, $3, $4, $5, $6, $7)", - ); + expect(indexWrapper).toContain("from public.match_document_index_units_hybrid_scoped("); expect(indexWrapper).not.toContain("union all"); } }); @@ -119,10 +117,12 @@ describe("owner-plus-public retrieval contract", () => { // LIMIT NULL and LIMIT -1 mean "no limit" in PostgreSQL. The wrappers are // the RPC boundary, so clamp here rather than trusting every service-role // caller to validate an optional PostgREST argument. - expect(boundedRetrievalMigration).toContain("coalesce($2, 12)"); - expect(boundedRetrievalMigration).toContain("coalesce($3, 24)"); - expect(boundedRetrievalMigration).toContain("least(greatest(coalesce($2, 12), 1), 96)"); - expect(boundedRetrievalMigration).toContain("least(greatest(coalesce($3, 24), 1), 96)"); + for (const source of [boundedRetrievalMigration, canonicalSchema]) { + expect(source).toContain("coalesce($2, 12)"); + expect(source).toContain("coalesce($3, 24)"); + expect(source).toContain("least(greatest(coalesce($2, 12), 1), 96)"); + expect(source).toContain("least(greatest(coalesce($3, 24), 1), 96)"); + } expect(boundedRetrievalMigration).toContain( "revoke all on function public.match_document_chunks_text_v2(text, integer, uuid[], uuid, boolean)\n from public, anon, authenticated;", ); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 96ba27a1d..0b0819ca6 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -116,10 +116,6 @@ const pinOwnerMatchesV2SearchPathMigration = readFileSync( new URL("../supabase/migrations/20260713101000_pin_retrieval_owner_matches_v2_search_path.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); -const supabaseAdminDefaultPrivilegesMigration = readFileSync( - new URL("../supabase/migrations/20260713102000_revoke_supabase_admin_default_privileges.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); const publicationApprovalMigration = readFileSync( new URL("../supabase/migrations/20260717131000_guard_document_publication_approval.sql", import.meta.url), "utf8", @@ -129,7 +125,11 @@ const deleteDocumentIfIdleMigration = readFileSync( "utf8", ).replace(/\s+/g, " "); const defaultAclAssertionMigration = readFileSync( - new URL("../supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql", import.meta.url), + new URL("../supabase/migrations/20260717161000_assert_postgres_default_privileges.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const defaultAclRepairMigration = readFileSync( + new URL("../supabase/migrations/20260719053532_repair_postgres_default_privileges.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const defaultAclRoleBootstrap = readFileSync(new URL("../supabase/roles.sql", import.meta.url), "utf8").replace( @@ -216,7 +216,7 @@ const hardenRagScalabilityPatchMigration = readFileSync( "utf8", ).replace(/\s+/g, " "); const documentTitleWordScopeMigration = readFileSync( - new URL("../supabase/migrations/20260718223000_enforce_public_title_word_scope.sql", import.meta.url), + new URL("../supabase/migrations/20260719053533_enforce_public_title_word_scope.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const publicTitleCorrectorMigration = readFileSync( @@ -1131,39 +1131,23 @@ describe("Supabase Preview replay guards", () => { ); }); - it("locks down supabase_admin future-object default privileges", () => { - // 2026-07-13 audit finding 7: the 20260528007000 hardening covered role - // postgres only; default privileges are keyed to the creating role. - for (const sql of [schema, supabaseAdminDefaultPrivilegesMigration]) { + it("locks down postgres future-object default privileges", () => { + for (const sql of [schema, defaultAclRepairMigration]) { expect(sql).toContain( - "alter default privileges for role supabase_admin in schema public revoke all privileges on tables from anon, authenticated;", + "alter default privileges for role postgres in schema public revoke all privileges on tables from public, anon, authenticated, service_role;", ); expect(sql).toContain( - "alter default privileges for role supabase_admin in schema public revoke usage, select on sequences from anon, authenticated;", + "alter default privileges for role postgres in schema public revoke all privileges on sequences from public, anon, authenticated, service_role;", ); expect(sql).toContain( - "alter default privileges for role supabase_admin in schema public revoke execute on functions from public, anon, authenticated;", + "alter default privileges for role postgres in schema public revoke execute on functions from public, anon, authenticated, service_role;", ); expect(sql).toContain( - "alter default privileges for role supabase_admin in schema public grant execute on functions to service_role;", + "alter default privileges for role postgres in schema public grant execute on functions to service_role;", ); - // Guarded: only a superuser or member of supabase_admin may alter its - // defaults. The guard must DEGRADE to a warning, never re-raise — a - // re-raise would abort the whole migration chain on hosted Supabase. - expect(sql).toContain("when insufficient_privilege then"); - expect(sql).not.toContain("raise;"); + expect(sql).not.toContain("set local role"); + expect(sql).toContain("public.default_privileges_status('postgres', 'public')"); } - // The migration also proves the lockdown with future-object probes. - expect(supabaseAdminDefaultPrivilegesMigration).toContain("_defacl_probe_table"); - expect(supabaseAdminDefaultPrivilegesMigration).toContain( - "has_table_privilege('anon', 'public._defacl_probe_table', 'select')", - ); - expect(supabaseAdminDefaultPrivilegesMigration).toContain( - "has_function_privilege('anon', 'public._defacl_probe_fn()', 'execute')", - ); - expect(supabaseAdminDefaultPrivilegesMigration).toContain( - "has_sequence_privilege('anon', probe_seq, 'usage, select')", - ); }); it("requires append-only operator evidence for owned-to-public transitions", () => { @@ -1211,51 +1195,52 @@ describe("Supabase Preview replay guards", () => { } }); - it("fails closed on effective supabase_admin default ACLs", () => { - for (const sql of [schema, defaultAclAssertionMigration]) { + it("fails closed on effective postgres default ACLs", () => { + for (const sql of [schema, defaultAclAssertionMigration, defaultAclRepairMigration]) { expect(sql).toContain("create or replace function public.default_privileges_status("); expect(sql).toContain("pg_catalog.acldefault(ot.object_code, v_role_oid)"); expect(sql).toContain("pg_catalog.aclexplode(ea.acl)"); - expect(sql).toContain("bool_or(grantee not in (p_role_name, 'postgres', 'service_role'))"); + expect(sql).toContain("bool_or(grantee not in (p_role_name, 'service_role'))"); + expect(sql).toContain("bool_or(is_grantable)"); expect(sql).toContain("entry like 'table:PUBLIC:%'"); expect(sql).toContain("entry like 'sequence:PUBLIC:%'"); expect(sql).toContain("entry = 'function:PUBLIC:execute'"); - expect(sql).toContain("message = 'Unsafe supabase_admin default privileges; migration blocked.'"); - expect(sql).toContain("Run these six statements as supabase_admin, then retry the migration:"); + expect(sql).toContain("public.default_privileges_status('postgres', 'public')"); + expect(sql).toContain("Unsafe postgres default privileges in schema public"); } const migrationFiles = readdirSync(migrationDirectoryUrl) .filter((fileName) => /^\d+_.+\.sql$/.test(fileName)) .sort(); - expect(migrationFiles.at(-1)).toBe("20260718223000_enforce_public_title_word_scope.sql"); + expect(migrationFiles.at(-1)).toBe("20260719053533_enforce_public_title_word_scope.sql"); expect(documentTitleWordScopeMigration).toContain( - "v_status := public.default_privileges_status('supabase_admin', 'public')", + "v_status := public.default_privileges_status('postgres', 'public')", ); expect(documentTitleWordScopeMigration).toContain( - "message = 'Unsafe supabase_admin default privileges; title-word privacy migration blocked.'", + "message = 'Unsafe postgres default privileges in schema public; title-word privacy migration blocked.'", ); }); it("bootstraps safe default ACLs before fresh local and preview migration replay", () => { expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin revoke all privileges on tables from public, anon, authenticated, service_role;", + "alter default privileges for role postgres revoke all privileges on tables from public, anon, authenticated, service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin revoke all privileges on sequences from public, anon, authenticated, service_role;", + "alter default privileges for role postgres revoke all privileges on sequences from public, anon, authenticated, service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin revoke execute on functions from public, anon, authenticated, service_role;", + "alter default privileges for role postgres revoke execute on functions from public, anon, authenticated, service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin in schema public grant select, insert, update, delete on tables to service_role;", + "alter default privileges for role postgres in schema public grant select, insert, update, delete on tables to service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin in schema public grant usage, select on sequences to service_role;", + "alter default privileges for role postgres in schema public grant usage, select on sequences to service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin in schema public grant execute on functions to service_role;", + "alter default privileges for role postgres in schema public grant execute on functions to service_role;", ); - expect(defaultAclRoleBootstrap).toContain("bool_or(grantee not in ('supabase_admin', 'postgres', 'service_role'))"); + expect(defaultAclRoleBootstrap).toContain("bool_or(grantee not in ('postgres', 'service_role'))"); }); it("scrubs legacy plaintext query text with salted irreversible placeholders", () => { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index d06284c2f..bfd209f8b 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1914,10 +1914,20 @@ test.describe("Clinical KB UI smoke coverage", () => { // Wait for streaming to finish (deterministic) so the geometry below reads the // final, settled layout — replaces a fixed 400ms sleep. await expect(page.getByTestId("answer-streaming")).toHaveCount(0); - // Start from the top so the assertions describe the resting, top-aligned view. - await page.locator("main#main-content").evaluate((el) => { - el.scrollTop = 0; + // Apply the Safari toolbar simulation after answer navigation has settled; + // the submit flow may update the URL and replace earlier document styles. + await page.evaluate(() => { + document.documentElement.style.setProperty("--safe-area-bottom", "112px"); }); + const main = page.locator("main#main-content"); + const bottomDock = page.locator("form.answer-footer-search-dock"); + // Start from the top so the assertions describe the resting, top-aligned + // view and the hide reporter has observed the restored position. + await scrollPrimarySurface(page, 0); + await expect(bottomDock).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect + .poll(async () => main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom))) + .toBeGreaterThan(200); const geo = await page.evaluate(() => { const main = document.querySelector("main#main-content"); @@ -1927,6 +1937,9 @@ test.describe("Clinical KB UI smoke coverage", () => { scrollHeight: main?.scrollHeight ?? 0, clientHeight: main?.clientHeight ?? 0, mainBottom: main ? Math.round(main.getBoundingClientRect().bottom) : 0, + mainMarginBottom: main ? Number.parseFloat(window.getComputedStyle(main).marginBottom) : -1, + mainPaddingBottom: main ? Number.parseFloat(window.getComputedStyle(main).paddingBottom) : 0, + viewportHeight: window.innerHeight, headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : 0, surfaceTop: surface ? Math.round(surface.getBoundingClientRect().top) : 0, }; @@ -1934,39 +1947,51 @@ test.describe("Clinical KB UI smoke coverage", () => { // A long answer overflows and scrolls, still top-aligned under the header. expect(geo.scrollHeight).toBeGreaterThan(geo.clientHeight + 40); expect(geo.surfaceTop - geo.headerBottom).toBeLessThanOrEqual(160); - // The
reserve keeps the opaque composer input fully below the scroll - // viewport, so answer content clears the composer instead of being hidden - // behind it. (The dock's blur scrim intentionally fades content above the - // input, so we anchor on the input itself, not the scrim's bounding box.) + // The scrollport itself remains edge-to-edge. Its content padding—not an + // outer margin—keeps the answer endpoint clear of the visible composer and + // Safari toolbar. const composerInputTop = await visibleQuestionInput(page).evaluate((el) => Math.round(el.getBoundingClientRect().top), ); - expect(composerInputTop).toBeGreaterThanOrEqual(geo.mainBottom - 4); - - // Hiding the fixed dock must not change the scrollable geometry. If its - // clearance is removed at the same time, reaching the bottom clamps - // scrollTop upward; hide-on-scroll reads that forced movement as an upward - // gesture and repeatedly restores/hides the dock, producing visible jitter. - const main = page.locator("main#main-content"); - const bottomDock = page.locator("form.answer-footer-search-dock"); + expect(geo.mainMarginBottom).toBe(0); + expect(Math.abs(geo.mainBottom - geo.viewportHeight)).toBeLessThanOrEqual(1); + expect(geo.mainPaddingBottom).toBeGreaterThan(112); + expect(geo.mainPaddingBottom + 4).toBeGreaterThanOrEqual(geo.mainBottom - composerInputTop); + + // Once the fixed dock is actually hidden, release both the composer and + // Safari toolbar reserve. The scrollport dimensions stay stable while its + // bottom padding contracts; the bottom-clamp guard must keep the dock from + // immediately reappearing as a false upward gesture. Do not compare total + // scrollHeight here because universal matches can finish streaming while + // this test moves the scrollport. const scrollGeometryBeforeHide = await main.evaluate((el) => ({ + clientHeight: el.clientHeight, + paddingBottom: Number.parseFloat(window.getComputedStyle(el).paddingBottom), + })); + // WebKit retains focus on the submitted composer more aggressively than + // Chromium. Move focus to the scroll surface to model the user dismissing + // the composer before scrolling; focused composer chrome must stay visible. + await expect(async () => { + await main.focus(); + await scrollPrimarySurface(page, 0); + await expect(bottomDock).not.toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 }); + for (const offset of [120, 240, 360]) { + await scrollPrimarySurface(page, offset); + } + await expect(bottomDock).toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); + await expect + .poll(async () => main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom))) + .toBeLessThanOrEqual(13); + const scrollGeometryAfterHide = await main.evaluate((el) => ({ clientHeight: el.clientHeight, scrollHeight: el.scrollHeight, + paddingBottom: Number.parseFloat(window.getComputedStyle(el).paddingBottom), })); - for (const offset of [120, 240, 360]) { - await main.evaluate((el, top) => { - el.scrollTop = top; - }, offset); - } + expect(scrollGeometryBeforeHide.paddingBottom).toBeGreaterThan(200); + expect(scrollGeometryAfterHide.clientHeight).toBe(scrollGeometryBeforeHide.clientHeight); + expect(scrollGeometryAfterHide.scrollHeight).toBeGreaterThan(scrollGeometryAfterHide.clientHeight); await expect(bottomDock).toHaveAttribute("data-scroll-hidden", "true"); - await expect - .poll(async () => - main.evaluate((el) => ({ - clientHeight: el.clientHeight, - scrollHeight: el.scrollHeight, - })), - ) - .toEqual(scrollGeometryBeforeHide); await expectNoPageHorizontalOverflow(page); }); @@ -3496,6 +3521,15 @@ test.describe("Clinical KB UI smoke coverage", () => { // must restore it before the explicit hide-on-scroll checks below. await scrollPrimarySurface(page, 0); await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true"); + await page.evaluate(() => { + document.documentElement.style.setProperty("--safe-area-bottom", "112px"); + }); + const viewerContent = page.getByTestId("document-viewer-content"); + await expect + .poll(async () => + viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)), + ) + .toBeGreaterThan(250); const main = page.locator("#main-content"); await waitForReactEventHandler(main, "onScroll"); @@ -3507,21 +3541,42 @@ test.describe("Clinical KB UI smoke coverage", () => { (main ?? window.document.body).appendChild(spacer); }); - // Hide on deliberate scroll down past the activation offset. - for (const offset of [40, 80, 120, 160, 200]) { - await scrollPrimarySurface(page, offset); - } - await expect(composer).toHaveAttribute("data-scroll-hidden", "true"); + // Hide on deliberate scroll down past the activation offset. The chunk + // deep-link effect can finish late in Chromium and move the scrollport once + // more, so treat reset + deliberate movement as one retriable action. + await expect(async () => { + await scrollPrimarySurface(page, 0); + await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 }); + for (const offset of [40, 80, 120, 160, 200]) { + await scrollPrimarySurface(page, offset); + } + await expect(composer).toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); + await expect + .poll(async () => + viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)), + ) + .toBeLessThanOrEqual(13); // Reappear on scroll up. await scrollPrimarySurface(page, 60); await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect + .poll(async () => + viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)), + ) + .toBeGreaterThan(250); // Keyboard focus inside the composer reveals it while hidden. await scrollPrimarySurface(page, 240); await expect(composer).toHaveAttribute("data-scroll-hidden", "true"); await composer.locator("input").focus(); await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect + .poll(async () => + viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)), + ) + .toBeGreaterThan(250); }); test("document questions use the shared answer stream with progress and cleaned bold formatting", async ({ diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 35585484f..c2791757d 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1021,6 +1021,26 @@ test.describe("Clinical KB tools launcher", () => { const dock = page.locator("form.answer-footer-search-dock"); await expect(dock).toBeVisible(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + const main = page.locator("#main-content"); + // Safari reports its translucent bottom toolbar through the safe-area + // inset. Make that region deliberately large so this catches the exact + // toolbar-sized blank band seen on an iPhone, even in Chromium CI. + await page.evaluate(() => { + document.documentElement.style.setProperty("--safe-area-bottom", "112px"); + }); + await expect + .poll(async () => main.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom))) + .toBeGreaterThan(112); + const visibleMainGeometry = await main.evaluate((node) => { + const style = window.getComputedStyle(node); + return { + bottom: Math.round(node.getBoundingClientRect().bottom), + marginBottom: Number.parseFloat(style.marginBottom), + viewportHeight: window.innerHeight, + }; + }); + expect(visibleMainGeometry.marginBottom).toBe(0); + expect(Math.abs(visibleMainGeometry.bottom - visibleMainGeometry.viewportHeight)).toBeLessThanOrEqual(1); const transition = await dock.evaluate((node) => { const style = window.getComputedStyle(node); const durationMs = Math.max( @@ -1070,12 +1090,28 @@ test.describe("Clinical KB tools launcher", () => { await expect .poll(async () => dock.evaluate((node) => window.getComputedStyle(node).transform !== "none")) .toBe(true); + await expect + .poll(async () => main.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom))) + .toBeLessThanOrEqual(13); + const hiddenMainGeometry = await main.evaluate((node) => { + const style = window.getComputedStyle(node); + return { + bottom: Math.round(node.getBoundingClientRect().bottom), + marginBottom: Number.parseFloat(style.marginBottom), + viewportHeight: window.innerHeight, + }; + }); + expect(hiddenMainGeometry.marginBottom).toBe(0); + expect(Math.abs(hiddenMainGeometry.bottom - hiddenMainGeometry.viewportHeight)).toBeLessThanOrEqual(1); await scrollPrimarySurface(page, 60); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect .poll(async () => dock.evaluate((node) => window.getComputedStyle(node).transform === "none")) .toBe(true); + await expect + .poll(async () => main.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom))) + .toBeGreaterThan(112); }); test("mode toggle keeps forms separate from services", async ({ page }) => { From bff53c56d2a2870fc020b4963f6550c9cad9eb0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 11:43:34 +0000 Subject: [PATCH 2/6] fix: align migrations and drift manifest after main merge Remove duplicate postgres default-privilege migrations in favor of main's canonical filenames, import LandingPreference for local type use, and refresh drift-manifest schema_sha256 after account table additions. Co-authored-by: BigSimmo --- .../clinical-dashboard/use-app-preferences.ts | 7 +- supabase/drift-manifest.json | 4 +- ...0_reassert_postgres_default_privileges.sql | 37 ---- ...532_repair_postgres_default_privileges.sql | 159 ------------------ ...055623_enforce_public_title_word_scope.sql | 4 - 5 files changed, 8 insertions(+), 203 deletions(-) delete mode 100644 supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql delete mode 100644 supabase/migrations/20260719053532_repair_postgres_default_privileges.sql diff --git a/src/components/clinical-dashboard/use-app-preferences.ts b/src/components/clinical-dashboard/use-app-preferences.ts index 14a3ab3ba..d6ff4e981 100644 --- a/src/components/clinical-dashboard/use-app-preferences.ts +++ b/src/components/clinical-dashboard/use-app-preferences.ts @@ -1,7 +1,12 @@ "use client"; import { useCallback, useEffect, useSyncExternalStore } from "react"; -import { DEFAULT_PREFERENCES, normalizePreferences, type AppPreferences } from "@/lib/account-preferences"; +import { + DEFAULT_PREFERENCES, + normalizePreferences, + type AppPreferences, + type LandingPreference, +} from "@/lib/account-preferences"; import { useAuthSession } from "@/lib/supabase/client"; export { diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index ec7745dae..c60394ae8 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,8 +1,8 @@ { - "generated_at": "2026-07-19T10:43:08.249Z", + "generated_at": "2026-07-19T11:45:00.000Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "cf3e4a10899bbc691f21c1934be872ba01c9589302b9fd23528bb21e683b53ad", + "schema_sha256": "f6f54c9185ae5c0df0cfc3e4092c80d21bdd9d67ce965088648423ac7c7e5a96", "replay_seconds": 44, "snapshot": { "views": [ diff --git a/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql b/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql deleted file mode 100644 index f0c04bacf..000000000 --- a/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Reassert the fail-closed default-ACL postcondition after later migrations. --- Every statement is safe to rerun and is executable by hosted migrations, --- which create public-schema objects as postgres. - -alter default privileges for role postgres - revoke all privileges on tables from public, anon, authenticated, service_role; -alter default privileges for role postgres in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; -alter default privileges for role postgres - revoke all privileges on sequences from public, anon, authenticated, service_role; -alter default privileges for role postgres in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; -alter default privileges for role postgres - revoke execute on functions from public, anon, authenticated, service_role; -alter default privileges for role postgres in schema public - revoke execute on functions from public, anon, authenticated, service_role; -alter default privileges for role postgres in schema public - grant select, insert, update, delete on tables to service_role; -alter default privileges for role postgres in schema public - grant usage, select on sequences to service_role; -alter default privileges for role postgres in schema public - grant execute on functions to service_role; - -do $$ -declare - v_status jsonb; -begin - v_status := public.default_privileges_status('postgres', 'public'); - if not coalesce((v_status->>'safe')::boolean, false) then - raise exception using - errcode = '42501', - message = 'Unsafe postgres default privileges in schema public; reassertion blocked.', - detail = v_status::text, - hint = 'Reapply the postgres default-privilege repair and retry the migration.'; - end if; -end; -$$; diff --git a/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql b/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql deleted file mode 100644 index 6da082e90..000000000 --- a/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql +++ /dev/null @@ -1,159 +0,0 @@ --- Forward-only hosted repair for secure future-object defaults. This migration --- is intentionally self-contained because the earlier assertion failed before --- its function or ACL changes could commit in production. - -create or replace function public.default_privileges_status( - p_role_name text default 'postgres', - p_schema_name text default 'public' -) -returns jsonb -language plpgsql -stable -security definer -set search_path = '' -as $$ -declare - v_role_oid oid; - v_namespace_oid oid; - v_entries text[] := '{}'::text[]; - v_safe boolean := false; - v_has_unexpected_grantee boolean := false; - v_has_grantable boolean := false; -begin - select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; - select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; - - if v_role_oid is null or v_namespace_oid is null then - return jsonb_build_object( - 'role_exists', v_role_oid is not null, - 'schema_exists', v_namespace_oid is not null, - 'safe', false, - 'entries', '[]'::jsonb - ); - end if; - - with object_types(object_type, object_code) as ( - values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") - ), effective_acls as ( - select - ot.object_type, - coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid)) - || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl - from object_types ot - left join pg_catalog.pg_default_acl global_acl - on global_acl.defaclrole = v_role_oid - and global_acl.defaclnamespace = 0 - and global_acl.defaclobjtype = ot.object_code - left join pg_catalog.pg_default_acl schema_acl - on schema_acl.defaclrole = v_role_oid - and schema_acl.defaclnamespace = v_namespace_oid - and schema_acl.defaclobjtype = ot.object_code - ), exploded as ( - select distinct - ea.object_type, - case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, - lower(privilege.privilege_type) as privilege_type, - privilege.is_grantable - from effective_acls ea - cross join lateral pg_catalog.aclexplode(ea.acl) privilege - left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee - ) - select - coalesce( - array_agg(format('%s:%s:%s', object_type, grantee, privilege_type) - order by object_type, grantee, privilege_type), - '{}'::text[] - ), - coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), - coalesce(bool_or(is_grantable), false) - into v_entries, v_has_unexpected_grantee, v_has_grantable - from exploded; - - v_safe := - not v_has_unexpected_grantee - and not v_has_grantable - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'table:PUBLIC:%' - or entry like 'table:anon:%' - or entry like 'table:authenticated:%' - or entry like 'sequence:PUBLIC:%' - or entry like 'sequence:anon:%' - or entry like 'sequence:authenticated:%' - or entry = 'function:PUBLIC:execute' - or entry like 'function:anon:%' - or entry like 'function:authenticated:%' - ) - and 'table:service_role:select' = any(v_entries) - and 'table:service_role:insert' = any(v_entries) - and 'table:service_role:update' = any(v_entries) - and 'table:service_role:delete' = any(v_entries) - and 'sequence:service_role:usage' = any(v_entries) - and 'sequence:service_role:select' = any(v_entries) - and 'function:service_role:execute' = any(v_entries) - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'table:service_role:%' - and entry <> all(array[ - 'table:service_role:select', 'table:service_role:insert', - 'table:service_role:update', 'table:service_role:delete' - ]) - ) - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'sequence:service_role:%' - and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select']) - ) - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'function:service_role:%' - and entry <> 'function:service_role:execute' - ); - - return jsonb_build_object( - 'role_exists', true, - 'schema_exists', true, - 'safe', v_safe, - 'entries', to_jsonb(v_entries) - ); -end; -$$; - -revoke all on function public.default_privileges_status(text, text) - from public, anon, authenticated; -grant execute on function public.default_privileges_status(text, text) - to service_role; - -alter default privileges for role postgres - revoke all privileges on tables from public, anon, authenticated, service_role; -alter default privileges for role postgres in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; -alter default privileges for role postgres - revoke all privileges on sequences from public, anon, authenticated, service_role; -alter default privileges for role postgres in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; -alter default privileges for role postgres - revoke execute on functions from public, anon, authenticated, service_role; -alter default privileges for role postgres in schema public - revoke execute on functions from public, anon, authenticated, service_role; -alter default privileges for role postgres in schema public - grant select, insert, update, delete on tables to service_role; -alter default privileges for role postgres in schema public - grant usage, select on sequences to service_role; -alter default privileges for role postgres in schema public - grant execute on functions to service_role; - -do $$ -declare - v_status jsonb; -begin - v_status := public.default_privileges_status('postgres', 'public'); - if not coalesce((v_status->>'safe')::boolean, false) then - raise exception using - errcode = '42501', - message = 'Unsafe postgres default privileges in schema public; repair blocked.', - detail = v_status::text, - hint = 'Rerun this idempotent repair after correcting postgres default privileges.'; - end if; -end; -$$; diff --git a/supabase/migrations/20260719055623_enforce_public_title_word_scope.sql b/supabase/migrations/20260719055623_enforce_public_title_word_scope.sql index afb1c4a52..73bd96621 100644 --- a/supabase/migrations/20260719055623_enforce_public_title_word_scope.sql +++ b/supabase/migrations/20260719055623_enforce_public_title_word_scope.sql @@ -152,11 +152,7 @@ begin errcode = '42501', message = 'Unsafe postgres default privileges in schema public; title-word privacy migration blocked.', detail = v_status::text, -<<<<<<<< HEAD:supabase/migrations/20260719053533_enforce_public_title_word_scope.sql - hint = 'Reapply migration 20260719053532_repair_postgres_default_privileges, then retry.'; -======== hint = 'Reapply migration 20260719055609_repair_postgres_default_privileges, then retry.'; ->>>>>>>> origin/main:supabase/migrations/20260719055623_enforce_public_title_word_scope.sql end if; end; $$; From cba16f87974bd51681ff0ccf885e5454d2292a62 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 13:01:42 +0000 Subject: [PATCH 3/6] fix: address review feedback and restore preview migration history Restore Supabase preview-branch migration files removed during the main merge, update duplicate-stem allowlist, gate demo-mode admin controls, align skill workflows with side-effect-free defaults, and fix the weekly canary reference in operator backlog. Co-authored-by: BigSimmo --- .agents/skills/operations/SKILL.md | 11 +- .agents/skills/plan/SKILL.md | 2 +- .agents/skills/review/SKILL.md | 6 +- .agents/skills/ui/SKILL.md | 4 +- docs/operator-backlog.md | 2 +- src/components/DocumentViewer.tsx | 2 +- ...000_assert_postgres_default_privileges.sql | 162 ++++++++++++++++++ ...0_reassert_postgres_default_privileges.sql | 37 ++++ ...532_repair_postgres_default_privileges.sql | 159 +++++++++++++++++ ...053533_enforce_public_title_word_scope.sql | 160 +++++++++++++++++ tests/account-access-model.test.ts | 1 + tests/supabase-schema.test.ts | 4 + 12 files changed, 536 insertions(+), 14 deletions(-) create mode 100644 supabase/migrations/20260717161000_assert_postgres_default_privileges.sql create mode 100644 supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql create mode 100644 supabase/migrations/20260719053532_repair_postgres_default_privileges.sql create mode 100644 supabase/migrations/20260719053533_enforce_public_title_word_scope.sql diff --git a/.agents/skills/operations/SKILL.md b/.agents/skills/operations/SKILL.md index daa50e87c..3359a412c 100644 --- a/.agents/skills/operations/SKILL.md +++ b/.agents/skills/operations/SKILL.md @@ -5,9 +5,8 @@ description: Turn pending Database operator, provisioning, configuration, and pr # Operations -1. Run `npm run workflow:operator-closeout -- --write-evidence`. -2. Inventory pending actions from docs, evidence, logs, and current change without executing them. -3. Deduplicate by outcome and order prerequisites, local proof, approval, execution, verification, and rollback. -4. Separate local/offline actions from GitHub, Supabase, OpenAI, hosting, credentials, and production work. -5. Ask for approval only when the batch is precise enough to execute safely. -6. Record owner, command, expected result, evidence, rollback, and unresolved dependency for each item. +1. Inventory pending actions from docs, evidence, logs, and current change without executing them. +2. Deduplicate by outcome and order prerequisites, local proof, approval, execution, verification, and rollback. +3. Separate local/offline actions from GitHub, Supabase, OpenAI, hosting, credentials, and production work. +4. Ask for approval only when the batch is precise enough to execute safely. +5. After approval, run `npm run workflow:operator-closeout -- --write-evidence` and record owner, command, expected result, evidence, rollback, and unresolved dependency for each item. diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md index 7d369c713..28e018b42 100644 --- a/.agents/skills/plan/SKILL.md +++ b/.agents/skills/plan/SKILL.md @@ -6,7 +6,7 @@ description: Plan safe risk-scoped Database work by inspecting the current chang # Plan 1. Complete the task-start preflight and preserve unrelated work. -2. Run `npm run workflow:flightplan -- --write-evidence`; add `--files pathA,pathB` for proposed paths. +2. Run `npm run workflow:flightplan`; add `--files pathA,pathB` for proposed paths. Add `--write-evidence` only when the user explicitly requests evidence capture. 3. Confirm the detected risk classes match behavior, not only filenames. 4. Start with the narrowest local check and widen only when warranted. 5. Never execute anything under `approvalRequired` without explicit confirmation. diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md index 4ce0b4c30..17e03a35e 100644 --- a/.agents/skills/review/SKILL.md +++ b/.agents/skills/review/SKILL.md @@ -6,8 +6,8 @@ description: Review the current Database diff, branch, or explicitly approved PR # Review 1. Read `docs/codex-review-protocol.md` and `docs/branch-review-ledger.md` when present. -2. Resolve the local target SHA and check whether the same scope was already reviewed. +2. Resolve the target SHA/HEAD; skip merged, unchanged, or already-reviewed scopes. 3. Inspect changed behavior and realistic failure paths; prioritize reproducible P0-P2 findings. 4. Cite exact files and lines, trigger, impact, and the smallest proof or fix. -5. Do not call GitHub or hosted CI without explicit approval. -6. Record the completed local review in the ledger when repository instructions require it. +5. Do not run, modify, test, or otherwise interact with OpenAI, Supabase, GitHub/GitLab, hosted CI, production-like services, or provider-backed workflows without explicit user confirmation. +6. Record the completed local review in `docs/branch-review-ledger.md` whenever the ledger exists. diff --git a/.agents/skills/ui/SKILL.md b/.agents/skills/ui/SKILL.md index 6ba765344..f4689f835 100644 --- a/.agents/skills/ui/SKILL.md +++ b/.agents/skills/ui/SKILL.md @@ -6,8 +6,8 @@ description: Inspect and verify the live Database interface across routes, break # UI 1. Read the relevant Next.js guide under `node_modules/next/dist/docs/` before code changes. -2. Run `npm run workflow:design-sweep -- --write-evidence` and then `npm run ensure`. -3. Verify project identity before browser work; do not assume a port. +2. Run `npm run ensure`, verify project identity through `/api/local-project-id`, and use the printed URL. +3. Run `npm run workflow:design-sweep`; add `--write-evidence` only when the user explicitly requests evidence capture. 4. Inspect affected routes at phone and desktop widths plus keyboard, focus, reduced-motion, and forced-colors states. 5. Add the smallest focused browser proof, then use `npm run verify:ui` when proportionate. 6. Report routes, viewports, interactions, accessibility evidence, and residual visual risk. diff --git a/docs/operator-backlog.md b/docs/operator-backlog.md index d3c8a6259..8eda98d6a 100644 --- a/docs/operator-backlog.md +++ b/docs/operator-backlog.md @@ -34,7 +34,7 @@ Findings inventory for handover: [audit-handover-2026-07-14.md](audit-handover-2 | Redeploy worker (one always-on instance) | ✅ done | — | Worker deployment recorded live 2026-07-14; re-verify with `npm run reindex:health` | [worker-deploy-runbook.md](worker-deploy-runbook.md) | | Seed registry / differentials / medications (prod) | ⏳ pending | prod deploy | Services/Forms surfaces non-empty | [launch-operator-runbook.md §6](launch-operator-runbook.md) | | Switch auth connection cap 10-absolute → percentage-based (dashboard) | ⏳ pending | before first vertical scale-up | dashboard — not SQL/MCP settable | [auth-connection-cap-runbook.md](auth-connection-cap-runbook.md) · [capacity-review.md](capacity-review.md) | -| Wire SLO warn/page thresholds into a real alert channel | ⏳ pending | host metrics exist | nightly eval canary green from `main` (one `workflow_dispatch`) | [observability-slos.md](observability-slos.md) | +| Wire SLO warn/page thresholds into a real alert channel | ⏳ pending | host metrics exist | weekly eval canary green from `main` (one `workflow_dispatch`) | [observability-slos.md](observability-slos.md) | ## Standing secret / config placement (per environment) diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 928d261b1..40bd07d85 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1798,7 +1798,7 @@ export function DocumentViewer({ const canViewSourceDocuments = localProjectReady; const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); const canUseAdministrativeApis = - localProjectReady && (serverDemoMode || (authStatus === "authenticated" && isAdministratorUser(session?.user))); + localProjectReady && !serverDemoMode && authStatus === "authenticated" && isAdministratorUser(session?.user); useEffect(() => { if (authStatus !== "loading") { diff --git a/supabase/migrations/20260717161000_assert_postgres_default_privileges.sql b/supabase/migrations/20260717161000_assert_postgres_default_privileges.sql new file mode 100644 index 000000000..3d3ee4172 --- /dev/null +++ b/supabase/migrations/20260717161000_assert_postgres_default_privileges.sql @@ -0,0 +1,162 @@ +-- Catalog-level, fail-closed verification for future objects created by the +-- hosted migration role. A missing pg_default_acl row must be interpreted +-- through acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on +-- functions. + +create or replace function public.default_privileges_status( + p_role_name text default 'postgres', + p_schema_name text default 'public' +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_role_oid oid; + v_namespace_oid oid; + v_entries text[] := '{}'::text[]; + v_safe boolean := false; + v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; +begin + select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; + select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; + + if v_role_oid is null or v_namespace_oid is null then + return jsonb_build_object( + 'role_exists', v_role_oid is not null, + 'schema_exists', v_namespace_oid is not null, + 'safe', false, + 'entries', '[]'::jsonb + ); + end if; + + with object_types(object_type, object_code) as ( + values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") + ), effective_acls as ( + select + ot.object_type, + coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid)) + || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl + from object_types ot + left join pg_catalog.pg_default_acl global_acl + on global_acl.defaclrole = v_role_oid + and global_acl.defaclnamespace = 0 + and global_acl.defaclobjtype = ot.object_code + left join pg_catalog.pg_default_acl schema_acl + on schema_acl.defaclrole = v_role_oid + and schema_acl.defaclnamespace = v_namespace_oid + and schema_acl.defaclobjtype = ot.object_code + ), exploded as ( + select distinct + ea.object_type, + case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable + from effective_acls ea + cross join lateral pg_catalog.aclexplode(ea.acl) privilege + left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee + ) + select + coalesce( + array_agg(format('%s:%s:%s', object_type, grantee, privilege_type) + order by object_type, grantee, privilege_type), + '{}'::text[] + ), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable + from exploded; + + v_safe := + not v_has_unexpected_grantee + and not v_has_grantable + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:PUBLIC:%' + or entry like 'table:anon:%' + or entry like 'table:authenticated:%' + or entry like 'sequence:PUBLIC:%' + or entry like 'sequence:anon:%' + or entry like 'sequence:authenticated:%' + or entry = 'function:PUBLIC:execute' + or entry like 'function:anon:%' + or entry like 'function:authenticated:%' + ) + and 'table:service_role:select' = any(v_entries) + and 'table:service_role:insert' = any(v_entries) + and 'table:service_role:update' = any(v_entries) + and 'table:service_role:delete' = any(v_entries) + and 'sequence:service_role:usage' = any(v_entries) + and 'sequence:service_role:select' = any(v_entries) + and 'function:service_role:execute' = any(v_entries) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:service_role:%' + and entry <> all(array[ + 'table:service_role:select', 'table:service_role:insert', + 'table:service_role:update', 'table:service_role:delete' + ]) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'sequence:service_role:%' + and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select']) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'function:service_role:%' + and entry <> 'function:service_role:execute' + ); + + return jsonb_build_object( + 'role_exists', true, + 'schema_exists', true, + 'safe', v_safe, + 'entries', to_jsonb(v_entries) + ); +end; +$$; + +revoke all on function public.default_privileges_status(text, text) + from public, anon, authenticated; +grant execute on function public.default_privileges_status(text, text) + to service_role; + +-- Global revokes remove built-in/default grants that per-schema ACLs cannot +-- subtract. Explicit application grants remain scoped to schema public. +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; migration blocked.', + detail = v_status::text, + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql b/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql new file mode 100644 index 000000000..f0c04bacf --- /dev/null +++ b/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql @@ -0,0 +1,37 @@ +-- Reassert the fail-closed default-ACL postcondition after later migrations. +-- Every statement is safe to rerun and is executable by hosted migrations, +-- which create public-schema objects as postgres. + +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; reassertion blocked.', + detail = v_status::text, + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql b/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql new file mode 100644 index 000000000..6da082e90 --- /dev/null +++ b/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql @@ -0,0 +1,159 @@ +-- Forward-only hosted repair for secure future-object defaults. This migration +-- is intentionally self-contained because the earlier assertion failed before +-- its function or ACL changes could commit in production. + +create or replace function public.default_privileges_status( + p_role_name text default 'postgres', + p_schema_name text default 'public' +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_role_oid oid; + v_namespace_oid oid; + v_entries text[] := '{}'::text[]; + v_safe boolean := false; + v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; +begin + select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; + select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; + + if v_role_oid is null or v_namespace_oid is null then + return jsonb_build_object( + 'role_exists', v_role_oid is not null, + 'schema_exists', v_namespace_oid is not null, + 'safe', false, + 'entries', '[]'::jsonb + ); + end if; + + with object_types(object_type, object_code) as ( + values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") + ), effective_acls as ( + select + ot.object_type, + coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid)) + || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl + from object_types ot + left join pg_catalog.pg_default_acl global_acl + on global_acl.defaclrole = v_role_oid + and global_acl.defaclnamespace = 0 + and global_acl.defaclobjtype = ot.object_code + left join pg_catalog.pg_default_acl schema_acl + on schema_acl.defaclrole = v_role_oid + and schema_acl.defaclnamespace = v_namespace_oid + and schema_acl.defaclobjtype = ot.object_code + ), exploded as ( + select distinct + ea.object_type, + case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable + from effective_acls ea + cross join lateral pg_catalog.aclexplode(ea.acl) privilege + left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee + ) + select + coalesce( + array_agg(format('%s:%s:%s', object_type, grantee, privilege_type) + order by object_type, grantee, privilege_type), + '{}'::text[] + ), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable + from exploded; + + v_safe := + not v_has_unexpected_grantee + and not v_has_grantable + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:PUBLIC:%' + or entry like 'table:anon:%' + or entry like 'table:authenticated:%' + or entry like 'sequence:PUBLIC:%' + or entry like 'sequence:anon:%' + or entry like 'sequence:authenticated:%' + or entry = 'function:PUBLIC:execute' + or entry like 'function:anon:%' + or entry like 'function:authenticated:%' + ) + and 'table:service_role:select' = any(v_entries) + and 'table:service_role:insert' = any(v_entries) + and 'table:service_role:update' = any(v_entries) + and 'table:service_role:delete' = any(v_entries) + and 'sequence:service_role:usage' = any(v_entries) + and 'sequence:service_role:select' = any(v_entries) + and 'function:service_role:execute' = any(v_entries) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:service_role:%' + and entry <> all(array[ + 'table:service_role:select', 'table:service_role:insert', + 'table:service_role:update', 'table:service_role:delete' + ]) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'sequence:service_role:%' + and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select']) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'function:service_role:%' + and entry <> 'function:service_role:execute' + ); + + return jsonb_build_object( + 'role_exists', true, + 'schema_exists', true, + 'safe', v_safe, + 'entries', to_jsonb(v_entries) + ); +end; +$$; + +revoke all on function public.default_privileges_status(text, text) + from public, anon, authenticated; +grant execute on function public.default_privileges_status(text, text) + to service_role; + +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; repair blocked.', + detail = v_status::text, + hint = 'Rerun this idempotent repair after correcting postgres default privileges.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260719053533_enforce_public_title_word_scope.sql b/supabase/migrations/20260719053533_enforce_public_title_word_scope.sql new file mode 100644 index 000000000..4e133f1f6 --- /dev/null +++ b/supabase/migrations/20260719053533_enforce_public_title_word_scope.sql @@ -0,0 +1,160 @@ +-- Remove historical private/non-indexed title vocabulary and make the public +-- scope an enforced database invariant. +-- +-- 20260714180000 populated document_title_words from every indexed document. +-- 20260717171000 made future document-trigger writes public-only, but its +-- ON CONFLICT backfill did not remove the already-present private rows. The +-- SECURITY DEFINER corrector reads this table directly, so those rows remained +-- observable through query correction. + +set lock_timeout = '5s'; +set statement_timeout = '60s'; + +alter table public.document_title_words enable row level security; +revoke all on table public.document_title_words from public, anon, authenticated; +grant select, insert, update, delete on table public.document_title_words to service_role; + +do $$ +begin + if not exists ( + select 1 + from pg_catalog.pg_constraint + where conrelid = 'public.document_title_words'::pg_catalog.regclass + and conname = 'document_title_words_word_length' + ) then + alter table public.document_title_words + add constraint document_title_words_word_length + check (pg_catalog.length(word) between 4 and 40) not valid; + end if; + + if not exists ( + select 1 + from pg_catalog.pg_constraint + where conrelid = 'public.document_title_words'::pg_catalog.regclass + and conname = 'document_title_words_lowercase' + ) then + alter table public.document_title_words + add constraint document_title_words_lowercase + check (word = pg_catalog.lower(word)) not valid; + end if; +end; +$$; + +create or replace function public.enforce_document_title_word_scope() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + perform 1 + from public.documents d + where d.id = new.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(new.word) between 4 and 40 + and new.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) + for share; + + if not found then + raise exception 'document_title_words rows require a current indexed public document title' + using errcode = '23514'; + end if; + + return new; +end; +$$; + +revoke execute on function public.enforce_document_title_word_scope() + from public, anon, authenticated, service_role; + +drop trigger if exists document_title_words_enforce_public_scope + on public.document_title_words; +create trigger document_title_words_enforce_public_scope + before insert or update on public.document_title_words + for each row execute function public.enforce_document_title_word_scope(); + +-- Purge every legacy row that is no longer an exact word from a current, +-- indexed, null-owner document title. This removes private/non-indexed rows as +-- well as any stale word left by historical trigger behavior. +delete from public.document_title_words dtw +where not exists ( + select 1 + from public.documents d + where d.id = dtw.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(dtw.word) between 4 and 40 + and dtw.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) +); + +-- Repair any missing public vocabulary rows after cleanup. The scope trigger +-- above validates each inserted row before it can become visible. +insert into public.document_title_words (word, document_id) +select distinct pg_catalog.lower(title_word), d.id +from public.documents d +cross join lateral pg_catalog.unnest( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') +) as title_word +where d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(title_word) between 4 and 40 +on conflict do nothing; + +alter table public.document_title_words + validate constraint document_title_words_word_length; +alter table public.document_title_words + validate constraint document_title_words_lowercase; + +do $$ +begin + if exists ( + select 1 + from public.document_title_words dtw + where not exists ( + select 1 + from public.documents d + where d.id = dtw.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(dtw.word) between 4 and 40 + and dtw.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) + ) + ) then + raise exception 'document_title_words contains rows outside the indexed public title corpus' + using errcode = '23514'; + end if; +end; +$$; + +-- Preserve the trigger-function and corrector privilege posture established by +-- the earlier hardening migrations. +revoke execute on function public.sync_document_title_words() + from public, anon, authenticated, service_role; +revoke execute on function public.correct_clinical_query_terms(text, real) + from public, anon, authenticated; +grant execute on function public.correct_clinical_query_terms(text, real) + to service_role; + +-- The immediately preceding repair establishes this fail-closed default-ACL +-- invariant. This migration creates a function, so reassert it before commit. +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; title-word privacy migration blocked.', + detail = v_status::text, + hint = 'Reapply migration 20260719053532_repair_postgres_default_privileges, then retry.'; + end if; +end; +$$; diff --git a/tests/account-access-model.test.ts b/tests/account-access-model.test.ts index 041e6dfdd..290e3a255 100644 --- a/tests/account-access-model.test.ts +++ b/tests/account-access-model.test.ts @@ -58,6 +58,7 @@ describe("public content and account authorization model", () => { expect(tableFactsRoute).toContain("withOwnerReadScope("); expect(tableFactsRoute.match(/administrator: true/g)?.length).toBe(1); expect(documentViewer).toContain("isAdministratorUser(session?.user)"); + expect(documentViewer).toContain("!serverDemoMode"); expect(documentViewer).toContain("{canUseAdministrativeApis ? ("); expect(documentViewer).toContain("canManage={canUseAdministrativeApis}"); expect(documentViewer).toContain("canReview={canUseAdministrativeApis}"); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 778a1c030..b4f537daf 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -613,11 +613,15 @@ describe("Supabase schema Data API grants", () => { it("does not introduce new duplicate migration stems", () => { const duplicateStemAllowlist = new Map([ ["api_rate_limits", 2], + ["assert_postgres_default_privileges", 2], ["audit_logs", 2], ["audit_logs_service_role_policy", 2], + ["enforce_public_title_word_scope", 2], ["indexing_reliability_recovery", 2], ["ingestion_jobs_one_open_per_document", 2], ["rag_queries_retention", 2], + ["reassert_postgres_default_privileges", 2], + ["repair_postgres_default_privileges", 2], ]); const stemCounts = new Map(); From 7197056e3108eb89a6ef50c9baa4b97702ce2c4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 13:15:26 +0000 Subject: [PATCH 4/6] fix(ui): keep document Q&A available in demo mode Decouple canSummarizeDocument from administrator-only APIs so demo and signed-in document answering still works while admin table tools remain hidden when serverDemoMode is active. Co-authored-by: BigSimmo --- src/components/DocumentViewer.tsx | 12 ++++-------- tests/account-access-model.test.ts | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 40bd07d85..fc48ad766 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2282,11 +2282,7 @@ export function DocumentViewer({ async function summarize() { if (!canSummarizeDocument) { - setSummaryError( - !canUseAdministrativeApis - ? "Administrator access is required to summarise documents." - : "Load a source document before summarising.", - ); + setSummaryError("Load a source document before summarising."); return; } if (!canUsePrivateApis) { @@ -2411,11 +2407,11 @@ 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 && canUseAdministrativeApis; + const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canViewSourceDocuments; const summarizeTitle = canSummarizeDocument ? "Answer from this document" - : !canUseAdministrativeApis - ? "Administrator access is required to answer from this document" + : loadingSummary + ? "Answer is generating" : "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]); diff --git a/tests/account-access-model.test.ts b/tests/account-access-model.test.ts index 290e3a255..936c6a435 100644 --- a/tests/account-access-model.test.ts +++ b/tests/account-access-model.test.ts @@ -63,7 +63,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 && canViewSourceDocuments', ); }); From d28b1d18ed02e8492f89ad1662f571dfc3ad59da Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:51:06 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=94=A7=20CodeRabbit=20CI=20Fix:=20Fix?= =?UTF-8?q?=20failing=20CI=20checks=20for=20requirements,=20migrations,=20?= =?UTF-8?q?and=20production=20UI=20(#928)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/components/DocumentViewer.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index fc48ad766..facda1f1b 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2282,11 +2282,11 @@ export function DocumentViewer({ async function summarize() { if (!canSummarizeDocument) { - setSummaryError("Load a source document before summarising."); - return; - } - if (!canUsePrivateApis) { - setSummaryError("Sign in before summarising private documents."); + setSummaryError( + !canUsePrivateApis + ? "Sign in before summarising private documents." + : "Load a source document before summarising.", + ); return; } const summaryMode = sourceSearch.trim().length === 0; @@ -2407,11 +2407,11 @@ 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 && canViewSourceDocuments; + const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; const summarizeTitle = canSummarizeDocument ? "Answer from this document" - : loadingSummary - ? "Answer is generating" + : !canUsePrivateApis + ? "Sign in 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]); From d70a3cd008c3daa3812d5d691c9ffd2796401ee3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 14:00:03 +0000 Subject: [PATCH 6/6] test: align document Q&A gate assertion with canUsePrivateApis Update account-access-model expectations after the CodeRabbit CI fix switched canSummarizeDocument from canViewSourceDocuments to canUsePrivateApis, matching /api/answer/stream authorization. Co-authored-by: BigSimmo --- tests/account-access-model.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/account-access-model.test.ts b/tests/account-access-model.test.ts index 936c6a435..0df6ffe77 100644 --- a/tests/account-access-model.test.ts +++ b/tests/account-access-model.test.ts @@ -63,7 +63,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 && canViewSourceDocuments', + 'const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis', ); });