diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d02ec6290..a955cb507 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -327,11 +327,27 @@ jobs: docker load -i /tmp/supabase-docker-cache/images.tar fi - - name: Start Supabase Local Emulator - run: supabase start + - name: Start Supabase Local Emulator and bootstrap default ACLs + run: | + bootstrap_dir="$(mktemp -d)" + mv supabase/roles.sql "${bootstrap_dir}/roles.sql" + mv supabase/migrations "${bootstrap_dir}/migrations" + mkdir supabase/migrations + + supabase start + + rmdir supabase/migrations + mv "${bootstrap_dir}/migrations" supabase/migrations + mv "${bootstrap_dir}/roles.sql" supabase/roles.sql + + 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 \ + < supabase/roles.sql - name: Verify Migration Replay - run: supabase db reset + run: supabase migration up --local - name: Save Supabase Docker images if: success() && steps.supabase-docker-cache.outputs.cache-hit != 'true' diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index fff042348..8e04cd332 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -486,6 +486,7 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-14 | PR #655 / codex/release-blocker-remediation | 3b152ed1f2f4f08b5672adaf0dc3b433f8ba8db1 + reviewed follow-up diff | final review-thread and release-readiness follow-up | Confirmed and fixed one P1 maintenance-path tenancy defect: registry embedding metadata refreshes could re-private public registry documents. The refresh now preserves public/owner scope, keeps generated intent-label ownership aligned, is idempotent, and rejects foreign-owner documents. Three scoped P2 review items were also resolved: answer-owner ref mutation moved out of render, PDF page changes use router navigation without scroll reset, and the worker-free staging harness no longer enqueues a reindex job before cleanup. No other high-confidence issue remained in the reviewed follow-up diff. | GitHub review-thread inspection; bundled Next.js navigation guide; focused Vitest 38/38; scoped ESLint; Prettier; full TypeScript; `git diff --check`. Final-head hosted CI, staging evidence, and provider-free production governance gates remain required after push. | | 2026-07-14 | PR #655 / codex/release-blocker-remediation | 978d4f462fcdd4f665060bfc86ed62d8617751cb + reviewed follow-up diff | final automated-review disposition | Fixed the remaining valid review findings: offline evaluation now excludes forced-vector fixtures and owns provider-mode selection; registry detection is shared; staging Supabase calls are bounded; retrieval is covered by a request-start deadline; deadline-expired answers are not cached; and registry label reconciliation preserves reviewer metadata and confidence while refreshing generator-owned metadata. The unsupported-related-document deadline finding was not applicable because the configured unsupported route budget is intentionally `0` and creates no deadline. | GitHub review-thread inspection; focused Vitest 58/58; scoped ESLint; Prettier; full TypeScript; `git diff --check`. Flaky aggregate browser/local suites intentionally not repeated; final-head hosted CI and staging evidence remain required. | | 2026-07-14 | PR #655 / codex/release-blocker-remediation | dedb38a4a1bb05f87b94a89f5cade7b4a8109c99 + reviewed follow-up diff | late automated-review safety follow-up | Fixed two newly raised scoped issues: provider-free governance now rejects public differential projections while continuing to allow the three intentionally public registry kinds, and owner-scoped answer-thread clearing also removes the unscoped legacy session/local key so old clinical text is not retained. | GitHub review-thread inspection; focused Vitest 13/13; scoped ESLint; Prettier; `git diff --check`. | +| 2026-07-14 | detached review worktree (origin/main at scan start) | 0696ded585ff9611e5a1325bc6e6c85d0c74bb50 | repository-wide comprehensive and security audit | Eight security findings survived final calibration: two P2 (uncapped full-page PDF OCR raster; broad indexed-document public promotion) and six P3 (PDF deadline and aggregate budgets, delete/reindex lifecycle race, staged-generation document search, cross-tenant correction vocabulary, and fail-open future-object default privileges). Seven additional candidates were rejected. One P2 operational documentation drift was also confirmed between the operator backlog and launch runbook. No source fix was applied. | Codex Security standard scan completed with 763 ranked source-like files and 70 deep-reviewed selections; seven safe offline finding probes passed; local action-pin, documentation-link/script-reference, codebase-index, CI-scope, and autofix-workflow guards passed; offline npm audit reported zero cached advisories; `git diff --check` passed. Lint, typecheck, full tests, build, browser, live Supabase/OpenAI, hosted CI, drift, and production checks were not run because this checkout lacked `node_modules` and provider access was not authorized. | | 2026-07-14 | PR #655 / codex/release-blocker-remediation | 1ece1b891ed2d7f9577a060ae481e33cd4925ea4 + reviewed follow-up diff | offline release latency follow-up | The offline quality release gate reproducibly isolated one live timeout: a generic agitation table-lookup question was expanded into an unnecessary ten-term dose/route AND query, making lexical retrieval take about 39 seconds. Dose/route expansion now requires an actual dosing/route signal; the same case retrieves its expected source and four citations in 1.27 seconds without a model. | Focused clinical-search/retrieval Vitest 111/111; scoped ESLint; Prettier; `git diff --check`; live provider-free single-case quality eval passed with zero model/request/token/cost/generation evidence. | | 2026-07-14 | PR #655 / codex/release-blocker-remediation | 3ed3a7a2df37d7d15143ab7606e5748ac7ecca09 + reviewed follow-up diff | offline dose-route latency follow-up | The next isolated timeout was a short IM/PO agitation question receiving the same blanket ten-term AND expansion. Agitation dose/route retrieval now keeps only the dose and route signals present in the question; the exact case retrieves its expected source and five citations in 1.54 seconds without a model. All remaining RAG cases 23–44 passed individually, so no further deadline crash remains. | Focused clinical-search/retrieval Vitest 111/111; scoped ESLint; Prettier; `git diff --check`; live provider-free case 22 passed; live provider-free cases 23–44 passed individually. | | 2026-07-14 | PR #655 / codex/release-blocker-remediation | a3f3a89676015cd5f018c07e8c3ad9483f91cef6 + reviewed follow-up diff | offline adversarial-latency follow-up | The final blocking offline-quality failure was an adversarial secret-exfiltration query that correctly refused but first spent about 25 seconds in lexical retrieval. Adversarial manipulation now short-circuits at the search boundary before provider-client creation, cache access, classification, aliases, or Supabase work, and is never cached. | Focused Vitest 2/2; scoped ESLint; Prettier; full TypeScript; `git diff --check`; live provider-free adversarial case completed in 100 ms with 0 ms RPC time; `eval:quality:release:offline` passed with zero blocking failures and zero model, request-ID, token, cost, or generation-latency evidence. Flaky local browser/composite suites intentionally not repeated; hosted CI remains authoritative. | @@ -571,6 +572,7 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-17 | codex/chat-supabase-migration-preflight-b463 | f7c4e293ef35acc54f2b82bbccb2990d51289d5c | live production Supabase security, integrity, drift, and performance review plus remediation | Resolved and deployed the P2 retrieval-performance issue with `20260717160000_optimize_owner_public_retrieval`: owner/public filtering now happens in one scoped query and index-unit text/term candidates use separate GIN-friendly branches. Warm text retrieval improved from 1.269 seconds to 34 ms; warm index-unit retrieval completed in 36-39 ms (first cold run 2.376 seconds with 2,009 physical reads). No P0/P1 security, privacy, RLS, privilege, storage, migration-history, or integrity issue was found. | Isolated Docker schema replay; pre-deploy drift showed exactly four intended function changes; linked production push; post-deploy `No unexpected schema drift`; exact project and migration-history checks; security and performance advisors; live access-scope parity; bounded `EXPLAIN ANALYZE`; post-migration logs; focused Vitest 74/74; offline RAG fixtures 36/36 and contract tests 291/291; ESLint; TypeScript; function grants; production readiness. Full `verify:cheap` reached the 10-minute host timeout during broad Vitest and ended with EPIPE; focused and domain checks passed. No OpenAI calls, write load test, or backup/PITR restore test was performed. | | 2026-07-17 | codex/chat-forms-import-6914 | e5caaa46cad5fb9a937f1dc43312723799b98abb + working-tree diff | shared Forms/Services catalogue access and LOCAL_NO_AUTH_OWNER_ID review | Fixed one P1 availability/design defect: authenticated reads materialized a private copy of the shared catalogue on first access, creating drift, unnecessary writes, and possible registry-corpus side effects. Forms/Services now merge the reviewed shared baseline with private owner overrides for list, detail, and universal search; older partial overrides retain missing shared metadata; no registry GET seeds or embeds. Private rows and linked documents remain owner-scoped. The ignored local owner setting was corrected to the verified sole live-owner UUID and source validation now requires a UUID. No remaining high-confidence defect in scope. | Focused registry/universal Vitest initially exposed four local expectation/count mismatches; corrected registry suite passed 17/17 and registry/logging suite passed 23/23. Full TypeScript passed. `verify:cheap` passed runtime, action pins, sitemap, brand, type/icon/function guards, full lint, and TypeScript; full Vitest reached 2,588 passing with one stale logging-guard failure, which was fixed and focused-verified. The final full-suite rerun was terminated by the 5-minute host timeout without a reported assertion failure. `git diff --check` passed. No Supabase/OpenAI/provider call or schema/RLS mutation was run for this review. | | 2026-07-17 | codex/scrolling-cleanup-20260717 | ff77cd06c + latest origin/main sync | cross-page scrolling and interaction stability review | Fixed two confirmed P2 defects: desktop action-popup placement performed synchronous geometry work for every captured scroll event, and submitted differential searches with zero document matches fell back to the home state. Placement is now coalesced per animation frame with passive scroll listeners, and submitted empty-evidence results remain visible. Hardened three popup/navigation browser helpers that reproduced hydration timing failures. No other high-confidence defect remains in the scoped diff. | Scroll-focused Chromium 28/28; final affected Chromium 5/5; source regressions 2/2; scoped ESLint; Prettier; TypeScript and production build passed before the final upstream-only sync; `git diff --check`. The aggregate local Vitest/UI runs were affected by concurrent-worktree resource contention, so exact-head hosted CI remains required before merge. No Supabase/OpenAI/live-provider checks run. | +| 2026-07-17 | codex/chat-audit-remediation-port-20260717 | 4f41093ba01f88e6d04a53f0782e8676806e3f6f | audit-findings remediation and local merge-readiness review | No remaining high-confidence P0-P2 defect in the reviewed diff. The review fixed two integration issues before handoff: transactional delete moved `rag_response_cache` cleanup out of the API route but left the explicit route-table guard stale, and upstream added a migration after the intentionally final fail-closed ACL assertion. The guard now matches direct route queries and the unapplied ACL migration is renumbered last. Publication requires explicit approved manifests, delete/reindex is serialized transactionally with upload compensation, PDF extraction is bounded, search ignores staged generations, and unsafe effective default ACLs block. | Rebasing and regenerated drift manifest against local `origin/main` 220de891; disposable Docker schema replay; publication/delete/ACL SQL fixtures; Python 4/4; focused Vitest 237/237 plus post-sync schema/retrieval 66/66; docs guards; offline RAG 290/290; production-readiness CI ready; `verify:cheap` 2602/2602 before final upstream sync; exact-head `verify:pr-local` formatting, lint, typecheck, 2628/2628 tests, production build (1043 pages), client-secret scan, and RAG fixtures; `git diff --check`. No live Supabase/OpenAI/GitHub/hosted-CI/provider checks, deployment, or live migration apply. | | 2026-07-17 | PR #713 / codex/chat-workflow-ideas-0916 | b52112df6aa36311d7420189064acd79dcf2c3f5 + reviewed follow-up diff | workflow toolkit review follow-up | Fixed all 14 actionable Codex and CodeRabbit threads: cross-platform path fixtures, installation-managed preflight guidance, complete Supabase-backed API database scoping, per-command approval boundaries, plugin-ignore narrowing, isolated CI-scope proof, remote-Git command guarding, repository-skill verification classification, `TypeError` diagnosis, strict CLI option values, machine-parseable JSON evidence output, and preservation of baseline database/clinical approval gates in the RAG lab. No unresolved actionable finding remains in the reviewed scope. | `npm run verify:cheap` passed with 273 files and 2,599 tests; focused toolkit Vitest 20/20; CI-scope self-test; plugin-ignore proof; `git diff --check`. Exact-head hosted CI remains required after the follow-up push. No Supabase, OpenAI, or other live product-provider command was run. | | 2026-07-17 | PR #699 / codex/test-reliability-hardening | 6202835ab1cf3703af311b9afdf372f74c63e040 | branch-cleanup-superseded | Closed as fully superseded by merged PR #705 (`e5caaa46c`). Range-diff maps the original implementation commit to #705's first integration commit; #705 then adds seven focused reliability fixes, while #699's remaining commit is merge-only and contributes no unique relevant patch. The exact-SHA remote ref and the unregistered local predecessor ref (`b518c1de9`) were deleted after final rechecks. | Fresh GitHub PR/head/status inventory; exact `ls-remote` and local-ref checks; cherry-pick-aware log; range-diff against PR #705's merged head; merge-parent verification; exact leased remote deletion and exact-old-value local `update-ref` deletion. No Supabase/OpenAI/product-provider checks run. | | 2026-07-17 | PR #716 / codex/pr-process-hardening-20260717 | 220de891f10f82df11eb2e8137367514c139a206 + reviewed implementation/follow-up diff | PR metadata, CI triage, review routing, Actions permissions, secret scanning, and branch-hygiene hardening | No remaining high-confidence P0-P2 defect in the reviewed process diff. Added a trusted base-SHA PR metadata/risk policy with draft and merge-queue handling; corrected CI triage to compare only the same workflow's latest completed `main` run; wired self-tests into local/hosted gates; documented the policy; created the missing durable Codex routing labels; removed four unused per-head routing labels; and applied read-only default Actions tokens, immutable Action pinning, no Actions-authored approvals, push protection, and automatic merged-branch deletion. Four policy defects were fixed before merge: API-only paths no longer trigger UI evidence, slash-bearing outcome titles are accepted, all seven governance items are required exactly, and placeholder risk/rollback text is rejected. | Offline `check:pr-policy`, `check:ci-triage`, `check:ci-scope`, action-pin check, scoped Prettier, and `git diff --check` passed. Initial hosted CI passed static, safety, coverage, build, images, Semgrep, Gitleaks, GitGuardian, and the aggregate gate; exact-head CI is required after the review fix. Broader local gates were deferred because other registered worktrees repeatedly held the heavyweight lock. GitHub provider inspection/settings changes were explicitly authorized. No Supabase/OpenAI/product-provider command ran. | diff --git a/docs/operator-backlog.md b/docs/operator-backlog.md index 2ac3a23b9..f43306d8a 100644 --- a/docs/operator-backlog.md +++ b/docs/operator-backlog.md @@ -17,23 +17,23 @@ Findings inventory for handover: [audit-handover-2026-07-14.md](audit-handover-2 ## Launch-gating actions -| Action | Status | Blocked by | Verify command | Runbook | -| -------------------------------------------------------------------------- | ---------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Apply July-8 migration batch (a–g) to live | ✅ done | — | `SUPABASE_ENVIRONMENT=production npm run check:july8-live-batch` (2026-07-13: 6 live, apply=no-op) | [operator-apply-july8-batch.md](operator-apply-july8-batch.md) | -| Apply drift-codify forward migration (step 1h) | ⏳ pending | migration artifact committed + live fingerprint recapture | `npm run check:drift` then `npm run eval:retrieval:quality` (36/36) | [database-drift-detection.md](database-drift-detection.md) | -| Full release gate (bounded OpenAI spend) | ⏳ pending | migrations 1 applied | `npm run verify:release`; `npm run eval:quality -- --rag-only` | [launch-operator-runbook.md §2](launch-operator-runbook.md) | -| Provision staging Supabase project (`Clinical KB Staging`, ap-southeast-2) | ⏳ pending | — | `npm run check:indexing` after `db push` | [staging-setup.md](staging-setup.md) | -| Staging soak + rollback rehearsal on Railway | ⏳ pending | staging provisioned | `scripts/soak-test.ts --confirm-staging` (answer p95 ≤ 25 s) | [launch-operator-runbook.md §4](launch-operator-runbook.md) · [capacity-review.md](capacity-review.md) | -| Production deploy to Railway | ⏳ pending | release gate + soak pass | `GET /api/health` → `{"status":"ok"}`; `npm run check:deployment-readiness` | [deployment-architecture.md](deployment-architecture.md) | +| Action | Status | Blocked by | Verify command | Runbook | +| -------------------------------------------------------------------------- | ---------- | -------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Apply July-8 migration batch (a–g) to live | ✅ done | — | `SUPABASE_ENVIRONMENT=production npm run check:july8-live-batch` (2026-07-13: 6 live, apply=no-op) | [operator-apply-july8-batch.md](operator-apply-july8-batch.md) | +| Apply drift-codify forward migration (step 1h) | ✅ done | — | Applied and drift/readiness verified 2026-07-13; verify only unless new reviewed drift is found | [database-drift-detection.md](database-drift-detection.md) | +| Full release gate (bounded OpenAI spend) | ⏳ pending | migrations 1 applied | `npm run verify:release`; `npm run eval:quality -- --rag-only` | [launch-operator-runbook.md §2](launch-operator-runbook.md) | +| Provision staging Supabase project (`Clinical KB Staging`, ap-southeast-2) | ⏳ pending | — | `npm run check:indexing` after `db push` | [staging-setup.md](staging-setup.md) | +| Staging soak + rollback rehearsal on Railway | ⏳ pending | staging provisioned | `scripts/soak-test.ts --confirm-staging` (answer p95 ≤ 25 s) | [launch-operator-runbook.md §4](launch-operator-runbook.md) · [capacity-review.md](capacity-review.md) | +| Production deploy to Railway | ✅ done | — | App deployment recorded live 2026-07-14; re-verify with `GET /api/health` and deployment readiness | [deployment-architecture.md](deployment-architecture.md) | ## Post-deploy actions -| Action | Status | Blocked by | Verify command | Runbook | -| --------------------------------------------------------------------- | ---------- | ------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| Redeploy worker (one always-on instance) | ⏳ pending | migration `20260708130000` live | `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) | +| Action | Status | Blocked by | Verify command | Runbook | +| --------------------------------------------------------------------- | ---------- | ------------------------------ | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| 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) | ## Standing secret / config placement (per environment) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 5a996d03d..5bc236c26 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -320,7 +320,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, operator caveat):** `20260713102000_revoke_supabase_admin_default_privileges.sql` revokes anon/authenticated future-object defaults for role `supabase_admin` and probes the lockdown with future-object creation. On hosted Supabase the migration degrades to a WARNING if `postgres` cannot act for `supabase_admin`; **operator follow-up:** watch for that warning during live apply and, if present, run the six `ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin` statements once via the dashboard SQL editor, then re-run the migration's probe block. +- **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. The migration attempts the hardening statements and then fails closed if the effective postcondition is unsafe, returning the exact six-statement operator remediation in its error hint. 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`. - **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/package.json b/package.json index a285a076e..00a8c6125 100644 --- a/package.json +++ b/package.json @@ -122,8 +122,10 @@ "images:re-stamp-generation": "node scripts/run-tsx.mjs scripts/reindex-image-generation-metadata.ts", "supabase:recovery-status": "node scripts/run-tsx.mjs scripts/supabase-recovery-status.ts", "promote:query-misses": "node scripts/run-tsx.mjs scripts/promote-query-misses.ts", - "promote:public-documents": "node scripts/run-tsx.mjs scripts/promote-public-documents.ts", + "promote:public-documents": "node scripts/run-tsx.mjs scripts/promote-public-documents-batch.ts", "promote:public-documents:batch": "node scripts/run-tsx.mjs scripts/promote-public-documents-batch.ts", + "audit:public-document-approvals": "node scripts/run-tsx.mjs scripts/audit-public-document-approvals.ts", + "check:default-acl": "node scripts/run-tsx.mjs scripts/check-default-acl.ts", "eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts", "eval:rag:offline": "node scripts/eval-rag-offline.mjs", "eval:answer-quality": "node scripts/run-eval-safe.mjs scripts/eval-answer-quality.ts", diff --git a/scripts/audit-public-document-approvals.ts b/scripts/audit-public-document-approvals.ts new file mode 100644 index 000000000..dee9e6346 --- /dev/null +++ b/scripts/audit-public-document-approvals.ts @@ -0,0 +1,62 @@ +import { loadEnvConfig } from "@next/env"; + +loadEnvConfig(process.cwd()); + +const PAGE_SIZE = 500; + +async function loadAdminClient() { + const { createAdminClient } = await import("@/lib/supabase/admin"); + return createAdminClient(); +} + +async function main() { + const supabase = await loadAdminClient(); + const documents: Array<{ id: string; title: string; metadata: Record | null }> = []; + for (let from = 0; ; from += PAGE_SIZE) { + const { data, error } = await supabase + .from("documents") + .select("id, title, metadata") + .eq("status", "indexed") + .is("owner_id", null) + .order("id") + .range(from, from + PAGE_SIZE - 1); + if (error) throw new Error(error.message); + documents.push(...((data ?? []) as typeof documents)); + if ((data ?? []).length < PAGE_SIZE) break; + } + + const approvalIds = documents + .map((document) => String(document.metadata?.publication_approval_id ?? "")) + .filter(Boolean); + const approvals = new Map(); + for (let index = 0; index < approvalIds.length; index += PAGE_SIZE) { + const { data, error } = await supabase + .from("document_publication_approvals") + .select("id, document_id, manifest_digest, decision") + .in("id", approvalIds.slice(index, index + PAGE_SIZE)); + if (error) throw new Error(error.message); + for (const approval of data ?? []) approvals.set(approval.id, approval); + } + + const missing = documents.filter((document) => { + const approvalId = String(document.metadata?.publication_approval_id ?? ""); + const digest = String(document.metadata?.publication_manifest_digest ?? ""); + const approval = approvals.get(approvalId); + return ( + !approval || + approval.document_id !== document.id || + approval.decision !== "approved" || + approval.manifest_digest !== digest + ); + }); + + console.log(`[public-documents:audit] indexed public documents: ${documents.length}`); + console.log(`[public-documents:audit] lacking matching approval evidence: ${missing.length}`); + for (const document of missing) console.log(`${document.id}\t${document.title}`); + console.log("[public-documents:audit] read-only audit complete; no ownership or publication state was changed."); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/check-default-acl.ts b/scripts/check-default-acl.ts new file mode 100644 index 000000000..7edff312b --- /dev/null +++ b/scripts/check-default-acl.ts @@ -0,0 +1,25 @@ +import { createAdminClient } from "../src/lib/supabase/admin"; + +function argument(name: string, fallback: string) { + const index = process.argv.indexOf(name); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : fallback; +} + +const roleName = argument("--role", "supabase_admin"); +const schemaName = argument("--schema", "public"); +if (!process.argv.includes("--confirm-provider-read")) { + throw new Error("Refusing provider-backed ACL verification without --confirm-provider-read."); +} +const supabase = createAdminClient(); +const { data, error } = await supabase.rpc("default_privileges_status", { + p_role_name: roleName, + p_schema_name: schemaName, +}); + +if (error) throw new Error(`Default ACL verification failed: ${error.message}`); +console.log(JSON.stringify(data, null, 2)); + +const status = data && typeof data === "object" && !Array.isArray(data) ? data : null; +if (!status || status.safe !== true) { + throw new Error(`Unsafe default privileges for ${roleName} in schema ${schemaName}.`); +} diff --git a/scripts/generate-drift-manifest.ts b/scripts/generate-drift-manifest.ts index 55deb7fcb..74f0d2448 100644 --- a/scripts/generate-drift-manifest.ts +++ b/scripts/generate-drift-manifest.ts @@ -56,6 +56,7 @@ async function main() { } const schemaSql = readFileSync(repoUrl("supabase/schema.sql"), "utf8"); + const rolesSql = readFileSync(repoUrl("supabase/roles.sql"), "utf8"); const scaffoldSql = readFileSync(repoUrl("scripts/sql/drift-replay-scaffold.sql"), "utf8"); const { normalizedSchemaSha256 } = await import("./check-drift"); @@ -94,6 +95,8 @@ async function main() { sql, ); + console.log("Applying role bootstrap (supabase_admin)…"); + psql("supabase_admin", 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/promote-public-documents-batch.ts b/scripts/promote-public-documents-batch.ts index eba457a06..29b487dc8 100644 --- a/scripts/promote-public-documents-batch.ts +++ b/scripts/promote-public-documents-batch.ts @@ -1,95 +1,115 @@ +import { readFile } from "node:fs/promises"; import { loadEnvConfig } from "@next/env"; +import { + assertPublicationApplyConfirmation, + parsePublicationCommandArgs, + parsePublicationManifest, + publicationManifestDigest, +} from "@/lib/publication-manifest"; loadEnvConfig(process.cwd()); -const BATCH_SIZE = 25; - -function withPublicCorpusMetadata(metadata: unknown): Record { - const base = - typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) - ? (metadata as Record) - : {}; - return { ...base, public_corpus: true }; -} - async function loadAdminClient() { const { createAdminClient } = await import("@/lib/supabase/admin"); return createAdminClient(); } -async function countPublic(supabase: Awaited>) { - const { count, error } = await supabase - .from("documents") - .select("id", { count: "exact", head: true }) - .eq("status", "indexed") - .is("owner_id", null); - if (error) throw new Error(error.message); - return count ?? 0; -} +async function main() { + const args = parsePublicationCommandArgs(process.argv.slice(2)); + const raw = await readFile(args.manifestPath, "utf8"); + const manifest = parsePublicationManifest(raw); + const digest = publicationManifestDigest(raw); + const supabase = await loadAdminClient(); + const ids = manifest.documents.map((document) => document.documentId); -async function fetchBatchIds(supabase: Awaited>) { - const { data, error } = await supabase + const { data: documents, error: documentError } = await supabase .from("documents") - .select("id") - .eq("status", "indexed") - .not("owner_id", "is", null) - .limit(BATCH_SIZE); - if (error) throw new Error(error.message); - return (data ?? []).map((row) => row.id as string); -} - -async function promoteBatch(supabase: Awaited>, ids: string[]) { - if (ids.length === 0) return; + .select("id, owner_id, status, title") + .in("id", ids); + if (documentError) throw new Error(documentError.message); - const updatedAt = new Date().toISOString(); - const { data: documents, error: fetchError } = await supabase.from("documents").select("id, metadata").in("id", ids); - if (fetchError) throw new Error(fetchError.message); + const documentsById = new Map((documents ?? []).map((document) => [document.id, document])); + const validationErrors: string[] = []; + for (const entry of manifest.documents) { + const document = documentsById.get(entry.documentId); + if (!document) validationErrors.push(`${entry.documentId}: not found`); + else if (document.owner_id !== entry.expectedOwnerId) validationErrors.push(`${entry.documentId}: owner changed`); + else if (document.status !== "indexed") validationErrors.push(`${entry.documentId}: status is ${document.status}`); + } + if (validationErrors.length > 0) { + throw new Error(`Publication manifest validation failed:\n${validationErrors.join("\n")}`); + } - await Promise.all( - (documents ?? []).map(async (document) => { - const { error } = await supabase - .from("documents") - .update({ - owner_id: null, - metadata: withPublicCorpusMetadata(document.metadata), - updated_at: updatedAt, - }) - .eq("id", document.id); - if (error) throw new Error(error.message); - }), + const decisionCounts = Object.fromEntries( + ["approved", "keep_private", "quarantine"].map((decision) => [ + decision, + manifest.documents.filter((document) => document.decision === decision).length, + ]), ); + console.log(`[public-documents:promote] manifest SHA-256: ${digest}`); + console.log(`[public-documents:promote] explicit document count: ${manifest.documents.length}`); + console.log(`[public-documents:promote] decisions: ${JSON.stringify(decisionCounts)}`); - const artifactUpdates = await Promise.all([ - supabase.from("document_labels").update({ owner_id: null, updated_at: updatedAt }).in("document_id", ids), - supabase.from("document_summaries").update({ owner_id: null, updated_at: updatedAt }).in("document_id", ids), - supabase.from("document_sections").update({ owner_id: null, updated_at: updatedAt }).in("document_id", ids), - supabase.from("document_memory_cards").update({ owner_id: null, updated_at: updatedAt }).in("document_id", ids), - supabase.from("document_table_facts").update({ owner_id: null }).in("document_id", ids), - supabase.from("document_embedding_fields").update({ owner_id: null }).in("document_id", ids), - supabase.from("document_index_quality").update({ owner_id: null, updated_at: updatedAt }).in("document_id", ids), - supabase.from("document_index_units").update({ owner_id: null, updated_at: updatedAt }).in("document_id", ids), - ]); - for (const { error } of artifactUpdates) { - if (error) throw new Error(error.message); + if (!args.apply) { + console.log("[public-documents:promote] dry run only; no approvals or document ownership were changed."); + console.log( + `[public-documents:promote] apply with --expected-count ${manifest.documents.length} --confirm-sha256 ${digest} --apply`, + ); + return; } -} -async function main() { - const supabase = await loadAdminClient(); - let batches = 0; + assertPublicationApplyConfirmation({ + manifest, + digest, + expectedCount: args.expectedCount, + confirmSha256: args.confirmSha256, + }); - while (true) { - const ids = await fetchBatchIds(supabase); - if (ids.length === 0) break; - await promoteBatch(supabase, ids); - batches += 1; - const publicCount = await countPublic(supabase); - console.log( - `[public-documents:promote] batch ${batches}: promoted ${ids.length}; public indexed total ${publicCount}`, - ); + const { data: existingApprovals, error: existingApprovalError } = await supabase + .from("document_publication_approvals") + .select("document_id, expected_prior_owner_id, decision, manifest_digest") + .eq("manifest_digest", digest) + .in("document_id", ids); + if (existingApprovalError) throw new Error(existingApprovalError.message); + const existing = new Set( + (existingApprovals ?? []).map( + (approval) => + `${approval.document_id}:${approval.expected_prior_owner_id}:${approval.decision}:${approval.manifest_digest}`, + ), + ); + const approvals = manifest.documents + .filter( + (document) => !existing.has(`${document.documentId}:${document.expectedOwnerId}:${document.decision}:${digest}`), + ) + .map((document) => ({ + document_id: document.documentId, + expected_prior_owner_id: document.expectedOwnerId, + approving_operator_id: manifest.approvingOperatorId, + decision: document.decision, + reason: manifest.reason, + evidence_references: manifest.evidenceReferences, + manifest_digest: digest, + })); + if (approvals.length > 0) { + const { error: approvalError } = await supabase.from("document_publication_approvals").insert(approvals); + if (approvalError) throw new Error(approvalError.message); + } + + const approvedDocuments = manifest.documents + .filter((document) => document.decision === "approved") + .map((document) => ({ document_id: document.documentId, expected_owner_id: document.expectedOwnerId })); + if (approvedDocuments.length === 0) { + console.log("[public-documents:promote] decisions recorded; no documents were approved for publication."); + return; } - console.log(`[public-documents:promote] complete. indexed public documents: ${await countPublic(supabase)}`); + const { data: result, error: publishError } = await supabase.rpc("publish_approved_documents", { + p_documents: approvedDocuments, + p_manifest_digest: digest, + p_expected_count: approvedDocuments.length, + }); + if (publishError) throw new Error(publishError.message); + console.log(`[public-documents:promote] result: ${JSON.stringify(result)}`); } main().catch((error: unknown) => { diff --git a/scripts/promote-public-documents.ts b/scripts/promote-public-documents.ts deleted file mode 100644 index 4242c906c..000000000 --- a/scripts/promote-public-documents.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { loadEnvConfig } from "@next/env"; - -loadEnvConfig(process.cwd()); - -type PromoteArgs = { - ownerId?: string; -}; - -async function loadAdminClient() { - const { createAdminClient } = await import("@/lib/supabase/admin"); - return createAdminClient(); -} - -function parseArgs(argv: string[]): PromoteArgs { - const args: PromoteArgs = { - ownerId: process.env.PUBLIC_WORKSPACE_OWNER_ID ?? process.env.LOCAL_NO_AUTH_OWNER_ID, - }; - - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (token === "--owner-id") { - args.ownerId = argv[index + 1]; - index += 1; - continue; - } - throw new Error(`Unknown argument: ${token}`); - } - - return args; -} - -async function countCandidates(supabase: Awaited>, ownerId?: string) { - let query = supabase - .from("documents") - .select("id", { count: "exact", head: true }) - .eq("status", "indexed") - .not("owner_id", "is", null); - - if (ownerId) query = query.eq("owner_id", ownerId); - - const { count, error } = await query; - if (error) throw new Error(error.message); - return count ?? 0; -} - -async function countPublic(supabase: Awaited>) { - const { count, error } = await supabase - .from("documents") - .select("id", { count: "exact", head: true }) - .eq("status", "indexed") - .is("owner_id", null); - - if (error) throw new Error(error.message); - return count ?? 0; -} - -async function main() { - const args = parseArgs(process.argv.slice(2)); - const supabase = await loadAdminClient(); - - const [candidateCount, publicCount] = await Promise.all([ - countCandidates(supabase, args.ownerId), - countPublic(supabase), - ]); - - console.log("[public-documents:promote] indexed public documents:", publicCount); - console.log( - `[public-documents:promote] pending promotion${args.ownerId ? ` for owner ${args.ownerId}` : ""}:`, - candidateCount, - ); - console.log( - "Promote remaining owned indexed documents with `npm run promote:public-documents:batch` or apply migration 20260706120000_promote_remaining_indexed_documents_public.sql.", - ); -} - -main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : error); - process.exit(1); -}); diff --git a/scripts/sql/verify-default-acl-catalog.sql b/scripts/sql/verify-default-acl-catalog.sql new file mode 100644 index 000000000..228777936 --- /dev/null +++ b/scripts/sql/verify-default-acl-catalog.sql @@ -0,0 +1,109 @@ +begin; + +create role default_acl_fixture_role nologin; +create role default_acl_fixture_attacker nologin; + +do $$ +declare + status jsonb; +begin + status := public.default_privileges_status('default_acl_fixture_role', 'public'); + if coalesce((status->>'safe')::boolean, false) then + raise exception 'missing pg_default_acl rows hid built-in PUBLIC function execute'; + end if; +end; +$$; + +alter default privileges for role default_acl_fixture_role + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role default_acl_fixture_role in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role default_acl_fixture_role + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role default_acl_fixture_role in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role default_acl_fixture_role + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role default_acl_fixture_role in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role default_acl_fixture_role in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role default_acl_fixture_role in schema public + grant usage, select on sequences to service_role; +alter default privileges for role default_acl_fixture_role in schema public + grant execute on functions to service_role; + +do $$ +declare + status jsonb; +begin + status := public.default_privileges_status('default_acl_fixture_role', 'public'); + if not coalesce((status->>'safe')::boolean, false) then + raise exception 'safe default ACL fixture was rejected: %', status; + end if; +end; +$$; + +alter default privileges for role default_acl_fixture_role in schema public + grant select on tables to authenticated; + +do $$ +declare + status jsonb; +begin + status := public.default_privileges_status('default_acl_fixture_role', 'public'); + if coalesce((status->>'safe')::boolean, false) then + raise exception 'unsafe authenticated table default was accepted'; + end if; +end; +$$; + +alter default privileges for role default_acl_fixture_role in schema public + revoke select on tables from authenticated; +alter default privileges for role default_acl_fixture_role in schema public + grant select on tables to public; + +do $$ +declare + status jsonb; +begin + status := public.default_privileges_status('default_acl_fixture_role', 'public'); + if coalesce((status->>'safe')::boolean, false) then + raise exception 'unsafe PUBLIC table default was accepted'; + end if; +end; +$$; + +alter default privileges for role default_acl_fixture_role in schema public + revoke select on tables from public; +alter default privileges for role default_acl_fixture_role in schema public + grant select on tables to default_acl_fixture_attacker; + +do $$ +declare + status jsonb; +begin + status := public.default_privileges_status('default_acl_fixture_role', 'public'); + if coalesce((status->>'safe')::boolean, false) then + raise exception 'unsafe arbitrary-role table default was accepted'; + end if; +end; +$$; + +alter default privileges for role default_acl_fixture_role in schema public + revoke select on tables from default_acl_fixture_attacker; +alter default privileges for role default_acl_fixture_role in schema public + grant usage on sequences to public; + +do $$ +declare + status jsonb; +begin + status := public.default_privileges_status('default_acl_fixture_role', 'public'); + if coalesce((status->>'safe')::boolean, false) then + raise exception 'unsafe PUBLIC sequence default was accepted'; + end if; +end; +$$; + +rollback; diff --git a/scripts/sql/verify-delete-reindex-exclusion.sql b/scripts/sql/verify-delete-reindex-exclusion.sql new file mode 100644 index 000000000..1285ba3f7 --- /dev/null +++ b/scripts/sql/verify-delete-reindex-exclusion.sql @@ -0,0 +1,106 @@ +begin; + +insert into auth.users (id, instance_id, aud, role, email, encrypted_password, created_at, updated_at) +values ( + 'd1000000-0000-4000-8000-000000000001', + '00000000-0000-0000-0000-000000000000', + 'authenticated', + 'authenticated', + 'delete-race-owner@example.test', + '', + now(), + now() +); + +insert into public.documents (id, owner_id, title, file_name, file_type, storage_path, status) +values ( + 'd2000000-0000-4000-8000-000000000002', + 'd1000000-0000-4000-8000-000000000001', + 'Delete race fixture', + 'delete-race.pdf', + 'application/pdf', + 'delete-race/source.pdf', + 'indexed' +); + +insert into public.document_images (document_id, storage_path) +values ('d2000000-0000-4000-8000-000000000002', 'delete-race/image-1.png'); + +insert into public.ingestion_jobs (id, document_id, status, stage, progress) +values ( + 'd3000000-0000-4000-8000-000000000003', + 'd2000000-0000-4000-8000-000000000002', + 'failed', + 'failed', + 0 +); + +do $$ +declare + result jsonb; +begin + result := public.retry_ingestion_job_if_idle( + 'd3000000-0000-4000-8000-000000000003', + 'd1000000-0000-4000-8000-000000000001', + now() - interval '15 minutes', + 3, + now(), + now() + ); + if result->>'outcome' <> 'queued' then + raise exception 'retry-first ordering did not queue atomically: %', result; + end if; + + result := public.delete_document_if_idle( + 'd2000000-0000-4000-8000-000000000002', + 'd1000000-0000-4000-8000-000000000001', + 'clinical-documents', + 'clinical-images' + ); + if result->>'outcome' <> 'active_job' then + raise exception 'job-first ordering did not block deletion: %', result; + end if; + if not exists (select 1 from public.documents where id = 'd2000000-0000-4000-8000-000000000002') then + raise exception 'job-first ordering deleted the document'; + end if; +end; +$$; + +delete from public.ingestion_jobs where id = 'd3000000-0000-4000-8000-000000000003'; + +do $$ +declare + result jsonb; + cleanup_id uuid; +begin + result := public.delete_document_if_idle( + 'd2000000-0000-4000-8000-000000000002', + 'd1000000-0000-4000-8000-000000000001', + 'clinical-documents', + 'clinical-images' + ); + if result->>'outcome' <> 'deleted' then + raise exception 'delete-first ordering did not delete: %', result; + end if; + cleanup_id := (result->>'cleanup_job_id')::uuid; + if not exists ( + select 1 from public.storage_cleanup_jobs + where id = cleanup_id + and document_id is null + and document_paths = array['delete-race/source.pdf'] + and image_paths = array['delete-race/image-1.png'] + ) then + raise exception 'delete cleanup ledger did not preserve the storage snapshot'; + end if; + + begin + insert into public.ingestion_jobs (document_id, status, stage, progress) + values ('d2000000-0000-4000-8000-000000000002', 'pending', 'queued', 0); + raise exception 'delete-first ordering allowed a reindex job'; + exception when foreign_key_violation then + null; + end; +end; +$$; + +rollback; diff --git a/scripts/sql/verify-publication-approval.sql b/scripts/sql/verify-publication-approval.sql new file mode 100644 index 000000000..64692bf00 --- /dev/null +++ b/scripts/sql/verify-publication-approval.sql @@ -0,0 +1,109 @@ +begin; + +insert into auth.users (id, aud, role, email, encrypted_password, created_at, updated_at) +values + ('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'authenticated', 'authenticated', 'publication-owner@example.invalid', '', now(), now()), + ('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'authenticated', 'authenticated', 'publication-operator@example.invalid', '', now(), now()) +on conflict (id) do nothing; + +insert into public.documents (id, owner_id, title, file_name, file_type, storage_path, status) +values + ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Approved fixture', 'approved.pdf', 'application/pdf', 'fixtures/approved.pdf', 'indexed'), + ('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Private fixture', 'private.pdf', 'application/pdf', 'fixtures/private.pdf', 'indexed'), + ('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Quarantine fixture', 'quarantine.pdf', 'application/pdf', 'fixtures/quarantine.pdf', 'indexed'), + ('10000000-0000-4000-8000-000000000004', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Unapproved fixture', 'unapproved.pdf', 'application/pdf', 'fixtures/unapproved.pdf', 'indexed'); + +insert into public.document_labels (document_id, owner_id, label, label_type, source) +values ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'publication fixture', 'custom', 'manual'); + +insert into public.document_publication_approvals ( + document_id, expected_prior_owner_id, approving_operator_id, decision, reason, evidence_references, manifest_digest +) +values + ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'approved', 'Approved publication fixture.', array['fixture:approved'], repeat('a', 64)), + ('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'keep_private', 'Private publication fixture.', array['fixture:private'], repeat('a', 64)), + ('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'quarantine', 'Quarantine publication fixture.', array['fixture:quarantine'], repeat('a', 64)); + +select public.publish_approved_documents( + jsonb_build_array(jsonb_build_object( + 'document_id', '10000000-0000-4000-8000-000000000001', + 'expected_owner_id', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + )), + repeat('a', 64), + 1 +); + +do $$ +begin + if not exists ( + select 1 from public.documents + where id = '10000000-0000-4000-8000-000000000001' + and owner_id is null + and metadata->>'publication_manifest_digest' = repeat('a', 64) + ) then raise exception 'approved publication fixture was not published'; end if; + if not exists ( + select 1 from public.document_labels + where document_id = '10000000-0000-4000-8000-000000000001' and owner_id is null + ) then raise exception 'approved publication artifact owner was not updated'; end if; + if exists ( + select 1 from public.documents + where id in ('10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000003') + and owner_id is null + ) then raise exception 'private or quarantine publication fixture was published'; end if; + + begin + update public.documents + set owner_id = null, metadata = metadata || jsonb_build_object('public_corpus', true) + where id = '10000000-0000-4000-8000-000000000004'; + raise exception 'unapproved publication fixture unexpectedly published'; + exception when others then + if sqlerrm = 'unapproved publication fixture unexpectedly published' then raise; end if; + end; + + begin + insert into public.documents (id, owner_id, title, file_name, file_type, storage_path, status) + values ( + '10000000-0000-4000-8000-000000000005', + null, + 'Direct public insert fixture', + 'direct-public.pdf', + 'application/pdf', + 'fixtures/direct-public.pdf', + 'indexed' + ); + raise exception 'direct public document insert unexpectedly succeeded'; + exception when others then + if sqlerrm = 'direct public document insert unexpectedly succeeded' then raise; end if; + if sqlstate <> 'P0001' + or sqlerrm <> 'public documents must be created as owned rows before approved publication' then + raise; + end if; + end; + + begin + insert into public.document_publication_approvals ( + document_id, expected_prior_owner_id, approving_operator_id, decision, reason, evidence_references, manifest_digest + ) values ( + '10000000-0000-4000-8000-000000000001', + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + 'quarantine', + 'Contradictory decision fixture.', + array['fixture:contradictory'], + repeat('a', 64) + ); + raise exception 'contradictory publication approval unexpectedly succeeded'; + exception when unique_violation then + null; + end; + + begin + update public.document_publication_approvals set reason = 'mutated' + where document_id = '10000000-0000-4000-8000-000000000001'; + raise exception 'publication approval ledger unexpectedly allowed mutation'; + exception when others then + if sqlerrm = 'publication approval ledger unexpectedly allowed mutation' then raise; end if; + end; +end $$; + +rollback; diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index f6e3f2374..d0f767cfe 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -131,6 +131,16 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: .single(); if (jobError) { + // A concurrent transactional delete holds the parent row lock while it + // removes the document. Once deletion commits, the FK check for this job + // fails with 23503. That is a normal lifecycle conflict, not a server + // fault, and there is no surviving document state to roll back. + if (jobError.code === "23503") { + return NextResponse.json( + { error: "Document was deleted while reindexing. Refresh the document list and retry." }, + { status: 409 }, + ); + } // R17: a unique index on ingestion_jobs(document_id) where status in // (pending,processing) can reject this insert with 23505 when a // concurrent request won the race between the pre-check above and this diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 5246c653c..87bdf5f18 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -26,8 +26,22 @@ const renameSchema = z const documentRouteParamsSchema = z.object({ id: z.string().uuid(), }); +const deleteDocumentResultSchema = z.discriminatedUnion("outcome", [ + z.object({ outcome: z.literal("not_found") }), + z.object({ + outcome: z.literal("active_job"), + job_id: z.string().uuid(), + job_status: z.enum(["pending", "processing"]), + }), + z.object({ + outcome: z.literal("deleted"), + cleanup_job_id: z.string().uuid(), + document_title: z.string(), + source_path: z.string().nullable(), + image_paths: z.array(z.string()), + }), +]); -const cleanupPageSize = 1000; const defaultPageWindow = 9; const maxPageWindow = 40; const defaultChunkWindow = 16; @@ -54,32 +68,6 @@ function safeMetadata(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } -function isNonEmptyString(value: string | null | undefined): value is string { - return typeof value === "string" && value.length > 0; -} - -async function selectDocumentRowsInPages(args: { - supabase: ReturnType; - table: "document_images" | "document_chunks"; - select: string; - documentId: string; -}) { - const rows: T[] = []; - for (let offset = 0; ; offset += cleanupPageSize) { - const { data, error } = await args.supabase - .from(args.table) - .select(args.select) - .eq("document_id", args.documentId) - .range(offset, offset + cleanupPageSize - 1); - if (error) throw new Error(error.message); - - const page = (data ?? []) as T[]; - rows.push(...page); - if (page.length < cleanupPageSize) break; - } - return rows; -} - function metadataText(metadata: Record, key: string) { const value = metadata[key]; return typeof value === "string" && value.trim() ? value.trim() : null; @@ -170,37 +158,6 @@ async function removeStorageObjects(args: { return { storageRemoved, storageWarnings: warnings }; } -async function createStorageCleanupJob(args: { - supabase: ReturnType; - ownerId: string; - documentId: string; - documentTitle: string; - sourcePath: string | null; - imagePaths: string[]; -}) { - const { data, error } = await args.supabase - .from("storage_cleanup_jobs") - .insert({ - owner_id: args.ownerId, - document_id: args.documentId, - document_title: args.documentTitle, - document_bucket: env.SUPABASE_DOCUMENT_BUCKET, - document_paths: args.sourcePath ? [args.sourcePath] : [], - image_bucket: env.SUPABASE_IMAGE_BUCKET, - image_paths: Array.from(new Set(args.imagePaths.filter(Boolean))), - status: "pending", - metadata: { - operation: "permanent_document_delete", - created_by: "api/documents/[id]", - }, - }) - .select("id") - .single(); - - if (error) throw new Error(error.message); - return data.id as string; -} - async function updateStorageCleanupJob(args: { supabase: ReturnType; cleanupJobId: string; @@ -227,49 +184,6 @@ async function updateStorageCleanupJob(args: { return error ? storageWarningsFrom(error, "Cleanup ledger") : null; } -async function deleteDocumentIndexTraceRows(args: { - supabase: ReturnType; - ownerId: string; - documentId: string; - chunkIds: string[]; -}) { - const cleanupErrors: string[] = []; - - if (args.chunkIds.length > 0) { - const chunkTraceDeletes = [ - args.supabase.from("rag_queries").delete().overlaps("source_chunk_ids", args.chunkIds), - args.supabase.from("rag_query_misses").delete().overlaps("top_chunk_ids", args.chunkIds), - args.supabase.from("rag_query_misses").delete().overlaps("cited_chunk_ids", args.chunkIds), - ]; - - for (const query of chunkTraceDeletes) { - const { error } = await query; - if (error) cleanupErrors.push(error.message); - } - } - - const documentTraceDeletes = [ - args.supabase - .from("rag_query_misses") - .delete() - .or(`clicked_document_id.eq.${args.documentId},expected_document_id.eq.${args.documentId}`), - args.supabase - .from("rag_response_cache") - .delete() - .eq("owner_id", args.ownerId) - .in("cache_kind", ["search", "answer"]), - ]; - - for (const query of documentTraceDeletes) { - const { error } = await query; - if (error) cleanupErrors.push(error.message); - } - - if (cleanupErrors.length > 0) { - throw new Error(`Index trace cleanup failed: ${cleanupErrors.join("; ")}`); - } -} - export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id: rawId } = await params; @@ -531,118 +445,36 @@ 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 { data: document, error: documentError } = await supabase - .from("documents") - .select("id,owner_id,title,storage_path") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); - - if (documentError) throw new Error(documentError.message); - if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); + const { data, error } = await supabase.rpc("delete_document_if_idle", { + p_document_id: id, + p_owner_id: user.id, + p_document_bucket: env.SUPABASE_DOCUMENT_BUCKET, + p_image_bucket: env.SUPABASE_IMAGE_BUCKET, + }); + if (error) throw new Error(error.message); - // Audit M9: block deletion on PENDING jobs too, matching the reindex - // routes' checkIngestionMutationSafety predicate. A just-queued reindex - // job (status "pending") racing this DELETE let the worker upload a new - // generation of image objects after the storage paths were enumerated, - // orphaning them permanently. - async function loadActiveJobs() { - return supabase - .from("ingestion_jobs") - .select("id,status") - .eq("document_id", id) - .in("status", ["pending", "processing"]) - .limit(1); + const parsedResult = deleteDocumentResultSchema.safeParse(data); + if (!parsedResult.success) throw new Error("delete_document_if_idle returned an invalid result."); + const result = parsedResult.data; + if (result.outcome === "not_found") { + return NextResponse.json({ error: "Document not found." }, { status: 404 }); } - - const { data: activeJobs, error: activeJobsError } = await loadActiveJobs(); - - if (activeJobsError) throw new Error(activeJobsError.message); - if ((activeJobs ?? []).length > 0) { + if (result.outcome === "active_job") { throw new PublicApiError( "Document has pending or processing indexing work. Stop or wait for the worker before deleting.", 409, + { code: "document_indexing_active" }, ); } - const [images, chunks] = await Promise.all([ - selectDocumentRowsInPages<{ storage_path: string | null }>({ - supabase, - table: "document_images", - select: "storage_path", - documentId: id, - }), - selectDocumentRowsInPages<{ id: string | null }>({ - supabase, - table: "document_chunks", - select: "id", - documentId: id, - }), - ]); - - const chunkIds = chunks.map((chunk) => chunk.id).filter(isNonEmptyString); - const imagePaths = images.map((image) => image.storage_path).filter(isNonEmptyString); - const cleanupJobId = await createStorageCleanupJob({ - supabase, - ownerId: user.id, - documentId: id, - documentTitle: document.title, - sourcePath: document.storage_path, - imagePaths, - }); - - const { data: lateActiveJobs, error: lateActiveJobsError } = await loadActiveJobs(); - if (lateActiveJobsError) throw new Error(lateActiveJobsError.message); - if ((lateActiveJobs ?? []).length > 0) { - const message = - "Document gained pending or processing indexing work during delete. Stop or wait for the worker before deleting."; - const ledgerWarning = await updateStorageCleanupJob({ - supabase, - cleanupJobId, - status: "failed", - storageRemoved: 0, - warnings: [message], - aborted: true, - }); - throw new PublicApiError(ledgerWarning ? `${message}; ${ledgerWarning}` : message, 409); - } - - try { - await deleteDocumentIndexTraceRows({ supabase, ownerId: user.id, documentId: id, chunkIds }); - } catch (traceCleanupError) { - const message = traceCleanupError instanceof Error ? traceCleanupError.message : "Index trace cleanup failed."; - const ledgerWarning = await updateStorageCleanupJob({ - supabase, - cleanupJobId, - status: "failed", - storageRemoved: 0, - warnings: [message], - aborted: true, - }); - throw new Error(ledgerWarning ? `${message}; ${ledgerWarning}` : message); - } - - const { error: deleteError } = await supabase.from("documents").delete().eq("id", id).eq("owner_id", user.id); - if (deleteError) { - const ledgerWarning = await updateStorageCleanupJob({ - supabase, - cleanupJobId, - status: "failed", - storageRemoved: 0, - warnings: [`Database delete: ${deleteError.message}`], - aborted: true, - }); - throw new Error(ledgerWarning ? `${deleteError.message}; ${ledgerWarning}` : deleteError.message); - } - const cleanup = await removeStorageObjects({ supabase, - sourcePath: document.storage_path, - imagePaths, + sourcePath: result.source_path, + imagePaths: result.image_paths, }); const ledgerWarning = await updateStorageCleanupJob({ supabase, - cleanupJobId, + cleanupJobId: result.cleanup_job_id, status: cleanup.storageWarnings.length > 0 ? "failed" : "completed", storageRemoved: cleanup.storageRemoved, warnings: cleanup.storageWarnings, @@ -655,7 +487,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i action: "document_delete", resourceType: "document", resourceId: id, - metadata: { title: document.title, storageRemoved: cleanup.storageRemoved }, + metadata: { title: result.document_title, storageRemoved: cleanup.storageRemoved }, }); return NextResponse.json({ deleted: true, diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 27b099407..386b77f41 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -56,6 +56,7 @@ export async function POST(request: Request) { if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); const results: Array<{ documentId: string; mode: string; ok: boolean; jobId?: string; error?: string }> = []; + let deleteRaceDetected = false; for (const document of documents) { try { @@ -130,6 +131,10 @@ export async function POST(request: Request) { .select("id") .single(); if (jobError) { + if (jobError.code === "23503") { + deleteRaceDetected = true; + throw new Error("Document was deleted while reindexing. Refresh the document list and retry."); + } // R17: same race as the single-document reindex route — a unique // index on ingestion_jobs(document_id) where status in // (pending,processing) can reject this insert with 23505 when a @@ -189,11 +194,14 @@ export async function POST(request: Request) { } invalidateRagCachesForOwner(user.id); - return NextResponse.json({ - ok: results.every((result) => result.ok), - results, - missingDocumentIds: documentIds.filter((id) => !documents.some((document) => document.id === id)), - }); + return NextResponse.json( + { + ok: results.every((result) => result.ok), + results, + missingDocumentIds: documentIds.filter((id) => !documents.some((document) => document.id === id)), + }, + { status: deleteRaceDetected ? 409 : 200 }, + ); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); if (error instanceof PublicApiError) return jsonError(error, error.status); diff --git a/src/app/api/ingestion/jobs/[id]/retry/route.ts b/src/app/api/ingestion/jobs/[id]/retry/route.ts index 5622ab18d..bef50a2bd 100644 --- a/src/app/api/ingestion/jobs/[id]/retry/route.ts +++ b/src/app/api/ingestion/jobs/[id]/retry/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { env, isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { ingestionJobRetryRejectionReason, retryDocumentQueueUpdate } from "@/lib/ingestion"; +import { ingestionJobRetryRejectionReason } from "@/lib/ingestion"; import { ingestionRollbackFenceStamp } from "@/lib/ingestion-mutation-safety"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -13,6 +13,12 @@ export const runtime = "nodejs"; const ingestionRetryRouteParamsSchema = z.object({ id: z.string().uuid(), }); +const ingestionRetryResultSchema = z.discriminatedUnion("outcome", [ + z.object({ outcome: z.literal("not_found") }), + z.object({ outcome: z.literal("completed") }), + z.object({ outcome: z.literal("active_worker") }), + z.object({ outcome: z.literal("queued"), job: z.object({}).passthrough() }), +]); export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -23,66 +29,27 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); - const { data: job, error: jobError } = await supabase - .from("ingestion_jobs") - .select( - "id,document_id,batch_id,status,stage,progress,error_message,attempt_count,max_attempts,locked_at,locked_by,next_run_at,completed_at,documents!inner(owner_id,status)", - ) - .eq("id", id) - .eq("documents.owner_id", user.id) - .maybeSingle(); - - if (jobError) throw new Error(jobError.message); - if (!job) return NextResponse.json({ error: "Ingestion job not found." }, { status: 404 }); - - // IDX-R16: refuse to retry a job that already completed. Resetting a - // completed job re-pends terminal work; a worker then re-claims it and - // rebuilds against the live index (zombie re-ingest). Rebuild via reindex. - const retryRejection = ingestionJobRetryRejectionReason(job.status); - if (retryRejection) { - return NextResponse.json({ error: retryRejection }, { status: 409 }); - } - - // IDX-C3 / B6: refuse to retry a job a live worker still holds, atomically. - // A SELECT-then-UPDATE was a TOCTOU race: a worker could claim the job - // between the read and the write, and the unguarded UPDATE would silently - // reset the row the worker is processing → two workers ingest the same - // document_id and interleave mixed-generation clinical chunks. We instead - // make the reset a single conditional UPDATE whose WHERE clause refuses to - // touch a row that is freshly 'processing'. The reset only applies when the - // job is NOT processing, OR its lock is already stale, OR it has no lock. const staleThreshold = new Date(Date.now() - env.WORKER_STALE_AFTER_MINUTES * 60_000).toISOString(); - - // Rollback fence: next_run_at doubles as a per-request stamp. Two - // overlapping retries write generically identical resets (pending/queued/ - // attempt 0), so the rollback below additionally matches on this exact - // value — a stale rollback from the losing request affects zero rows - // instead of reverting the winning request's reset. const resetNextRunAt = ingestionRollbackFenceStamp(); - - const { data, error } = await supabase - .from("ingestion_jobs") - .update({ - status: "pending", - stage: "queued", - progress: 0, - error_message: null, - attempt_count: 0, - max_attempts: env.WORKER_MAX_ATTEMPTS, - locked_at: null, - locked_by: null, - next_run_at: resetNextRunAt, - completed_at: null, - }) - .eq("id", id) - .or(`status.neq.processing,locked_at.is.null,locked_at.lt.${staleThreshold}`) - .select() - .maybeSingle(); - + const { data, error } = await supabase.rpc("retry_ingestion_job_if_idle", { + p_job_id: id, + p_owner_id: user.id, + p_stale_before: staleThreshold, + p_max_attempts: env.WORKER_MAX_ATTEMPTS, + p_next_run_at: resetNextRunAt, + p_document_updated_at: ingestionRollbackFenceStamp(), + }); if (error) throw new Error(error.message); - // 0 rows affected means the guard rejected the reset: the job is actively - // being processed with a fresh lock. Refuse rather than clobber it. - if (!data) { + + const parsed = ingestionRetryResultSchema.safeParse(data); + if (!parsed.success) throw new Error("retry_ingestion_job_if_idle returned an invalid result."); + if (parsed.data.outcome === "not_found") { + return NextResponse.json({ error: "Ingestion job not found." }, { status: 404 }); + } + if (parsed.data.outcome === "completed") { + return NextResponse.json({ error: ingestionJobRetryRejectionReason("completed") }, { status: 409 }); + } + if (parsed.data.outcome === "active_worker") { return NextResponse.json( { error: "This job is still being processed by a worker. Wait for it to finish or go stale before retrying.", @@ -91,59 +58,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: ); } - // IDX-H1: do NOT reset the document index here. The worker calls resetDocumentIndex at - // job start (worker/main.ts), so resetting before enqueue would leave a previously-good - // clinical document with zero index if the worker never runs or fails permanently. We - // only re-queue; the prior index stays live until the worker commits a fresh one. - // updated_at is stamped so the reindex routes' rollback fences (which - // match on documents.updated_at) can see this competing queue-state write. - // - // IDX-R15: never demote an already-indexed document to `queued`. That write - // alone (independent of resetDocumentIndex above) forces the worker's - // destructive non-atomic path, which deletes the live index at job start. - // retryDocumentQueueUpdate keeps indexed documents on the atomic path. - const documentRow = (Array.isArray(job.documents) ? job.documents[0] : job.documents) as - { status?: string | null } | null | undefined; - const { error: documentError } = await supabase - .from("documents") - .update( - retryDocumentQueueUpdate({ - documentStatus: documentRow?.status ?? null, - fenceStamp: ingestionRollbackFenceStamp(), - }), - ) - .eq("id", job.document_id) - .eq("owner_id", user.id); - if (documentError) { - const { error: rollbackError } = await supabase - .from("ingestion_jobs") - .update({ - status: job.status, - stage: job.stage, - progress: job.progress, - error_message: job.error_message, - attempt_count: job.attempt_count, - max_attempts: job.max_attempts, - locked_at: job.locked_at, - locked_by: job.locked_by, - next_run_at: job.next_run_at, - completed_at: job.completed_at, - }) - .eq("id", id) - .eq("status", "pending") - .eq("stage", "queued") - .eq("progress", 0) - .eq("attempt_count", 0) - .is("locked_at", null) - .is("locked_by", null) - .eq("next_run_at", resetNextRunAt); - if (rollbackError) { - throw new Error(`${documentError.message}; failed to roll back retried job state: ${rollbackError.message}`); - } - throw new Error(documentError.message); - } - - return NextResponse.json({ job: data }); + return NextResponse.json({ job: parsed.data.job }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); diff --git a/src/lib/extractors/document.ts b/src/lib/extractors/document.ts index 67b316f4a..8dbdcaa2a 100644 --- a/src/lib/extractors/document.ts +++ b/src/lib/extractors/document.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import type { ChildProcess } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import ExcelJS from "exceljs"; @@ -9,6 +10,14 @@ import JSZip from "jszip"; import { safeBufferFrom } from "@/lib/safe-buffer"; import { z } from "zod"; import type { ExtractedDocument, ExtractedPage } from "@/lib/types"; +import { + assertExtractedPdfBudget, + isPdfExtractionResourceError, + PDF_EXTRACTION_BUDGET, + PdfExtractionBudgetTracker, + PdfExtractionResourceError, + type PdfExtractionBudget, +} from "@/lib/extractors/pdf-extraction-budget"; const extractedPageSchema = z.object({ pageNumber: z.number().int().positive(), @@ -33,41 +42,153 @@ const extractedDocumentSchema = z.object({ images: z.array(extractedImageSchema), warnings: z.array(z.string()).optional(), temporaryPaths: z.array(z.string()).optional(), + budgetUsage: z + .object({ + pages: z.number().int().nonnegative(), + artifacts: z.number().int().nonnegative(), + artifactBytes: z.number().int().nonnegative(), + textBytes: z.number().int().nonnegative(), + }) + .optional(), }); export function parseExtractedDocumentPayload(raw: string): ExtractedDocument { return extractedDocumentSchema.parse(JSON.parse(raw)); } -function runPythonPdfExtractor(filePath: string, outputDir: string) { - const scriptPath = path.join(process.cwd(), "worker", "python", "extract_pdf_assets.py"); +export async function terminateProcessTree(child: ChildProcess) { + if (!child.pid || child.exitCode !== null) return; + if (process.platform === "win32") { + await new Promise((resolve) => { + const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { + stdio: "ignore", + windowsHide: true, + }); + killer.once("error", () => { + child.kill("SIGKILL"); + resolve(); + }); + killer.once("close", () => resolve()); + }); + return; + } + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } +} + +export async function runPythonPdfExtractor( + filePath: string, + outputDir: string, + limits: PdfExtractionBudget = PDF_EXTRACTION_BUDGET, + scriptPathOverride?: string, +) { + const scriptPath = scriptPathOverride ?? path.join(process.cwd(), "worker", "python", "extract_pdf_assets.py"); const outputJsonPath = path.join(outputDir, "extract.json"); + const budgetPath = path.join(path.dirname(outputDir), "pdf-extraction-budget.json"); + await writeFile(budgetPath, JSON.stringify(limits), "utf8"); return new Promise((resolve, reject) => { - const child = spawn(process.env.PYTHON_BIN || "python", [scriptPath, filePath, outputDir, outputJsonPath], { - cwd: process.cwd(), - stdio: ["ignore", "pipe", "pipe"], - }); + const child = spawn( + process.env.PYTHON_BIN || "python", + [scriptPath, filePath, outputDir, outputJsonPath, budgetPath], + { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + windowsHide: true, + }, + ); let stdout = ""; let stderr = ""; + let deadlineExceeded = false; + let outputExceeded = false; + let settled = false; + let terminationPromise: Promise | null = null; + const terminate = () => { + terminationPromise ??= terminateProcessTree(child); + return terminationPromise; + }; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + callback(); + }; + const deadline = setTimeout(() => { + deadlineExceeded = true; + void terminate(); + }, limits.totalTimeoutMs); + deadline.unref(); + child.stdout.on("data", (chunk) => { stdout += chunk.toString(); + if (Buffer.byteLength(stdout, "utf8") > limits.maxResultBytes) { + outputExceeded = true; + void terminate(); + } }); child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); + if (stderr.length < 1024 * 1024) stderr += chunk.toString(); }); + child.once("error", (error) => finish(() => reject(error))); child.on("close", async (code) => { + await terminationPromise; + if (deadlineExceeded) { + finish(() => + reject( + new PdfExtractionResourceError( + "PDF_EXTRACTION_DEADLINE_EXCEEDED", + `Python extraction exceeded ${limits.totalTimeoutMs} ms`, + ), + ), + ); + return; + } + if (outputExceeded) { + finish(() => + reject( + new PdfExtractionResourceError( + "PDF_EXTRACTION_BUDGET_EXCEEDED", + `extractor stdout exceeded ${limits.maxResultBytes} bytes`, + ), + ), + ); + return; + } if (code !== 0) { - reject(new Error(stderr || `PDF extractor exited with code ${code}`)); + if (code === 3 || stderr.includes("PDF_EXTRACTION_BUDGET_EXCEEDED")) { + finish(() => + reject( + new PdfExtractionResourceError( + "PDF_EXTRACTION_BUDGET_EXCEEDED", + stderr.trim() || "Python extraction exceeded a resource limit", + ), + ), + ); + return; + } + finish(() => reject(new Error(stderr || `PDF extractor exited with code ${code}`))); return; } try { - const jsonPayload = await readFile(outputJsonPath, "utf8").catch(() => extractJsonFromStdout(stdout)); - resolve(parseExtractedDocumentPayload(jsonPayload)); + const outputMetadata = await stat(outputJsonPath).catch(() => null); + if (outputMetadata && outputMetadata.size > limits.maxResultBytes) { + throw new PdfExtractionResourceError( + "PDF_EXTRACTION_BUDGET_EXCEEDED", + `result JSON exceeds ${limits.maxResultBytes} bytes`, + ); + } + const jsonPayload = outputMetadata ? await readFile(outputJsonPath, "utf8") : extractJsonFromStdout(stdout); + const extracted = parseExtractedDocumentPayload(jsonPayload); + await assertExtractedPdfBudget(extracted, jsonPayload, limits); + finish(() => resolve(extracted)); } catch (error) { - reject(error); + finish(() => reject(error)); } }); }); @@ -80,7 +201,11 @@ function extractJsonFromStdout(stdout: string) { return stdout.slice(start, end + 1); } -async function extractPdf(buffer: Buffer) { +export async function extractPdf( + buffer: Buffer, + options: { limits?: PdfExtractionBudget; scriptPathOverride?: string } = {}, +) { + const limits = options.limits ?? PDF_EXTRACTION_BUDGET; const tempRoot = await mkdtemp(path.join(tmpdir(), "clinical-kb-")); const pdfPath = path.join(tempRoot, "document.pdf"); const imageDir = path.join(tempRoot, "images"); @@ -88,15 +213,30 @@ async function extractPdf(buffer: Buffer) { await writeFile(pdfPath, buffer); try { - const extracted = await runPythonPdfExtractor(pdfPath, imageDir); + const extracted = await runPythonPdfExtractor(pdfPath, imageDir, limits, options.scriptPathOverride); return { ...extracted, temporaryPaths: [tempRoot] }; - } catch { + } catch (error) { + if (isPdfExtractionResourceError(error)) { + await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); + throw error; + } // Fallback for developer machines without PyMuPDF/pytesseract. It still // indexes text PDFs, but scanned PDFs and image extraction need the Python // helper dependencies listed in worker/python/requirements.txt. const parser = new PDFParse({ data: buffer }); try { const parsed = await parser.getText(); + const budget = new PdfExtractionBudgetTracker(limits); + const rawPages: ExtractedPage[] = + parsed.pages.length > 0 + ? parsed.pages.map((page: { num: number; text?: string }) => ({ + pageNumber: page.num, + text: page.text || "", + ocrUsed: false, + })) + : [{ pageNumber: 1, text: parsed.text || "", ocrUsed: false }]; + for (const page of rawPages) budget.addPage(page.text); + const imageResult = await parser.getImage({ imageBuffer: true, imageDataUrl: true, @@ -111,6 +251,7 @@ async function extractPdf(buffer: Buffer) { const outputPath = path.join(imageDir, `fallback-page-${page.pageNumber}-image-${index + 1}.${extension}`); const bytes = dataUrlMatch ? safeBufferFrom(dataUrlMatch[2], "base64") : Buffer.from(image.data); if (!bytes) continue; + budget.addArtifact(bytes.byteLength); await writeFile(outputPath, bytes); images.push({ pageNumber: page.pageNumber, @@ -138,15 +279,6 @@ async function extractPdf(buffer: Buffer) { imageCountByPage.set(image.pageNumber, (imageCountByPage.get(image.pageNumber) ?? 0) + 1); } - const rawPages: ExtractedPage[] = - parsed.pages.length > 0 - ? parsed.pages.map((page: { num: number; text?: string }) => ({ - pageNumber: page.num, - text: page.text || "", - ocrUsed: false, - })) - : [{ pageNumber: 1, text: parsed.text || "", ocrUsed: false }]; - const pages: ExtractedPage[] = rawPages.map((page) => { const textLength = page.text.trim().length; const hasImages = (imageCountByPage.get(page.pageNumber) ?? 0) > 0; @@ -160,7 +292,9 @@ async function extractPdf(buffer: Buffer) { warnings.push(`needs_ocr: ${ocrNeededPages} page(s) appear image-only and were not OCR'd by the JS fallback.`); } - return { pages, images, warnings, temporaryPaths: [tempRoot] }; + const result = { pages, images, warnings, temporaryPaths: [tempRoot] }; + budget.assertResult(JSON.stringify(result)); + return result; } catch (fallbackError) { await parser.destroy().catch(() => undefined); await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); diff --git a/src/lib/extractors/pdf-extraction-budget.ts b/src/lib/extractors/pdf-extraction-budget.ts new file mode 100644 index 000000000..7a8ec9a15 --- /dev/null +++ b/src/lib/extractors/pdf-extraction-budget.ts @@ -0,0 +1,106 @@ +import { stat } from "node:fs/promises"; +import type { ExtractedDocument } from "@/lib/types"; + +export const PDF_EXTRACTION_BUDGET = { + version: 1, + maxRenderPixels: 4_000_000, + maxPages: 5_000, + maxArtifacts: 10_000, + maxArtifactBytes: 512 * 1024 * 1024, + maxTextBytes: 64 * 1024 * 1024, + maxResultBytes: 64 * 1024 * 1024, + ocrPageTimeoutSeconds: 60, + totalTimeoutMs: 15 * 60 * 1_000, +} as const; + +export type PdfExtractionBudget = { + version: number; + maxRenderPixels: number; + maxPages: number; + maxArtifacts: number; + maxArtifactBytes: number; + maxTextBytes: number; + maxResultBytes: number; + ocrPageTimeoutSeconds: number; + totalTimeoutMs: number; +}; + +export const PDF_EXTRACTION_RESOURCE_ERROR_CODES = [ + "PDF_EXTRACTION_BUDGET_EXCEEDED", + "PDF_EXTRACTION_DEADLINE_EXCEEDED", +] as const; + +export type PdfExtractionResourceErrorCode = (typeof PDF_EXTRACTION_RESOURCE_ERROR_CODES)[number]; + +export class PdfExtractionResourceError extends Error { + readonly code: PdfExtractionResourceErrorCode; + + constructor(code: PdfExtractionResourceErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "PdfExtractionResourceError"; + this.code = code; + } +} + +export function isPdfExtractionResourceError(error: unknown): error is PdfExtractionResourceError { + if (error instanceof PdfExtractionResourceError) return true; + const message = error instanceof Error ? error.message : String(error); + return PDF_EXTRACTION_RESOURCE_ERROR_CODES.some((code) => message.includes(code)); +} + +function budgetExceeded(message: string): never { + throw new PdfExtractionResourceError("PDF_EXTRACTION_BUDGET_EXCEEDED", message); +} + +export class PdfExtractionBudgetTracker { + private pageCount = 0; + private artifactCount = 0; + private artifactBytes = 0; + private textBytes = 0; + + constructor(readonly limits: PdfExtractionBudget = PDF_EXTRACTION_BUDGET) {} + + addPage(text: string) { + this.pageCount += 1; + if (this.pageCount > this.limits.maxPages) { + budgetExceeded(`page count ${this.pageCount} exceeds ${this.limits.maxPages}`); + } + this.textBytes += Buffer.byteLength(text, "utf8"); + if (this.textBytes > this.limits.maxTextBytes) { + budgetExceeded(`extracted UTF-8 text exceeds ${this.limits.maxTextBytes} bytes`); + } + } + + addArtifact(byteLength: number) { + this.artifactCount += 1; + if (this.artifactCount > this.limits.maxArtifacts) { + budgetExceeded(`artifact count ${this.artifactCount} exceeds ${this.limits.maxArtifacts}`); + } + this.artifactBytes += Math.max(0, byteLength); + if (this.artifactBytes > this.limits.maxArtifactBytes) { + budgetExceeded(`temporary artifact bytes exceed ${this.limits.maxArtifactBytes}`); + } + } + + assertResult(raw: string | Buffer) { + const byteLength = typeof raw === "string" ? Buffer.byteLength(raw, "utf8") : raw.byteLength; + if (byteLength > this.limits.maxResultBytes) { + budgetExceeded(`result JSON exceeds ${this.limits.maxResultBytes} bytes`); + } + } +} + +export async function assertExtractedPdfBudget( + extracted: ExtractedDocument, + rawResult: string, + limits: PdfExtractionBudget = PDF_EXTRACTION_BUDGET, +) { + const tracker = new PdfExtractionBudgetTracker(limits); + tracker.assertResult(rawResult); + for (const page of extracted.pages) tracker.addPage(page.text); + for (const image of extracted.images) { + const metadata = await stat(image.path).catch(() => null); + if (!metadata?.isFile()) budgetExceeded(`artifact is missing: ${image.path}`); + tracker.addArtifact(metadata.size); + } +} diff --git a/src/lib/ingestion.ts b/src/lib/ingestion.ts index b0e987c71..e483781c0 100644 --- a/src/lib/ingestion.ts +++ b/src/lib/ingestion.ts @@ -1,5 +1,8 @@ +import { isPdfExtractionResourceError } from "@/lib/extractors/pdf-extraction-budget"; + export function isRetryableIngestionError(error: unknown) { const message = error instanceof Error ? error.message : String(error); + if (isPdfExtractionResourceError(error)) return false; // Duplicate-key conflicts — including document_pages page-number duplicates — // are partial index-write conflicts that the worker routes to manual queue // recovery, never auto-retry. (Audit L17: removed an unreachable retry diff --git a/src/lib/publication-manifest.ts b/src/lib/publication-manifest.ts new file mode 100644 index 000000000..f42381e24 --- /dev/null +++ b/src/lib/publication-manifest.ts @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; + +const publicationDecisionSchema = z.enum(["approved", "keep_private", "quarantine"]); + +const publicationManifestSchema = z + .object({ + version: z.literal(1), + approvingOperatorId: z.string().uuid(), + reason: z.string().trim().min(3).max(2000), + evidenceReferences: z.array(z.string().trim().min(1)).min(1), + documents: z + .array( + z.object({ + documentId: z.string().uuid(), + expectedOwnerId: z.string().uuid(), + decision: publicationDecisionSchema, + }), + ) + .min(1), + }) + .superRefine((manifest, context) => { + const seen = new Set(); + for (const [index, document] of manifest.documents.entries()) { + if (seen.has(document.documentId)) { + context.addIssue({ + code: "custom", + message: "documentId values must be unique", + path: ["documents", index, "documentId"], + }); + } + seen.add(document.documentId); + } + }); + +export type PublicationManifest = z.infer; + +export type PublicationCommandArgs = { + manifestPath: string; + apply: boolean; + expectedCount?: number; + confirmSha256?: string; +}; + +export function parsePublicationManifest(raw: string): PublicationManifest { + return publicationManifestSchema.parse(JSON.parse(raw)); +} + +export function publicationManifestDigest(raw: string | Buffer) { + return createHash("sha256").update(raw).digest("hex"); +} + +export function parsePublicationCommandArgs(argv: string[]): PublicationCommandArgs { + let manifestPath: string | undefined; + let expectedCount: number | undefined; + let confirmSha256: string | undefined; + let apply = false; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--manifest") { + manifestPath = argv[++index]; + } else if (token === "--expected-count") { + expectedCount = Number(argv[++index]); + } else if (token === "--confirm-sha256") { + confirmSha256 = argv[++index]?.toLowerCase(); + } else if (token === "--apply") { + apply = true; + } else { + throw new Error(`Unknown argument: ${token}`); + } + } + + if (!manifestPath) throw new Error("--manifest is required"); + if (expectedCount !== undefined && (!Number.isSafeInteger(expectedCount) || expectedCount < 1)) { + throw new Error("--expected-count must be a positive integer"); + } + if (confirmSha256 !== undefined && !/^[0-9a-f]{64}$/.test(confirmSha256)) { + throw new Error("--confirm-sha256 must be a SHA-256 digest"); + } + if (apply && expectedCount === undefined) throw new Error("--expected-count is required with --apply"); + if (apply && confirmSha256 === undefined) throw new Error("--confirm-sha256 is required with --apply"); + + return { manifestPath, apply, expectedCount, confirmSha256 }; +} + +export function assertPublicationApplyConfirmation(args: { + manifest: PublicationManifest; + digest: string; + expectedCount?: number; + confirmSha256?: string; +}) { + if (args.expectedCount !== args.manifest.documents.length) { + throw new Error( + `Expected count ${String(args.expectedCount)} does not match manifest count ${args.manifest.documents.length}`, + ); + } + if (args.confirmSha256 !== args.digest) { + throw new Error(`Confirmed SHA-256 does not match manifest digest ${args.digest}`); + } +} diff --git a/src/lib/storage-upload-compensation.ts b/src/lib/storage-upload-compensation.ts new file mode 100644 index 000000000..5b3fe266f --- /dev/null +++ b/src/lib/storage-upload-compensation.ts @@ -0,0 +1,42 @@ +type StorageRemovalError = { message: string } | null; + +export type UploadedArtifactStorage = { + from: (bucket: string) => { + remove: (paths: string[]) => Promise<{ error: StorageRemovalError }>; + }; +}; + +function errorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (error && typeof error === "object" && "message" in error) return String(error.message); + return String(error); +} + +/** + * Remove one newly uploaded object when its corresponding database write did + * not commit. Successful compensation preserves the original persistence + * error; failed compensation reports both failures so the orphan is visible. + */ +export async function compensateUploadedArtifactAndThrow(args: { + storage: UploadedArtifactStorage; + bucket: string; + path: string; + persistenceError: unknown; +}): Promise { + let cleanupError: unknown = null; + try { + const cleanup = await args.storage.from(args.bucket).remove([args.path]); + cleanupError = cleanup.error; + } catch (error) { + cleanupError = error; + } + + if (cleanupError) { + throw new Error( + `Artifact persistence failed: ${errorMessage(args.persistenceError)}; uploaded artifact cleanup failed: ${errorMessage(cleanupError)}`, + { cause: args.persistenceError }, + ); + } + if (args.persistenceError instanceof Error) throw args.persistenceError; + throw new Error(errorMessage(args.persistenceError)); +} diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index f00a72ba9..b4a4b9375 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -889,6 +889,42 @@ export type Database = { }, ]; }; + document_publication_approvals: { + Row: { + approved_at: string; + approving_operator_id: string; + decision: string; + document_id: string; + evidence_references: string[]; + expected_prior_owner_id: string; + id: string; + manifest_digest: string; + reason: string; + }; + Insert: { + approved_at?: string; + approving_operator_id: string; + decision: string; + document_id: string; + evidence_references: string[]; + expected_prior_owner_id: string; + id?: string; + manifest_digest: string; + reason: string; + }; + Update: { + approved_at?: string; + approving_operator_id?: string; + decision?: string; + document_id?: string; + evidence_references?: string[]; + expected_prior_owner_id?: string; + id?: string; + manifest_digest?: string; + reason?: string; + }; + Relationships: []; + }; document_sections: { Row: { artifact_generation_id: string | null; @@ -2280,6 +2316,30 @@ export type Database = { Args: { input_query: string; min_sim?: number }; Returns: string; }; + default_privileges_status: { + Args: { p_role_name?: string; p_schema_name?: string }; + Returns: Json; + }; + delete_document_if_idle: { + Args: { + p_document_bucket: string; + p_document_id: string; + p_image_bucket: string; + p_owner_id: string; + }; + Returns: Json; + }; + retry_ingestion_job_if_idle: { + Args: { + p_document_updated_at: string; + p_job_id: string; + p_max_attempts: number; + p_next_run_at: string; + p_owner_id: string; + p_stale_before: string; + }; + Returns: Json; + }; detect_legacy_ivfflat_indexes: { Args: never; Returns: string[] }; document_label_metadata: { Args: { p_document_id: string }; @@ -2721,6 +2781,14 @@ export type Database = { Args: { owner_filter: string; row_owner_id: string | null; include_public?: boolean }; Returns: boolean; }; + publish_approved_documents: { + Args: { + p_documents: Json; + p_expected_count: number; + p_manifest_digest: string; + }; + Returns: Json; + }; purge_expired_rag_queries: { Args: { p_retention_days?: number }; Returns: number; diff --git a/src/lib/types.ts b/src/lib/types.ts index 12ab73e14..a71f4e707 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1043,6 +1043,12 @@ export type ExtractedDocument = { images: ExtractedImage[]; warnings?: string[]; temporaryPaths?: string[]; + budgetUsage?: { + pages: number; + artifacts: number; + artifactBytes: number; + textBytes: number; + }; }; export type ChunkInput = { diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 44f0b66f7..4c7e9a0a4 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-17T11:30:04.370Z", + "generated_at": "2026-07-17T14:08:59.595Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "59ee18d0723774416b931bea9f4fef96fa699eb99ba61d218b6870a621dde649", - "replay_seconds": 45, + "schema_sha256": "e93fe90cae38654e77b7be0f81e5a0c90b0c7db2fd37d76e32f4f52a089eff45", + "replay_seconds": 30, "snapshot": { "views": [ { @@ -1819,6 +1819,90 @@ "rls_forced": false, "rls_enabled": true }, + { + "acl": [ + "postgres=arwdDxtm/postgres", + "service_role=arwdDxtm/postgres" + ], + "name": "document_publication_approvals", + "columns": [ + { + "name": "approved_at", + "type": "timestamp with time zone", + "default": "now()", + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "approving_operator_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "decision", + "type": "text", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "document_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "evidence_references", + "type": "text[]", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "expected_prior_owner_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "id", + "type": "uuid", + "default": "gen_random_uuid()", + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "manifest_digest", + "type": "text", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "reason", + "type": "text", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + } + ], + "reloptions": null, + "rls_forced": false, + "rls_enabled": true + }, { "acl": [ "postgres=arwdDxtm/postgres", @@ -5028,6 +5112,24 @@ "table": "document_pages", "def_hash": "001064d4f85791f720b56615ca14500f" }, + { + "def": "CREATE UNIQUE INDEX document_publication_approval_document_id_expected_prior_ow_key ON public.document_publication_approvals USING btree (document_id, expected_prior_owner_id, manifest_digest)", + "name": "document_publication_approval_document_id_expected_prior_ow_key", + "table": "document_publication_approvals", + "def_hash": "b9adf9d81f711d3cead9d3e44e734995" + }, + { + "def": "CREATE INDEX document_publication_approvals_document_idx ON public.document_publication_approvals USING btree (document_id, approved_at DESC)", + "name": "document_publication_approvals_document_idx", + "table": "document_publication_approvals", + "def_hash": "a80988eb1419a39ddc008d71deceb309" + }, + { + "def": "CREATE UNIQUE INDEX document_publication_approvals_pkey ON public.document_publication_approvals USING btree (id)", + "name": "document_publication_approvals_pkey", + "table": "document_publication_approvals", + "def_hash": "10ba75d5f5e4bb0374659bd6c8ac4167" + }, { "def": "CREATE INDEX document_sections_document_generation_idx ON public.document_sections USING btree (document_id, index_generation_id) WHERE (index_generation_id IS NOT NULL)", "name": "document_sections_document_generation_idx", @@ -5852,6 +5954,18 @@ "permissive": "PERMISSIVE", "with_check": null }, + { + "cmd": "ALL", + "name": "document publication approvals service role", + "qual": "true", + "roles": [ + "service_role" + ], + "table": "document_publication_approvals", + "schema": "public", + "permissive": "PERMISSIVE", + "with_check": "true" + }, { "cmd": "ALL", "name": "document sections owner all", @@ -6143,11 +6257,21 @@ "name": "document_memory_cards_updated_at", "table": "document_memory_cards" }, + { + "def": "CREATE TRIGGER document_publication_approvals_immutable BEFORE DELETE OR UPDATE ON public.document_publication_approvals FOR EACH ROW EXECUTE FUNCTION public.prevent_document_publication_approval_mutation()", + "name": "document_publication_approvals_immutable", + "table": "document_publication_approvals" + }, { "def": "CREATE TRIGGER document_sections_updated_at BEFORE UPDATE ON public.document_sections FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "document_sections_updated_at", "table": "document_sections" }, + { + "def": "CREATE TRIGGER documents_require_publication_approval BEFORE INSERT OR UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.guard_document_publication_transition()", + "name": "documents_require_publication_approval", + "table": "documents" + }, { "def": "CREATE TRIGGER documents_updated_at BEFORE UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "documents_updated_at", @@ -6340,6 +6464,22 @@ "def_hash": "a5ebdd1ec14008cdf0c8601a32b4d24c", "signature": "public.correct_clinical_query_terms(text,real)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "675d521a0746befe09e68e47986c6355", + "signature": "public.default_privileges_status(text,text)" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "cf81c12782a5f977f4cddee4bd918b84", + "signature": "public.delete_document_if_idle(uuid,uuid,text,text)" + }, { "acl": [ "postgres=X/postgres", @@ -6404,6 +6544,14 @@ "def_hash": "107d6e5eb6d846971b48102c258ab2eb", "signature": "public.get_visual_evidence_cards(uuid,integer)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "2fef16fbe1d1e07658b5ce051f6e7d7a", + "signature": "public.guard_document_publication_transition()" + }, { "acl": [ "postgres=X/postgres", @@ -6636,6 +6784,14 @@ "def_hash": "8248e8d1aec23bbad35aca0ddc4dc94d", "signature": "public.match_documents_for_query(text,integer,uuid)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "b0947f8a0d37d0d8dda4185008c0d51a", + "signature": "public.prevent_document_publication_approval_mutation()" + }, { "acl": [ "=X/postgres", @@ -6645,6 +6801,14 @@ "def_hash": "3c5d2fe027c7be638f4ade47eca4abd8", "signature": "public.prevent_source_review_event_mutation()" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "406d621ed9ad3b052b9282e7e980813a", + "signature": "public.publish_approved_documents(jsonb,text,integer)" + }, { "acl": [ "postgres=X/postgres", @@ -6741,6 +6905,14 @@ "def_hash": "ca2877374851b432c4ca971e1e15f746", "signature": "public.retrieval_owner_matches(uuid,uuid)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "39b1d8de34c4ac584dee044282e7481b", + "signature": "public.retry_ingestion_job_if_idle(uuid,uuid,timestamp with time zone,integer,timestamp with time zone,timestamp with time zone)" + }, { "acl": [ "postgres=X/postgres", @@ -6770,7 +6942,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "e546ed66f9b45a3d4905cc911fcbed62", + "def_hash": "23795071424c8e1b9a53fb659e93a310", "signature": "public.search_document_chunks(uuid,text,integer,uuid)" }, { @@ -7224,6 +7396,36 @@ "name": "document_pages_pkey", "table": "document_pages" }, + { + "def": "UNIQUE (document_id, expected_prior_owner_id, manifest_digest)", + "name": "document_publication_approval_document_id_expected_prior_ow_key", + "table": "document_publication_approvals" + }, + { + "def": "CHECK ((decision = ANY (ARRAY['approved'::text, 'keep_private'::text, 'quarantine'::text])))", + "name": "document_publication_approvals_decision_check", + "table": "document_publication_approvals" + }, + { + "def": "CHECK ((cardinality(evidence_references) > 0))", + "name": "document_publication_approvals_evidence_references_check", + "table": "document_publication_approvals" + }, + { + "def": "CHECK ((manifest_digest ~ '^[0-9a-f]{64}$'::text))", + "name": "document_publication_approvals_manifest_digest_check", + "table": "document_publication_approvals" + }, + { + "def": "PRIMARY KEY (id)", + "name": "document_publication_approvals_pkey", + "table": "document_publication_approvals" + }, + { + "def": "CHECK (((char_length(TRIM(BOTH FROM reason)) >= 3) AND (char_length(TRIM(BOTH FROM reason)) <= 2000)))", + "name": "document_publication_approvals_reason_check", + "table": "document_publication_approvals" + }, { "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE", "name": "document_sections_document_id_fkey", diff --git a/supabase/migrations/20260717130000_filter_search_document_chunks_committed_generation.sql b/supabase/migrations/20260717130000_filter_search_document_chunks_committed_generation.sql new file mode 100644 index 000000000..c2f82cf70 --- /dev/null +++ b/supabase/migrations/20260717130000_filter_search_document_chunks_committed_generation.sql @@ -0,0 +1,73 @@ +-- Keep per-document lexical search on the document's committed index generation. +-- Legacy rows remain visible only while both the chunk and document generation are null. + +create or replace function public.search_document_chunks( + p_document_id uuid, + p_query text, + match_count integer default 20, + p_owner_id uuid default null +) +returns table ( + id uuid, + page_number integer, + chunk_index integer, + section_heading text, + content text, + image_ids uuid[], + text_rank real, + trigram_score real +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with normalized as ( + select + websearch_to_tsquery('english', coalesce(p_query, '')) as query_tsv, + lower(trim(coalesce(p_query, ''))) as query_text + ), + tokens as ( + select distinct token + from normalized, + lateral regexp_split_to_table(normalized.query_text, '\s+') as token + where length(token) >= 3 + ) + select + c.id, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.image_ids, + ts_rank_cd(c.search_tsv, normalized.query_tsv)::real as text_rank, + similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text)::real as trigram_score + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join normalized + where c.document_id = p_document_id + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.index_generation_id) + and ( + (p_owner_id is null and d.owner_id is null) + or (p_owner_id is not null and (d.owner_id is null or d.owner_id = p_owner_id)) + ) + and ( + c.search_tsv @@ normalized.query_tsv + or lower(coalesce(c.section_heading, '') || ' ' || c.content) % normalized.query_text + or lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || normalized.query_text || '%' + or exists ( + select 1 + from tokens t + where lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || t.token || '%' + or lower(coalesce(c.section_heading, '') || ' ' || c.content) % t.token + ) + ) + order by + ts_rank_cd(c.search_tsv, normalized.query_tsv) desc, + similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text) desc, + c.chunk_index asc + limit least(greatest(match_count, 1), 80); +$$; + +revoke execute on function public.search_document_chunks(uuid, text, integer, uuid) from public, anon, authenticated; +grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role; diff --git a/supabase/migrations/20260717131000_guard_document_publication_approval.sql b/supabase/migrations/20260717131000_guard_document_publication_approval.sql new file mode 100644 index 000000000..a2cc7f8e8 --- /dev/null +++ b/supabase/migrations/20260717131000_guard_document_publication_approval.sql @@ -0,0 +1,224 @@ +-- Require durable operator evidence before an owned document can enter the public corpus. +-- Historical public rows are deliberately left untouched for separate operator investigation. + +create table if not exists public.document_publication_approvals ( + id uuid primary key default gen_random_uuid(), + document_id uuid not null, + expected_prior_owner_id uuid not null, + approving_operator_id uuid not null, + decision text not null check (decision in ('approved', 'keep_private', 'quarantine')), + reason text not null check (char_length(trim(reason)) between 3 and 2000), + evidence_references text[] not null check (cardinality(evidence_references) > 0), + manifest_digest text not null check (manifest_digest ~ '^[0-9a-f]{64}$'), + approved_at timestamptz not null default now(), + unique (document_id, expected_prior_owner_id, manifest_digest) +); + +create index if not exists document_publication_approvals_document_idx + on public.document_publication_approvals(document_id, approved_at desc); + +alter table public.document_publication_approvals enable row level security; +revoke all on table public.document_publication_approvals from public, anon, authenticated; +grant select, insert on table public.document_publication_approvals to service_role; + +drop policy if exists "document publication approvals service role" on public.document_publication_approvals; +create policy "document publication approvals service role" + on public.document_publication_approvals for all to service_role using (true) with check (true); + +create or replace function public.prevent_document_publication_approval_mutation() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + raise exception 'document_publication_approvals is append-only'; +end; +$$; + +revoke all on function public.prevent_document_publication_approval_mutation() from public, anon, authenticated; + +drop trigger if exists document_publication_approvals_immutable on public.document_publication_approvals; +create trigger document_publication_approvals_immutable +before update or delete on public.document_publication_approvals +for each row execute function public.prevent_document_publication_approval_mutation(); + +create or replace function public.guard_document_publication_transition() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + v_approval_id uuid; + v_manifest_digest text; +begin + if tg_op = 'INSERT' then + if new.owner_id is null then + raise exception 'public documents must be created as owned rows before approved publication'; + end if; + return new; + end if; + + if old.owner_id is not null and new.owner_id is null then + begin + v_approval_id := nullif(new.metadata->>'publication_approval_id', '')::uuid; + exception when invalid_text_representation then + raise exception 'public document transition has an invalid publication approval id'; + end; + v_manifest_digest := lower(coalesce(new.metadata->>'publication_manifest_digest', '')); + + if v_approval_id is null or v_manifest_digest !~ '^[0-9a-f]{64}$' then + raise exception 'public document transition requires publication approval evidence'; + end if; + + if not exists ( + select 1 + from public.document_publication_approvals approval + where approval.id = v_approval_id + and approval.document_id = old.id + and approval.expected_prior_owner_id = old.owner_id + and approval.decision = 'approved' + and approval.manifest_digest = v_manifest_digest + ) then + raise exception 'public document transition approval does not match the document, prior owner, decision, and manifest'; + end if; + end if; + return new; +end; +$$; + +revoke all on function public.guard_document_publication_transition() from public, anon, authenticated; + +drop trigger if exists documents_require_publication_approval on public.documents; +create trigger documents_require_publication_approval +before insert or update on public.documents +for each row execute function public.guard_document_publication_transition(); + +create or replace function public.publish_approved_documents( + p_documents jsonb, + p_manifest_digest text, + p_expected_count integer +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_entry jsonb; + v_document public.documents%rowtype; + v_document_id uuid; + v_expected_owner_id uuid; + v_approval_id uuid; + v_manifest_digest text := lower(trim(coalesce(p_manifest_digest, ''))); + v_count integer; + v_results jsonb := '[]'::jsonb; +begin + if jsonb_typeof(p_documents) is distinct from 'array' then + raise exception 'publication documents must be a JSON array'; + end if; + if v_manifest_digest !~ '^[0-9a-f]{64}$' then + raise exception 'publication manifest digest must be a lowercase SHA-256 value'; + end if; + + v_count := jsonb_array_length(p_documents); + if p_expected_count is null or p_expected_count < 1 or p_expected_count <> v_count then + raise exception 'publication expected count % does not match manifest count %', p_expected_count, v_count; + end if; + if exists ( + select 1 + from jsonb_array_elements(p_documents) entry + group by entry->>'document_id' + having count(*) > 1 + ) then + raise exception 'publication manifest contains duplicate document ids'; + end if; + + for v_entry in select value from jsonb_array_elements(p_documents) + loop + begin + v_document_id := nullif(v_entry->>'document_id', '')::uuid; + v_expected_owner_id := nullif(v_entry->>'expected_owner_id', '')::uuid; + exception when invalid_text_representation then + raise exception 'publication manifest contains an invalid document or owner id'; + end; + if v_document_id is null or v_expected_owner_id is null then + raise exception 'publication manifest requires document_id and expected_owner_id'; + end if; + + select * into v_document + from public.documents + where id = v_document_id + for update; + if not found then + raise exception 'publication document % was not found', v_document_id; + end if; + if v_document.owner_id is distinct from v_expected_owner_id then + raise exception 'publication document % owner changed from the manifest expectation', v_document_id; + end if; + if v_document.status <> 'indexed' then + raise exception 'publication document % is not indexed', v_document_id; + end if; + + select approval.id into v_approval_id + from public.document_publication_approvals approval + where approval.document_id = v_document_id + and approval.expected_prior_owner_id = v_expected_owner_id + and approval.decision = 'approved' + and approval.manifest_digest = v_manifest_digest + order by approval.approved_at desc, approval.id desc + limit 1; + if v_approval_id is null then + raise exception 'publication document % lacks matching approved evidence', v_document_id; + end if; + + if exists ( + select 1 from public.document_labels where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_summaries where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_sections where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_memory_cards where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_table_facts where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_embedding_fields where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_index_quality where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_index_units where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + ) then + raise exception 'publication document % has mismatched artifact ownership', v_document_id; + end if; + + update public.document_labels set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_summaries set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_sections set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_memory_cards set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_table_facts set owner_id = null where document_id = v_document_id; + update public.document_embedding_fields set owner_id = null where document_id = v_document_id; + update public.document_index_quality set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_index_units set owner_id = null, updated_at = now() where document_id = v_document_id; + + update public.documents + set owner_id = null, + metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object( + 'public_corpus', true, + 'publication_approval_id', v_approval_id, + 'publication_manifest_digest', v_manifest_digest, + 'published_at', now() + ), + updated_at = now() + where id = v_document_id; + + v_results := v_results || jsonb_build_array(jsonb_build_object( + 'document_id', v_document_id, + 'previous_owner_id', v_expected_owner_id, + 'approval_id', v_approval_id, + 'outcome', 'published' + )); + end loop; + + return jsonb_build_object( + 'manifest_digest', v_manifest_digest, + 'published_count', v_count, + 'documents', v_results + ); +end; +$$; + +revoke all on function public.publish_approved_documents(jsonb, text, integer) from public, anon, authenticated; +grant execute on function public.publish_approved_documents(jsonb, text, integer) to service_role; diff --git a/supabase/migrations/20260717132000_delete_document_if_idle.sql b/supabase/migrations/20260717132000_delete_document_if_idle.sql new file mode 100644 index 000000000..22cea2131 --- /dev/null +++ b/supabase/migrations/20260717132000_delete_document_if_idle.sql @@ -0,0 +1,210 @@ +-- Serialize permanent document deletion with ingestion job creation. The +-- parent row lock conflicts with the FK key-share lock taken by a concurrent +-- ingestion_jobs insert: either the job commits first and deletion returns an +-- active_job outcome, or deletion commits first and the insert receives 23503. +create or replace function public.delete_document_if_idle( + p_document_id uuid, + p_owner_id uuid, + p_document_bucket text, + p_image_bucket text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_document public.documents%rowtype; + v_active_job public.ingestion_jobs%rowtype; + v_cleanup_job_id uuid; + v_image_paths text[] := '{}'::text[]; + v_chunk_ids uuid[] := '{}'::uuid[]; +begin + if p_document_id is null or p_owner_id is null then + raise exception 'Document and owner identifiers are required.' using errcode = '22023'; + end if; + if nullif(btrim(p_document_bucket), '') is null or nullif(btrim(p_image_bucket), '') is null then + raise exception 'Storage bucket names are required.' using errcode = '22023'; + end if; + + select d.* + into v_document + from public.documents d + where d.id = p_document_id + and d.owner_id = p_owner_id + for update; + + if not found then + return jsonb_build_object('outcome', 'not_found'); + end if; + + select j.* + into v_active_job + from public.ingestion_jobs j + where j.document_id = p_document_id + and j.status in ('pending', 'processing') + order by j.created_at asc, j.id asc + limit 1; + + if found then + return jsonb_build_object( + 'outcome', 'active_job', + 'job_id', v_active_job.id, + 'job_status', v_active_job.status + ); + end if; + + select coalesce(array_agg(distinct i.storage_path order by i.storage_path) + filter (where i.storage_path is not null and btrim(i.storage_path) <> ''), '{}'::text[]) + into v_image_paths + from public.document_images i + where i.document_id = p_document_id; + + select coalesce(array_agg(c.id order by c.id), '{}'::uuid[]) + into v_chunk_ids + from public.document_chunks c + where c.document_id = p_document_id; + + insert into public.storage_cleanup_jobs ( + owner_id, + document_id, + document_title, + document_bucket, + document_paths, + image_bucket, + image_paths, + status, + metadata + ) values ( + p_owner_id, + p_document_id, + v_document.title, + p_document_bucket, + case + when v_document.storage_path is null or btrim(v_document.storage_path) = '' then '{}'::text[] + else array[v_document.storage_path] + end, + p_image_bucket, + v_image_paths, + 'pending', + jsonb_build_object( + 'operation', 'permanent_document_delete', + 'created_by', 'delete_document_if_idle' + ) + ) + returning id into v_cleanup_job_id; + + if cardinality(v_chunk_ids) > 0 then + delete from public.rag_queries + where source_chunk_ids && v_chunk_ids; + delete from public.rag_query_misses + where top_chunk_ids && v_chunk_ids + or cited_chunk_ids && v_chunk_ids; + end if; + + delete from public.rag_query_misses + where clicked_document_id = p_document_id + or expected_document_id = p_document_id; + + delete from public.rag_response_cache + where owner_id = p_owner_id + and cache_kind in ('search', 'answer'); + + delete from public.documents where id = p_document_id; + + return jsonb_build_object( + 'outcome', 'deleted', + 'cleanup_job_id', v_cleanup_job_id, + 'document_title', v_document.title, + 'source_path', v_document.storage_path, + 'image_paths', to_jsonb(v_image_paths) + ); +end; +$$; + +revoke all on function public.delete_document_if_idle(uuid, uuid, text, text) + from public, anon, authenticated; +grant execute on function public.delete_document_if_idle(uuid, uuid, text, text) + to service_role; + + +create or replace function public.retry_ingestion_job_if_idle( + p_job_id uuid, + p_owner_id uuid, + p_stale_before timestamptz, + p_max_attempts integer, + p_next_run_at timestamptz, + p_document_updated_at timestamptz +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_job public.ingestion_jobs%rowtype; + v_document_status text; +begin + if p_job_id is null or p_owner_id is null or p_stale_before is null + or p_next_run_at is null or p_document_updated_at is null + or p_max_attempts is null or p_max_attempts < 1 then + raise exception 'Retry identifiers, timestamps, and max attempts are required.' using errcode = '22023'; + end if; + + select j.* + into v_job + from public.ingestion_jobs j + join public.documents d on d.id = j.document_id + where j.id = p_job_id + and d.owner_id = p_owner_id + for update of d, j; + + if not found then + return jsonb_build_object('outcome', 'not_found'); + end if; + + select status + into v_document_status + from public.documents + where id = v_job.document_id; + if v_job.status = 'completed' then + return jsonb_build_object('outcome', 'completed'); + end if; + if v_job.status = 'processing' + and v_job.locked_at is not null + and v_job.locked_at >= p_stale_before then + return jsonb_build_object('outcome', 'active_worker'); + end if; + + update public.ingestion_jobs + set status = 'pending', + stage = 'queued', + progress = 0, + error_message = null, + attempt_count = 0, + max_attempts = p_max_attempts, + locked_at = null, + locked_by = null, + next_run_at = p_next_run_at, + completed_at = null + where id = p_job_id + returning * into v_job; + + update public.documents + set status = case when v_document_status = 'indexed' then status else 'queued' end, + error_message = null, + updated_at = p_document_updated_at + where id = v_job.document_id + and owner_id = p_owner_id; + if not found then + raise exception 'Retry document disappeared while its row lock was held.' using errcode = '23503'; + end if; + + return jsonb_build_object('outcome', 'queued', 'job', to_jsonb(v_job)); +end; +$$; + +revoke all on function public.retry_ingestion_job_if_idle(uuid, uuid, timestamptz, integer, timestamptz, timestamptz) + from public, anon, authenticated; +grant execute on function public.retry_ingestion_job_if_idle(uuid, uuid, timestamptz, integer, timestamptz, timestamptz) + to service_role; diff --git a/supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql b/supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql new file mode 100644 index 000000000..ea4e91025 --- /dev/null +++ b/supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql @@ -0,0 +1,179 @@ +-- 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/roles.sql b/supabase/roles.sql new file mode 100644 index 000000000..c153381ec --- /dev/null +++ b/supabase/roles.sql @@ -0,0 +1,130 @@ +-- 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. + +do $$ +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 = 'supabase_admin'; + 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'; + 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; + + 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 ('supabase_admin', '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' + ); + + if not v_safe then + raise exception using + errcode = '42501', + message = 'Unsafe supabase_admin default privileges; bootstrap must run as supabase_admin.', + detail = to_jsonb(v_entries)::text; + end if; +end; +$$; diff --git a/supabase/schema.sql b/supabase/schema.sql index 292bfeb9f..9a4175541 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -4108,6 +4108,7 @@ as $$ cross join normalized where c.document_id = p_document_id and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.index_generation_id) and ( (p_owner_id is null and d.owner_id is null) or (p_owner_id is not null and (d.owner_id is null or d.owner_id = p_owner_id)) @@ -7525,3 +7526,616 @@ begin or public.retrieval_owner_matches_v2('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', true) then raise exception 'retrieval_owner_matches_v2 truth table failed'; end if; end $$; +-- Require durable operator evidence before an owned document can enter the public corpus. +-- Historical public rows are deliberately left untouched for separate operator investigation. + +create table if not exists public.document_publication_approvals ( + id uuid primary key default gen_random_uuid(), + document_id uuid not null, + expected_prior_owner_id uuid not null, + approving_operator_id uuid not null, + decision text not null check (decision in ('approved', 'keep_private', 'quarantine')), + reason text not null check (char_length(trim(reason)) between 3 and 2000), + evidence_references text[] not null check (cardinality(evidence_references) > 0), + manifest_digest text not null check (manifest_digest ~ '^[0-9a-f]{64}$'), + approved_at timestamptz not null default now(), + unique (document_id, expected_prior_owner_id, manifest_digest) +); + +create index if not exists document_publication_approvals_document_idx + on public.document_publication_approvals(document_id, approved_at desc); + +alter table public.document_publication_approvals enable row level security; +revoke all on table public.document_publication_approvals from public, anon, authenticated; +grant select, insert on table public.document_publication_approvals to service_role; + +drop policy if exists "document publication approvals service role" on public.document_publication_approvals; +create policy "document publication approvals service role" + on public.document_publication_approvals for all to service_role using (true) with check (true); + +create or replace function public.prevent_document_publication_approval_mutation() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + raise exception 'document_publication_approvals is append-only'; +end; +$$; + +revoke all on function public.prevent_document_publication_approval_mutation() from public, anon, authenticated; + +drop trigger if exists document_publication_approvals_immutable on public.document_publication_approvals; +create trigger document_publication_approvals_immutable +before update or delete on public.document_publication_approvals +for each row execute function public.prevent_document_publication_approval_mutation(); + +create or replace function public.guard_document_publication_transition() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + v_approval_id uuid; + v_manifest_digest text; +begin + if tg_op = 'INSERT' then + if new.owner_id is null then + raise exception 'public documents must be created as owned rows before approved publication'; + end if; + return new; + end if; + + if old.owner_id is not null and new.owner_id is null then + begin + v_approval_id := nullif(new.metadata->>'publication_approval_id', '')::uuid; + exception when invalid_text_representation then + raise exception 'public document transition has an invalid publication approval id'; + end; + v_manifest_digest := lower(coalesce(new.metadata->>'publication_manifest_digest', '')); + + if v_approval_id is null or v_manifest_digest !~ '^[0-9a-f]{64}$' then + raise exception 'public document transition requires publication approval evidence'; + end if; + + if not exists ( + select 1 + from public.document_publication_approvals approval + where approval.id = v_approval_id + and approval.document_id = old.id + and approval.expected_prior_owner_id = old.owner_id + and approval.decision = 'approved' + and approval.manifest_digest = v_manifest_digest + ) then + raise exception 'public document transition approval does not match the document, prior owner, decision, and manifest'; + end if; + end if; + return new; +end; +$$; + +revoke all on function public.guard_document_publication_transition() from public, anon, authenticated; + +drop trigger if exists documents_require_publication_approval on public.documents; +create trigger documents_require_publication_approval +before insert or update on public.documents +for each row execute function public.guard_document_publication_transition(); + +create or replace function public.publish_approved_documents( + p_documents jsonb, + p_manifest_digest text, + p_expected_count integer +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_entry jsonb; + v_document public.documents%rowtype; + v_document_id uuid; + v_expected_owner_id uuid; + v_approval_id uuid; + v_manifest_digest text := lower(trim(coalesce(p_manifest_digest, ''))); + v_count integer; + v_results jsonb := '[]'::jsonb; +begin + if jsonb_typeof(p_documents) is distinct from 'array' then + raise exception 'publication documents must be a JSON array'; + end if; + if v_manifest_digest !~ '^[0-9a-f]{64}$' then + raise exception 'publication manifest digest must be a lowercase SHA-256 value'; + end if; + + v_count := jsonb_array_length(p_documents); + if p_expected_count is null or p_expected_count < 1 or p_expected_count <> v_count then + raise exception 'publication expected count % does not match manifest count %', p_expected_count, v_count; + end if; + if exists ( + select 1 + from jsonb_array_elements(p_documents) entry + group by entry->>'document_id' + having count(*) > 1 + ) then + raise exception 'publication manifest contains duplicate document ids'; + end if; + + for v_entry in select value from jsonb_array_elements(p_documents) + loop + begin + v_document_id := nullif(v_entry->>'document_id', '')::uuid; + v_expected_owner_id := nullif(v_entry->>'expected_owner_id', '')::uuid; + exception when invalid_text_representation then + raise exception 'publication manifest contains an invalid document or owner id'; + end; + if v_document_id is null or v_expected_owner_id is null then + raise exception 'publication manifest requires document_id and expected_owner_id'; + end if; + + select * into v_document + from public.documents + where id = v_document_id + for update; + if not found then + raise exception 'publication document % was not found', v_document_id; + end if; + if v_document.owner_id is distinct from v_expected_owner_id then + raise exception 'publication document % owner changed from the manifest expectation', v_document_id; + end if; + if v_document.status <> 'indexed' then + raise exception 'publication document % is not indexed', v_document_id; + end if; + + select approval.id into v_approval_id + from public.document_publication_approvals approval + where approval.document_id = v_document_id + and approval.expected_prior_owner_id = v_expected_owner_id + and approval.decision = 'approved' + and approval.manifest_digest = v_manifest_digest + order by approval.approved_at desc, approval.id desc + limit 1; + if v_approval_id is null then + raise exception 'publication document % lacks matching approved evidence', v_document_id; + end if; + + if exists ( + select 1 from public.document_labels where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_summaries where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_sections where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_memory_cards where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_table_facts where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_embedding_fields where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_index_quality where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_index_units where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + ) then + raise exception 'publication document % has mismatched artifact ownership', v_document_id; + end if; + + update public.document_labels set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_summaries set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_sections set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_memory_cards set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_table_facts set owner_id = null where document_id = v_document_id; + update public.document_embedding_fields set owner_id = null where document_id = v_document_id; + update public.document_index_quality set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_index_units set owner_id = null, updated_at = now() where document_id = v_document_id; + + update public.documents + set owner_id = null, + metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object( + 'public_corpus', true, + 'publication_approval_id', v_approval_id, + 'publication_manifest_digest', v_manifest_digest, + 'published_at', now() + ), + updated_at = now() + where id = v_document_id; + + v_results := v_results || jsonb_build_array(jsonb_build_object( + 'document_id', v_document_id, + 'previous_owner_id', v_expected_owner_id, + 'approval_id', v_approval_id, + 'outcome', 'published' + )); + end loop; + + return jsonb_build_object( + 'manifest_digest', v_manifest_digest, + 'published_count', v_count, + 'documents', v_results + ); +end; +$$; + +revoke all on function public.publish_approved_documents(jsonb, text, integer) from public, anon, authenticated; +grant execute on function public.publish_approved_documents(jsonb, text, integer) to service_role; +-- Serialize permanent document deletion with ingestion job creation. The +-- parent row lock conflicts with the FK key-share lock taken by a concurrent +-- ingestion_jobs insert: either the job commits first and deletion returns an +-- active_job outcome, or deletion commits first and the insert receives 23503. +create or replace function public.delete_document_if_idle( + p_document_id uuid, + p_owner_id uuid, + p_document_bucket text, + p_image_bucket text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_document public.documents%rowtype; + v_active_job public.ingestion_jobs%rowtype; + v_cleanup_job_id uuid; + v_image_paths text[] := '{}'::text[]; + v_chunk_ids uuid[] := '{}'::uuid[]; +begin + if p_document_id is null or p_owner_id is null then + raise exception 'Document and owner identifiers are required.' using errcode = '22023'; + end if; + if nullif(btrim(p_document_bucket), '') is null or nullif(btrim(p_image_bucket), '') is null then + raise exception 'Storage bucket names are required.' using errcode = '22023'; + end if; + + select d.* + into v_document + from public.documents d + where d.id = p_document_id + and d.owner_id = p_owner_id + for update; + + if not found then + return jsonb_build_object('outcome', 'not_found'); + end if; + + select j.* + into v_active_job + from public.ingestion_jobs j + where j.document_id = p_document_id + and j.status in ('pending', 'processing') + order by j.created_at asc, j.id asc + limit 1; + + if found then + return jsonb_build_object( + 'outcome', 'active_job', + 'job_id', v_active_job.id, + 'job_status', v_active_job.status + ); + end if; + + select coalesce(array_agg(distinct i.storage_path order by i.storage_path) + filter (where i.storage_path is not null and btrim(i.storage_path) <> ''), '{}'::text[]) + into v_image_paths + from public.document_images i + where i.document_id = p_document_id; + + select coalesce(array_agg(c.id order by c.id), '{}'::uuid[]) + into v_chunk_ids + from public.document_chunks c + where c.document_id = p_document_id; + + insert into public.storage_cleanup_jobs ( + owner_id, + document_id, + document_title, + document_bucket, + document_paths, + image_bucket, + image_paths, + status, + metadata + ) values ( + p_owner_id, + p_document_id, + v_document.title, + p_document_bucket, + case + when v_document.storage_path is null or btrim(v_document.storage_path) = '' then '{}'::text[] + else array[v_document.storage_path] + end, + p_image_bucket, + v_image_paths, + 'pending', + jsonb_build_object( + 'operation', 'permanent_document_delete', + 'created_by', 'delete_document_if_idle' + ) + ) + returning id into v_cleanup_job_id; + + if cardinality(v_chunk_ids) > 0 then + delete from public.rag_queries + where source_chunk_ids && v_chunk_ids; + delete from public.rag_query_misses + where top_chunk_ids && v_chunk_ids + or cited_chunk_ids && v_chunk_ids; + end if; + + delete from public.rag_query_misses + where clicked_document_id = p_document_id + or expected_document_id = p_document_id; + + delete from public.rag_response_cache + where owner_id = p_owner_id + and cache_kind in ('search', 'answer'); + + delete from public.documents where id = p_document_id; + + return jsonb_build_object( + 'outcome', 'deleted', + 'cleanup_job_id', v_cleanup_job_id, + 'document_title', v_document.title, + 'source_path', v_document.storage_path, + 'image_paths', to_jsonb(v_image_paths) + ); +end; +$$; + +revoke all on function public.delete_document_if_idle(uuid, uuid, text, text) + from public, anon, authenticated; +grant execute on function public.delete_document_if_idle(uuid, uuid, text, text) + to service_role; + + +create or replace function public.retry_ingestion_job_if_idle( + p_job_id uuid, + p_owner_id uuid, + p_stale_before timestamptz, + p_max_attempts integer, + p_next_run_at timestamptz, + p_document_updated_at timestamptz +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_job public.ingestion_jobs%rowtype; + v_document_status text; +begin + if p_job_id is null or p_owner_id is null or p_stale_before is null + or p_next_run_at is null or p_document_updated_at is null + or p_max_attempts is null or p_max_attempts < 1 then + raise exception 'Retry identifiers, timestamps, and max attempts are required.' using errcode = '22023'; + end if; + + select j.* + into v_job + from public.ingestion_jobs j + join public.documents d on d.id = j.document_id + where j.id = p_job_id + and d.owner_id = p_owner_id + for update of d, j; + + if not found then + return jsonb_build_object('outcome', 'not_found'); + end if; + + select status + into v_document_status + from public.documents + where id = v_job.document_id; + if v_job.status = 'completed' then + return jsonb_build_object('outcome', 'completed'); + end if; + if v_job.status = 'processing' + and v_job.locked_at is not null + and v_job.locked_at >= p_stale_before then + return jsonb_build_object('outcome', 'active_worker'); + end if; + + update public.ingestion_jobs + set status = 'pending', + stage = 'queued', + progress = 0, + error_message = null, + attempt_count = 0, + max_attempts = p_max_attempts, + locked_at = null, + locked_by = null, + next_run_at = p_next_run_at, + completed_at = null + where id = p_job_id + returning * into v_job; + + update public.documents + set status = case when v_document_status = 'indexed' then status else 'queued' end, + error_message = null, + updated_at = p_document_updated_at + where id = v_job.document_id + and owner_id = p_owner_id; + if not found then + raise exception 'Retry document disappeared while its row lock was held.' using errcode = '23503'; + end if; + + return jsonb_build_object('outcome', 'queued', 'job', to_jsonb(v_job)); +end; +$$; + +revoke all on function public.retry_ingestion_job_if_idle(uuid, uuid, timestamptz, integer, timestamptz, timestamptz) + from public, anon, authenticated; +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 +-- 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/tests/pdf-extraction-budget.test.ts b/tests/pdf-extraction-budget.test.ts new file mode 100644 index 000000000..0e0857b66 --- /dev/null +++ b/tests/pdf-extraction-budget.test.ts @@ -0,0 +1,149 @@ +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import PDFDocument from "pdfkit"; +import { afterEach, describe, expect, it } from "vitest"; +import { extractPdf, runPythonPdfExtractor } from "@/lib/extractors/document"; +import { + PDF_EXTRACTION_BUDGET, + PdfExtractionBudgetTracker, + PdfExtractionResourceError, +} from "@/lib/extractors/pdf-extraction-budget"; +import { isRetryableIngestionError } from "@/lib/ingestion"; + +const roots: string[] = []; + +async function createTextPdf() { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const document = new PDFDocument(); + document.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + document.on("end", () => resolve(Buffer.concat(chunks))); + document.on("error", reject); + document.text("This extracted text is deliberately longer than one byte."); + document.end(); + }); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("PDF extraction budgets", () => { + it("accepts exact aggregate boundaries and rejects the first byte or item beyond them", () => { + const limits = { + ...PDF_EXTRACTION_BUDGET, + maxPages: 1, + maxArtifacts: 1, + maxArtifactBytes: 2, + maxTextBytes: 2, + maxResultBytes: 2, + }; + const tracker = new PdfExtractionBudgetTracker(limits); + tracker.addPage("é"); + tracker.addArtifact(2); + tracker.assertResult("é"); + expect(() => tracker.addArtifact(1)).toThrow(/PDF_EXTRACTION_BUDGET_EXCEEDED/); + + expect(() => new PdfExtractionBudgetTracker({ ...limits, maxPages: 0 }).addPage("")).toThrow( + /PDF_EXTRACTION_BUDGET_EXCEEDED/, + ); + expect(() => new PdfExtractionBudgetTracker({ ...limits, maxTextBytes: 1 }).addPage("é")).toThrow( + /PDF_EXTRACTION_BUDGET_EXCEEDED/, + ); + expect(() => new PdfExtractionBudgetTracker({ ...limits, maxResultBytes: 1 }).assertResult("é")).toThrow( + /PDF_EXTRACTION_BUDGET_EXCEEDED/, + ); + }); + + it("terminates the Python child tree when the total deadline expires", async () => { + const root = await mkdtemp(path.join(tmpdir(), "clinical-kb-pdf-deadline-")); + roots.push(root); + const inputPath = path.join(root, "child-pid-path.txt"); + const outputDir = path.join(root, "images"); + const fakeExtractor = path.join(root, "slow-extractor.py"); + const childPidPath = path.join(root, "child.pid"); + await mkdir(outputDir); + await writeFile(inputPath, childPidPath, "utf8"); + await writeFile( + fakeExtractor, + [ + "import pathlib, subprocess, sys, time", + "pid_path = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')", + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])", + "pathlib.Path(pid_path).write_text(str(child.pid), encoding='utf-8')", + "while True: time.sleep(1)", + ].join("\n"), + "utf8", + ); + + await expect( + runPythonPdfExtractor(inputPath, outputDir, { ...PDF_EXTRACTION_BUDGET, totalTimeoutMs: 1_000 }, fakeExtractor), + ).rejects.toMatchObject({ code: "PDF_EXTRACTION_DEADLINE_EXCEEDED" }); + + const childPid = Number(await readFile(childPidPath, "utf8")); + let childIsAlive = false; + try { + process.kill(childPid, 0); + childIsAlive = true; + } catch { + childIsAlive = false; + } + if (childIsAlive) process.kill(childPid, "SIGKILL"); + expect(childIsAlive).toBe(false); + }); + + it("classifies budget and deadline rejections as non-retryable", () => { + expect( + isRetryableIngestionError(new PdfExtractionResourceError("PDF_EXTRACTION_DEADLINE_EXCEEDED", "deadline timeout")), + ).toBe(false); + expect( + isRetryableIngestionError(new PdfExtractionResourceError("PDF_EXTRACTION_BUDGET_EXCEEDED", "artifact budget")), + ).toBe(false); + }); + + it("does not enter fallback and removes the temporary root after a budget rejection", async () => { + const root = await mkdtemp(path.join(tmpdir(), "clinical-kb-pdf-cleanup-test-")); + roots.push(root); + const fakeExtractor = path.join(root, "budget-extractor.py"); + const rootRecord = path.join(root, "temporary-root.txt"); + await writeFile( + fakeExtractor, + [ + "import pathlib, sys", + "record = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')", + "temporary_root = pathlib.Path(sys.argv[2]).parent", + "pathlib.Path(record).write_text(str(temporary_root), encoding='utf-8')", + "(temporary_root / 'partial-artifact.bin').write_bytes(b'partial')", + "print('PDF_EXTRACTION_BUDGET_EXCEEDED: test limit', file=sys.stderr)", + "raise SystemExit(3)", + ].join("\n"), + "utf8", + ); + + await expect( + extractPdf(Buffer.from(rootRecord, "utf8"), { scriptPathOverride: fakeExtractor }), + ).rejects.toMatchObject({ code: "PDF_EXTRACTION_BUDGET_EXCEEDED" }); + const temporaryRoot = await readFile(rootRecord, "utf8"); + expect(existsSync(temporaryRoot)).toBe(false); + }); + + it("applies the same aggregate text budget in the JavaScript fallback", async () => { + const root = await mkdtemp(path.join(tmpdir(), "clinical-kb-pdf-fallback-test-")); + roots.push(root); + const fakeExtractor = path.join(root, "missing-dependency.py"); + await writeFile( + fakeExtractor, + "import sys\nprint('PyMuPDF unavailable', file=sys.stderr)\nraise SystemExit(2)\n", + "utf8", + ); + + await expect( + extractPdf(await createTextPdf(), { + limits: { ...PDF_EXTRACTION_BUDGET, maxTextBytes: 1 }, + scriptPathOverride: fakeExtractor, + }), + ).rejects.toMatchObject({ code: "PDF_EXTRACTION_BUDGET_EXCEEDED" }); + }); +}); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index ba2aaf4a0..a7787ba22 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -16,7 +16,7 @@ function expectFeedbackTokenBoundToAnswer(payload: Record) { expect(claims.answerHash).toBe(createHash("sha256").update(String(payload.answer), "utf8").digest("hex")); } -type QueryError = { message: string }; +type QueryError = { message: string; code?: string }; type QueryResult = { data: unknown; error: QueryError | null }; type QueryFilter = { column: string; value: unknown }; type QueryInFilter = { column: string; values: unknown[] }; @@ -41,8 +41,8 @@ function ok(data: unknown): QueryResult { return { data, error: null }; } -function fail(message: string): QueryResult { - return { data: null, error: { message } }; +function fail(message: string, code?: string): QueryResult { + return { data: null, error: { message, ...(code ? { code } : {}) } }; } function rateLimitRow(overrides: Partial> = {}) { @@ -1822,25 +1822,8 @@ describe("private document API access", () => { }); it("refuses to retry a job a live worker still holds (IDX-C3, B6)", async () => { - // B6: the reset is a single conditional UPDATE guarded on status/locked_at. - // A fresh 'processing' lock means the WHERE clause matches 0 rows, so the - // update resolves with no row → refuse with 409. - const client = createSupabaseMock((call) => { - if (call.table === "ingestion_jobs" && call.operation === "select") { - return ok({ - id: "99999999-9999-4999-8999-999999999999", - document_id: documentId, - batch_id: null, - status: "processing", - locked_at: null, - }); - } - if (call.table === "ingestion_jobs" && call.operation === "update") { - // Guard rejected the reset: no row affected. - return ok(null); - } - return ok([]); - }); + const client = createSupabaseMock(); + client.rpc.mockResolvedValueOnce(ok({ outcome: "active_worker" })); mockRuntime(client); const { POST } = await import("../src/app/api/ingestion/jobs/[id]/retry/route"); @@ -1853,32 +1836,25 @@ describe("private document API access", () => { expect(response.status).toBe(409); expect(String((await payload(response)).error)).toContain("still being processed"); - // The guarded update must carry the status/stale-lock filter atomically. - const jobUpdate = client.calls.find((call) => call.table === "ingestion_jobs" && call.operation === "update"); - expect(jobUpdate?.filters.some((f) => f.column === "id")).toBe(true); - expect(jobUpdate?.orFilters.some((f) => f.includes("status.neq.processing") && f.includes("locked_at"))).toBe(true); - // Must NOT reset the document when the guard refused the job reset. - expect(client.calls.some((call) => call.table === "documents" && call.operation === "update")).toBe(false); + expect(client.rpc).toHaveBeenCalledWith( + "retry_ingestion_job_if_idle", + expect.objectContaining({ + p_job_id: "99999999-9999-4999-8999-999999999999", + p_owner_id: userId, + p_stale_before: expect.any(String), + }), + ); + expect(client.calls).toHaveLength(0); }); it("re-queues a stale/non-processing job without resetting the live index (IDX-C3, IDX-H1, B6)", async () => { - const client = createSupabaseMock((call) => { - if (call.table === "ingestion_jobs" && call.operation === "select") { - return ok({ - id: "99999999-9999-4999-8999-999999999999", - document_id: documentId, - batch_id: null, - status: "failed", - locked_at: null, - }); - } - if (call.table === "documents" && call.operation === "update") return ok([]); - if (call.table === "ingestion_jobs" && call.operation === "update") { - // Guard allowed the reset: one row affected. - return ok({ id: "99999999-9999-4999-8999-999999999999", document_id: documentId, status: "pending" }); - } - return ok([]); - }); + const client = createSupabaseMock(); + client.rpc.mockResolvedValueOnce( + ok({ + outcome: "queued", + job: { id: "99999999-9999-4999-8999-999999999999", document_id: documentId, status: "pending" }, + }), + ); mockRuntime(client); const { POST } = await import("../src/app/api/ingestion/jobs/[id]/retry/route"); @@ -1888,52 +1864,25 @@ describe("private document API access", () => { params: Promise.resolve({ id: "99999999-9999-4999-8999-999999999999" }), }, ); - const documentUpdate = client.calls.find((call) => call.table === "documents" && call.operation === "update"); - expect(response.status).toBe(200); - // IDX-H1: only re-queue; never zero the chunk/page counts here (the worker resets at start). - // updated_at is the rollback-fence stamp shared with the reindex routes. - expect(documentUpdate?.updatePayload).toEqual({ - status: "queued", - error_message: null, - updated_at: expect.any(String), - }); - expect(client.rpc).not.toHaveBeenCalled(); + expect(await payload(response)).toMatchObject({ job: { status: "pending", document_id: documentId } }); + expect(client.rpc).toHaveBeenCalledWith( + "retry_ingestion_job_if_idle", + expect.objectContaining({ + p_job_id: "99999999-9999-4999-8999-999999999999", + p_owner_id: userId, + p_max_attempts: expect.any(Number), + p_next_run_at: expect.any(String), + p_document_updated_at: expect.any(String), + }), + ); + expect(client.calls).toHaveLength(0); }); - it("rolls back the job retry when document queue status update fails", async () => { + it("returns failure when the transactional retry RPC rolls back", async () => { const retryJobId = "99999999-9999-4999-8999-999999999999"; - const previousJob = { - id: retryJobId, - document_id: documentId, - batch_id: null, - status: "failed", - stage: "failed", - progress: 42, - error_message: "OCR failed", - attempt_count: 2, - max_attempts: 3, - locked_at: null, - locked_by: null, - next_run_at: null, - completed_at: "2024-01-01T00:00:00.000Z", - }; - - let ingestionUpdateCount = 0; - const client = createSupabaseMock((call) => { - if (call.table === "ingestion_jobs" && call.operation === "select") { - return ok(previousJob); - } - if (call.table === "ingestion_jobs" && call.operation === "update") { - ingestionUpdateCount += 1; - if (ingestionUpdateCount === 1) { - return ok({ id: retryJobId, document_id: documentId, status: "pending" }); - } - return ok({ id: retryJobId }); - } - if (call.table === "documents" && call.operation === "update") return fail("documents update failed"); - return ok([]); - }); + const client = createSupabaseMock(); + client.rpc.mockResolvedValueOnce(fail("transaction rolled back")); mockRuntime(client); const { POST } = await import("../src/app/api/ingestion/jobs/[id]/retry/route"); @@ -1943,26 +1892,8 @@ describe("private document API access", () => { expect(response.status).toBe(500); expect(String((await payload(response)).error)).toBe("Request failed."); - const jobUpdates = client.calls.filter((call) => call.table === "ingestion_jobs" && call.operation === "update"); - expect(jobUpdates).toHaveLength(2); - expect(jobUpdates[1]?.updatePayload).toEqual({ - status: previousJob.status, - stage: previousJob.stage, - progress: previousJob.progress, - error_message: previousJob.error_message, - attempt_count: previousJob.attempt_count, - max_attempts: previousJob.max_attempts, - locked_at: previousJob.locked_at, - locked_by: previousJob.locked_by, - next_run_at: previousJob.next_run_at, - completed_at: previousJob.completed_at, - }); - // Rollback fence: the rollback must be conditional on the exact - // next_run_at this reset wrote, so it cannot revert a concurrent retry's - // newer reset that re-wrote the same generic pending/queued fields. - const resetNextRunAt = (jobUpdates[0]?.updatePayload as { next_run_at?: string }).next_run_at; - expect(typeof resetNextRunAt).toBe("string"); - expect(jobUpdates[1]?.filters).toContainEqual({ column: "next_run_at", value: resetNextRunAt }); + expect(client.rpc).toHaveBeenCalledWith("retry_ingestion_job_if_idle", expect.any(Object)); + expect(client.calls).toHaveLength(0); }); it("runs enrichment-only reindex for owned indexed documents using generic metadata", async () => { @@ -2421,6 +2352,49 @@ describe("private document API access", () => { expect(documentUpdates[1]?.filters).toContainEqual({ column: "updated_at", value: fence }); }); + it("returns 409 without rollback when deletion wins the single-document reindex race", async () => { + const document = { + id: documentId, + owner_id: userId, + title: "Deleted During Reindex", + file_name: "deleted.pdf", + source_path: null, + import_batch_id: null, + status: "failed", + error_message: "older failure", + page_count: 2, + chunk_count: 4, + image_count: 0, + metadata: {}, + }; + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select") return ok(document); + if (call.table === "import_batches") return ok([]); + if (call.table === "ingestion_jobs" && call.operation === "select") return ok([]); + if (call.table === "documents" && call.operation === "update") return ok([]); + if (call.table === "ingestion_jobs" && call.operation === "insert") { + return fail("ingestion_jobs_document_id_fkey", "23503"); + } + return ok([]); + }); + mockRuntime(client); + const { POST } = await import("../src/app/api/documents/[id]/reindex/route"); + + const response = await POST( + authenticatedRequest(`/api/documents/${documentId}/reindex`, { + method: "POST", + body: JSON.stringify({ mode: "full" }), + }), + { params: Promise.resolve({ id: documentId }) }, + ); + + expect(response.status).toBe(409); + expect(await payload(response)).toMatchObject({ + error: "Document was deleted while reindexing. Refresh the document list and retry.", + }); + expect(client.calls.filter((call) => call.table === "documents" && call.operation === "update")).toHaveLength(1); + }); + it("skips single-document rollback when a competing active job appears after the safety check", async () => { const document = { id: documentId, @@ -2542,6 +2516,55 @@ describe("private document API access", () => { expect(documentUpdates[1]?.filters).toContainEqual({ column: "updated_at", value: fence }); }); + it("returns 409 without rollback when deletion wins a bulk reindex race", async () => { + const document = { + id: documentId, + owner_id: userId, + title: "Bulk Deleted During Reindex", + file_name: "bulk-deleted.pdf", + source_path: null, + import_batch_id: null, + status: "failed", + error_message: "older failure", + page_count: 2, + chunk_count: 4, + image_count: 0, + metadata: {}, + }; + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select") return ok([document]); + if (call.table === "import_batches") return ok([]); + if (call.table === "ingestion_jobs" && call.operation === "select") return ok([]); + if (call.table === "documents" && call.operation === "update") return ok([]); + if (call.table === "ingestion_jobs" && call.operation === "insert") { + return fail("ingestion_jobs_document_id_fkey", "23503"); + } + return ok([]); + }); + mockRuntime(client, { invalidateRagCachesForOwner: vi.fn() }); + const { POST } = await import("../src/app/api/documents/bulk/reindex/route"); + + const response = await POST( + authenticatedRequest("/api/documents/bulk/reindex", { + method: "POST", + body: JSON.stringify({ documentIds: [documentId], mode: "full" }), + }), + ); + + expect(response.status).toBe(409); + expect(await payload(response)).toMatchObject({ + ok: false, + results: [ + { + documentId, + ok: false, + error: "Document was deleted while reindexing. Refresh the document list and retry.", + }, + ], + }); + expect(client.calls.filter((call) => call.table === "documents" && call.operation === "update")).toHaveLength(1); + }); + it("skips bulk rollback when a competing active job appears after the safety check", async () => { const document = { id: documentId, @@ -3281,14 +3304,13 @@ describe("private document API access", () => { it("permanently deletes an owned document, indexing traces, and storage objects", async () => { const sourcePath = `${userId}/documents/${documentId}/source.pdf`; const imagePath = `${userId}/images/${imageId}.png`; - const chunkId = "44444444-4444-4444-8444-444444444444"; const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "select") { return ok({ id: documentId, owner_id: userId, title: "Owned", storage_path: sourcePath }); } if (call.table === "ingestion_jobs" && call.operation === "select") return ok([]); if (call.table === "document_images" && call.operation === "select") return ok([{ storage_path: imagePath }]); - if (call.table === "document_chunks" && call.operation === "select") return ok([{ id: chunkId }]); + if (call.table === "document_chunks" && call.operation === "select") return ok([]); if (call.table === "storage_cleanup_jobs" && call.operation === "insert") return ok({ id: "cleanup-1" }); if (call.table === "storage_cleanup_jobs" && call.operation === "update") return ok([]); if (call.table === "rag_queries" && call.operation === "delete") return ok([]); @@ -3297,6 +3319,17 @@ describe("private document API access", () => { if (call.table === "documents" && call.operation === "delete") return ok([]); return ok([]); }); + client.rpc.mockImplementation(async (name: string) => + name === "delete_document_if_idle" + ? ok({ + outcome: "deleted", + cleanup_job_id: "55555555-5555-4555-8555-555555555555", + document_title: "Owned", + source_path: sourcePath, + image_paths: [imagePath], + }) + : ok([]), + ); mockRuntime(client); const { DELETE } = await import("../src/app/api/documents/[id]/route"); @@ -3304,58 +3337,31 @@ describe("private document API access", () => { params: Promise.resolve({ id: documentId }), }); const body = await payload(response); - const ragDelete = client.calls.find((call) => call.table === "rag_queries" && call.operation === "delete"); - const missDeletes = client.calls.filter((call) => call.table === "rag_query_misses" && call.operation === "delete"); - const cacheDelete = client.calls.find((call) => call.table === "rag_response_cache" && call.operation === "delete"); - const documentDelete = client.calls.find((call) => call.table === "documents" && call.operation === "delete"); - const cleanupInsert = client.calls.find( - (call) => call.table === "storage_cleanup_jobs" && call.operation === "insert", - ); const cleanupUpdate = client.calls.find( (call) => call.table === "storage_cleanup_jobs" && call.operation === "update", ); expect(response.status).toBe(200); expect(body).toMatchObject({ deleted: true, documentId, storageWarnings: [] }); - expect(cleanupInsert?.insertPayload).toMatchObject({ - owner_id: userId, - document_id: documentId, - document_paths: [sourcePath], - image_paths: [imagePath], - status: "pending", + expect(client.rpc).toHaveBeenCalledWith("delete_document_if_idle", { + p_document_id: documentId, + p_owner_id: userId, + p_document_bucket: "clinical-documents", + p_image_bucket: "clinical-images", }); expect(cleanupUpdate?.updatePayload).toMatchObject({ status: "completed", storage_removed: 0 }); - expect(ragDelete?.filters).not.toContainEqual({ column: "owner_id", value: userId }); - expect(ragDelete?.overlapsFilters).toContainEqual({ column: "source_chunk_ids", values: [chunkId] }); - expect(missDeletes.map((call) => call.overlapsFilters[0])).toEqual( - expect.arrayContaining([ - { column: "top_chunk_ids", values: [chunkId] }, - { column: "cited_chunk_ids", values: [chunkId] }, - ]), - ); - expect( - missDeletes.some((call) => - call.orFilters.includes(`clicked_document_id.eq.${documentId},expected_document_id.eq.${documentId}`), - ), - ).toBe(true); - expect(cacheDelete?.filters).toContainEqual({ column: "owner_id", value: userId }); - expect(cacheDelete?.inFilters).toContainEqual({ column: "cache_kind", values: ["search", "answer"] }); - expect(documentDelete?.filters).toContainEqual({ column: "id", value: documentId }); - expect(documentDelete?.filters).toContainEqual({ column: "owner_id", value: userId }); expect(client.storageMocks.storageFrom).toHaveBeenCalledWith("clinical-documents"); expect(client.storageMocks.storageFrom).toHaveBeenCalledWith("clinical-images"); expect(client.storageMocks.remove).toHaveBeenCalledWith([sourcePath]); expect(client.storageMocks.remove).toHaveBeenCalledWith([imagePath]); }); - it("paginates delete cleanup rows before removing the document", async () => { + it("removes transactionally snapshotted image paths in storage batches", async () => { const sourcePath = `${userId}/documents/${documentId}/source.pdf`; const firstImagePage = Array.from({ length: 1000 }, (_, index) => ({ storage_path: `${userId}/images/${index}.png`, })); const finalImage = { storage_path: `${userId}/images/final.png` }; - const firstChunkPage = Array.from({ length: 1000 }, (_, index) => ({ id: `chunk-${index}` })); - const finalChunk = { id: "chunk-final" }; const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "select") { return ok({ id: documentId, owner_id: userId, title: "Owned", storage_path: sourcePath }); @@ -3364,9 +3370,7 @@ describe("private document API access", () => { if (call.table === "document_images" && call.operation === "select") { return call.range?.from === 0 ? ok(firstImagePage) : ok([finalImage]); } - if (call.table === "document_chunks" && call.operation === "select") { - return call.range?.from === 0 ? ok(firstChunkPage) : ok([finalChunk]); - } + if (call.table === "document_chunks" && call.operation === "select") return ok([]); if (call.table === "storage_cleanup_jobs" && call.operation === "insert") return ok({ id: "cleanup-1" }); if (call.table === "storage_cleanup_jobs" && call.operation === "update") return ok([]); if (call.table === "rag_queries" && call.operation === "delete") return ok([]); @@ -3375,39 +3379,38 @@ describe("private document API access", () => { if (call.table === "documents" && call.operation === "delete") return ok([]); return ok([]); }); + client.rpc.mockImplementation(async (name: string) => + name === "delete_document_if_idle" + ? ok({ + outcome: "deleted", + cleanup_job_id: "55555555-5555-4555-8555-555555555555", + document_title: "Owned", + source_path: sourcePath, + image_paths: [...firstImagePage.map((image) => image.storage_path), finalImage.storage_path], + }) + : ok([]), + ); mockRuntime(client); const { DELETE } = await import("../src/app/api/documents/[id]/route"); const response = await DELETE(authenticatedRequest(`/api/documents/${documentId}`, { method: "DELETE" }), { params: Promise.resolve({ id: documentId }), }); - const imageSelects = client.calls.filter((call) => call.table === "document_images" && call.operation === "select"); - const chunkSelects = client.calls.filter((call) => call.table === "document_chunks" && call.operation === "select"); - const ragDelete = client.calls.find((call) => call.table === "rag_queries" && call.operation === "delete"); - expect(response.status).toBe(200); - expect(imageSelects.map((call) => call.range)).toEqual([ - { from: 0, to: 999 }, - { from: 1000, to: 1999 }, - ]); - expect(chunkSelects.map((call) => call.range)).toEqual([ - { from: 0, to: 999 }, - { from: 1000, to: 1999 }, - ]); - expect(ragDelete?.overlapsFilters[0]?.values).toContain("chunk-final"); - expect(client.storageMocks.remove).toHaveBeenCalledWith(expect.arrayContaining([finalImage.storage_path])); + expect(client.storageMocks.remove).toHaveBeenCalledWith(firstImagePage.map((image) => image.storage_path)); + expect(client.storageMocks.remove).toHaveBeenCalledWith([finalImage.storage_path]); + expect(client.calls.some((call) => call.table === "document_images")).toBe(false); + expect(client.calls.some((call) => call.table === "document_chunks")).toBe(false); }); - it("does not delete the document if index trace cleanup fails", async () => { - const sourcePath = `${userId}/documents/${documentId}/source.pdf`; - const chunkId = "44444444-4444-4444-8444-444444444444"; + it("does not touch storage when transactional deletion fails", async () => { const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "select") { - return ok({ id: documentId, owner_id: userId, title: "Owned", storage_path: sourcePath }); + return ok({ id: documentId, owner_id: userId, title: "Owned", storage_path: "source.pdf" }); } if (call.table === "ingestion_jobs" && call.operation === "select") return ok([]); if (call.table === "document_images" && call.operation === "select") return ok([]); - if (call.table === "document_chunks" && call.operation === "select") return ok([{ id: chunkId }]); + if (call.table === "document_chunks" && call.operation === "select") return ok([]); if (call.table === "storage_cleanup_jobs" && call.operation === "insert") return ok({ id: "cleanup-1" }); if (call.table === "storage_cleanup_jobs" && call.operation === "update") return ok([]); if (call.table === "rag_queries" && call.operation === "delete") return fail("query log delete failed"); @@ -3416,23 +3419,17 @@ describe("private document API access", () => { if (call.table === "documents" && call.operation === "delete") return ok([]); return ok([]); }); + client.rpc.mockImplementation(async (name: string) => + name === "delete_document_if_idle" ? fail("index trace cleanup failed") : ok([]), + ); mockRuntime(client); const { DELETE } = await import("../src/app/api/documents/[id]/route"); const response = await DELETE(authenticatedRequest(`/api/documents/${documentId}`, { method: "DELETE" }), { params: Promise.resolve({ id: documentId }), }); - const cleanupUpdate = client.calls.find( - (call) => call.table === "storage_cleanup_jobs" && call.operation === "update", - ); - expect(response.status).toBe(500); expect(await payload(response)).toMatchObject({ error: "Request failed." }); - expect(cleanupUpdate?.updatePayload).toMatchObject({ - status: "failed", - last_error: "Index trace cleanup failed: query log delete failed", - }); - expect(client.calls.some((call) => call.table === "documents" && call.operation === "delete")).toBe(false); expect(client.storageMocks.remove).not.toHaveBeenCalled(); }); @@ -3450,6 +3447,15 @@ describe("private document API access", () => { } return ok([]); }); + client.rpc.mockImplementation(async (name: string) => + name === "delete_document_if_idle" + ? ok({ + outcome: "active_job", + job_id: "66666666-6666-4666-8666-666666666666", + job_status: jobStatus, + }) + : ok([]), + ); mockRuntime(client); const { DELETE } = await import("../src/app/api/documents/[id]/route"); diff --git a/tests/publication-manifest.test.ts b/tests/publication-manifest.test.ts new file mode 100644 index 000000000..caa779834 --- /dev/null +++ b/tests/publication-manifest.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + assertPublicationApplyConfirmation, + parsePublicationCommandArgs, + parsePublicationManifest, + publicationManifestDigest, +} from "@/lib/publication-manifest"; + +const manifest = { + version: 1, + approvingOperatorId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + reason: "Reviewed for public corpus publication.", + evidenceReferences: ["ticket:CLIN-42"], + documents: [ + { + documentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + expectedOwnerId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + decision: "approved", + }, + ], +}; + +describe("publication manifests", () => { + it("requires an explicit manifest and defaults to dry-run", () => { + expect(parsePublicationCommandArgs(["--manifest", "publication.json"])).toEqual({ + manifestPath: "publication.json", + apply: false, + expectedCount: undefined, + confirmSha256: undefined, + }); + expect(() => parsePublicationCommandArgs([])).toThrow(/--manifest/); + }); + + it("requires count and SHA confirmation before apply", () => { + expect(() => parsePublicationCommandArgs(["--manifest", "publication.json", "--apply"])).toThrow( + /--expected-count/, + ); + const raw = JSON.stringify(manifest); + const digest = publicationManifestDigest(raw); + const parsed = parsePublicationManifest(raw); + expect(() => + assertPublicationApplyConfirmation({ + manifest: parsed, + digest, + expectedCount: 2, + confirmSha256: digest, + }), + ).toThrow(/count/); + expect(() => + assertPublicationApplyConfirmation({ + manifest: parsed, + digest, + expectedCount: 1, + confirmSha256: "0".repeat(64), + }), + ).toThrow(/SHA-256/); + }); + + it("rejects missing evidence, duplicate documents, and accepts each explicit decision", () => { + expect(() => parsePublicationManifest(JSON.stringify({ ...manifest, evidenceReferences: [] }))).toThrow(); + expect(() => + parsePublicationManifest( + JSON.stringify({ ...manifest, documents: [manifest.documents[0], manifest.documents[0]] }), + ), + ).toThrow(/unique/); + for (const decision of ["approved", "keep_private", "quarantine"] as const) { + expect( + parsePublicationManifest(JSON.stringify({ ...manifest, documents: [{ ...manifest.documents[0], decision }] })) + .documents[0].decision, + ).toBe(decision); + } + }); +}); diff --git a/tests/retrieval-owner-filter-guard.test.ts b/tests/retrieval-owner-filter-guard.test.ts index 112439fe4..c8d8f691a 100644 --- a/tests/retrieval-owner-filter-guard.test.ts +++ b/tests/retrieval-owner-filter-guard.test.ts @@ -34,7 +34,6 @@ const OWNER_SCOPED_API_TABLES = new Set([ "rag_answer_feedback", "rag_queries", "rag_query_misses", - "rag_response_cache", "rag_retrieval_logs", "storage_cleanup_jobs", ]); diff --git a/tests/storage-upload-compensation.test.ts b/tests/storage-upload-compensation.test.ts new file mode 100644 index 000000000..a8e17a832 --- /dev/null +++ b/tests/storage-upload-compensation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { compensateUploadedArtifactAndThrow } from "@/lib/storage-upload-compensation"; + +describe("compensateUploadedArtifactAndThrow", () => { + it("removes exactly the uploaded path and rethrows the persistence error", async () => { + const persistenceError = new Error("image row insert failed"); + const remove = vi.fn().mockResolvedValue({ error: null }); + const from = vi.fn().mockReturnValue({ remove }); + + await expect( + compensateUploadedArtifactAndThrow({ + storage: { from }, + bucket: "clinical-images", + path: "owner/images/document/generation/image-1.png", + persistenceError, + }), + ).rejects.toBe(persistenceError); + + expect(from).toHaveBeenCalledWith("clinical-images"); + expect(remove).toHaveBeenCalledWith(["owner/images/document/generation/image-1.png"]); + expect(remove).toHaveBeenCalledTimes(1); + }); + + it("reports both errors when compensation fails", async () => { + const remove = vi.fn().mockResolvedValue({ error: { message: "storage remove failed" } }); + + await expect( + compensateUploadedArtifactAndThrow({ + storage: { from: () => ({ remove }) }, + bucket: "clinical-images", + path: "owner/images/document/generation/image-1.png", + persistenceError: new Error("image row insert failed"), + }), + ).rejects.toThrow("image row insert failed; uploaded artifact cleanup failed: storage remove failed"); + }); +}); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 2e1b4634a..151f3d296 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -120,6 +120,22 @@ 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", +).replace(/\s+/g, " "); +const deleteDocumentIfIdleMigration = readFileSync( + new URL("../supabase/migrations/20260717132000_delete_document_if_idle.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const defaultAclAssertionMigration = readFileSync( + new URL("../supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const defaultAclRoleBootstrap = readFileSync(new URL("../supabase/roles.sql", import.meta.url), "utf8").replace( + /\s+/g, + " ", +); const scrubLegacyQueryTextMigration = readFileSync( new URL("../supabase/migrations/20260713103000_scrub_legacy_rag_query_text.sql", import.meta.url), "utf8", @@ -176,6 +192,13 @@ const searchDocumentChunksOwnerScopeMigration = readFileSync( new URL("../supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const searchDocumentChunksCommittedGenerationMigration = readFileSync( + new URL( + "../supabase/migrations/20260717130000_filter_search_document_chunks_committed_generation.sql", + import.meta.url, + ), + "utf8", +).replace(/\s+/g, " "); const retrievalPlanCacheMigration = readFileSync( new URL("../supabase/migrations/20260711120000_retrieval_fn_plan_cache_mode.sql", import.meta.url), "utf8", @@ -934,6 +957,39 @@ describe("Supabase schema Data API grants", () => { ); }); + it("filters per-document search to the committed generation before matching and limiting", () => { + for (const sql of [schema, searchDocumentChunksCommittedGenerationMigration]) { + const functionStart = sql.indexOf("create or replace function public.search_document_chunks("); + const functionEnd = sql.indexOf("$$;", functionStart); + const definition = sql.slice(functionStart, functionEnd); + const generationFilter = definition.indexOf( + "public.is_committed_document_generation(c.index_generation_id, d.index_generation_id)", + ); + + expect(generationFilter).toBeGreaterThanOrEqual(0); + expect(generationFilter).toBeLessThan(definition.indexOf("c.search_tsv @@ normalized.query_tsv")); + expect(generationFilter).toBeLessThan(definition.indexOf("limit least(greatest(match_count, 1), 80)")); + } + + const g0 = "00000000-0000-0000-0000-000000000001"; + const g1 = "00000000-0000-0000-0000-000000000002"; + const candidates = [ + { id: "committed", chunkGeneration: g0, documentGeneration: g0, rank: 0.5 }, + { id: "staged-higher-rank", chunkGeneration: g1, documentGeneration: g0, rank: 0.99 }, + { id: "legacy-null", chunkGeneration: null, documentGeneration: null, rank: 0.4 }, + ]; + const visible = candidates + .filter( + (row) => + row.chunkGeneration === row.documentGeneration && + (row.chunkGeneration !== null || row.documentGeneration === null), + ) + .sort((left, right) => right.rank - left.rank) + .map((row) => row.id); + + expect(visible).toEqual(["committed", "legacy-null"]); + }); + it("surfaces stale commit generation RPCs through search_schema_health", () => { for (const sql of [schema, searchSchemaHealthM13GuardMigration]) { expect(sql).toContain("commit_fn_def := pg_get_functiondef("); @@ -1081,6 +1137,92 @@ describe("Supabase Preview replay guards", () => { ); }); + it("requires append-only operator evidence for owned-to-public transitions", () => { + for (const sql of [schema, publicationApprovalMigration]) { + expect(sql).toContain("create table if not exists public.document_publication_approvals"); + expect(sql).toContain("check (cardinality(evidence_references) > 0)"); + expect(sql).toContain("unique (document_id, expected_prior_owner_id, manifest_digest)"); + expect(sql).toContain("before update or delete on public.document_publication_approvals"); + expect(sql).toContain("before insert or update on public.documents"); + expect(sql).toContain("if tg_op = 'INSERT' then"); + expect(sql).toContain("public documents must be created as owned rows before approved publication"); + expect(sql).toContain("old.owner_id is not null and new.owner_id is null"); + expect(sql).toContain("approval.expected_prior_owner_id = old.owner_id"); + expect(sql).toContain("create or replace function public.publish_approved_documents("); + expect(sql).toContain("for update;"); + expect(sql).toContain( + "grant execute on function public.publish_approved_documents(jsonb, text, integer) to service_role;", + ); + } + }); + + it("serializes permanent deletion against ingestion job creation", () => { + for (const sql of [schema, deleteDocumentIfIdleMigration]) { + const functionStart = sql.indexOf("create or replace function public.delete_document_if_idle("); + const functionBody = sql.slice(functionStart, sql.indexOf("$$;", functionStart)); + const rowLock = functionBody.indexOf("for update;"); + const activeJobCheck = functionBody.indexOf("from public.ingestion_jobs j"); + const ledgerInsert = functionBody.indexOf("insert into public.storage_cleanup_jobs"); + const parentDelete = functionBody.indexOf("delete from public.documents"); + expect(rowLock).toBeGreaterThanOrEqual(0); + expect(rowLock).toBeLessThan(activeJobCheck); + expect(activeJobCheck).toBeLessThan(ledgerInsert); + expect(ledgerInsert).toBeLessThan(parentDelete); + expect(sql).toContain( + "grant execute on function public.delete_document_if_idle(uuid, uuid, text, text) to service_role;", + ); + const retryStart = sql.indexOf("create or replace function public.retry_ingestion_job_if_idle("); + const retryBody = sql.slice(retryStart, sql.indexOf("$$;", retryStart)); + expect(retryBody).toContain("for update of d, j;"); + expect(retryBody.indexOf("for update of d, j;")).toBeLessThan(retryBody.indexOf("update public.ingestion_jobs")); + expect(retryBody).toContain("update public.documents"); + expect(sql).toContain( + "grant execute on function public.retry_ingestion_job_if_idle(uuid, uuid, timestamptz, integer, timestamptz, timestamptz) to service_role;", + ); + } + }); + + it("fails closed on effective supabase_admin default ACLs", () => { + for (const sql of [schema, defaultAclAssertionMigration]) { + 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("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:"); + } + + const migrationFiles = readdirSync(migrationDirectoryUrl) + .filter((fileName) => /^\d+_.+\.sql$/.test(fileName)) + .sort(); + expect(migrationFiles.at(-1)).toBe("20260717161000_assert_supabase_admin_default_privileges.sql"); + }); + + 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;", + ); + expect(defaultAclRoleBootstrap).toContain( + "alter default privileges for role supabase_admin 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;", + ); + expect(defaultAclRoleBootstrap).toContain( + "alter default privileges for role supabase_admin 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;", + ); + expect(defaultAclRoleBootstrap).toContain( + "alter default privileges for role supabase_admin in schema public grant execute on functions to service_role;", + ); + expect(defaultAclRoleBootstrap).toContain("bool_or(grantee not in ('supabase_admin', 'postgres', 'service_role'))"); + }); + it("scrubs legacy plaintext query text with salted irreversible placeholders", () => { // 2026-07-13 audit finding 5: rows written before the HMAC rollout still // held raw clinical query text. Placeholders must be salted (not bare diff --git a/worker/main.ts b/worker/main.ts index b5a9a1c12..bef0660d9 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -40,6 +40,7 @@ import { invalidateRagCachesForDocumentMutation } from "../src/lib/rag"; import { createAdminClient } from "../src/lib/supabase/admin"; import { probeSupabaseHealth } from "../src/lib/supabase/health"; import type { Json, TablesInsert, TablesUpdate } from "../src/lib/supabase/database.types"; +import { compensateUploadedArtifactAndThrow } from "../src/lib/storage-upload-compensation"; import type { ExtractedDocument, ImageEvidenceCategory } from "../src/lib/types"; import { buildAdditionalEmbeddingFieldInputs } from "./embedding-fields"; import { checkMedspacyPrerequisites, checkPythonPdfPrerequisites } from "./prerequisites"; @@ -1151,7 +1152,15 @@ async function uploadAndCaptionImages( .select("id,caption,page_number,image_type,labels,searchable") .single(); - if (error) throw new Error(error.message); + if (error) { + await compensateUploadedArtifactAndThrow({ + storage: supabase.storage, + bucket: env.SUPABASE_IMAGE_BUCKET, + path: imagePath, + persistenceError: new Error(error.message), + }); + } + if (!data) throw new Error("Document image insert returned no row."); if (data.searchable !== false) { insertedImages.push({ id: data.id, diff --git a/worker/python/extract_pdf_assets.py b/worker/python/extract_pdf_assets.py index 82a431a3e..f504de76e 100644 --- a/worker/python/extract_pdf_assets.py +++ b/worker/python/extract_pdf_assets.py @@ -1,5 +1,6 @@ import io import json +import math import os import re import sys @@ -19,12 +20,117 @@ MAX_TABLE_ROWS = 400 MAX_TABLE_TEXT_CHARS = 24000 TARGET_CROP_DPI = 220 -MIN_RENDER_SCALE = 2.0 MAX_RENDER_SCALE = 4.0 -MAX_RENDER_PIXELS = 4_000_000 +MIN_USEFUL_RENDER_SCALE = 0.25 + +DEFAULT_BUDGET = { + "version": 1, + "maxRenderPixels": 4_000_000, + "maxPages": 5_000, + "maxArtifacts": 10_000, + "maxArtifactBytes": 512 * 1024 * 1024, + "maxTextBytes": 64 * 1024 * 1024, + "maxResultBytes": 64 * 1024 * 1024, + "ocrPageTimeoutSeconds": 60, + "totalTimeoutMs": 15 * 60 * 1000, +} + + +class ExtractionBudgetExceeded(RuntimeError): + pass + + +class ExtractionBudget: + def __init__(self, limits=None): + self.limits = {**DEFAULT_BUDGET, **(limits or {})} + for key, value in self.limits.items(): + if not isinstance(value, int) or value <= 0: + raise ValueError(f"Invalid PDF extraction budget value for {key}") + self.page_count = 0 + self.artifact_count = 0 + self.artifact_bytes = 0 + self.text_bytes = 0 + + def set_page_count(self, count): + if count > self.limits["maxPages"]: + raise ExtractionBudgetExceeded( + f"page count {count} exceeds {self.limits['maxPages']}" + ) + self.page_count = count + + def add_text(self, text): + self.text_bytes += len((text or "").encode("utf-8")) + if self.text_bytes > self.limits["maxTextBytes"]: + raise ExtractionBudgetExceeded( + f"extracted UTF-8 text exceeds {self.limits['maxTextBytes']} bytes" + ) + def add_artifact(self, byte_length): + next_count = self.artifact_count + 1 + next_bytes = self.artifact_bytes + max(0, int(byte_length)) + if next_count > self.limits["maxArtifacts"]: + raise ExtractionBudgetExceeded( + f"artifact count {next_count} exceeds {self.limits['maxArtifacts']}" + ) + if next_bytes > self.limits["maxArtifactBytes"]: + raise ExtractionBudgetExceeded( + f"temporary artifact bytes exceed {self.limits['maxArtifactBytes']}" + ) + self.artifact_count = next_count + self.artifact_bytes = next_bytes + + def ensure_artifact_slot(self): + if self.artifact_count + 1 > self.limits["maxArtifacts"]: + raise ExtractionBudgetExceeded( + f"artifact count {self.artifact_count + 1} exceeds {self.limits['maxArtifacts']}" + ) + + def ensure_result(self, serialized): + byte_length = len(serialized.encode("utf-8")) + if byte_length > self.limits["maxResultBytes"]: + raise ExtractionBudgetExceeded( + f"result JSON exceeds {self.limits['maxResultBytes']} bytes" + ) -def maybe_ocr_page(page): + def usage(self): + return { + "pages": self.page_count, + "artifacts": self.artifact_count, + "artifactBytes": self.artifact_bytes, + "textBytes": self.text_bytes, + } + + +def bounded_render_scale(rect, desired_scale, max_pixels): + width = float(rect.width) + height = float(rect.height) + if not all(math.isfinite(value) and value > 0 for value in (width, height, desired_scale)): + return None + scale = min(float(desired_scale), MAX_RENDER_SCALE) + scale = min(scale, math.sqrt(max_pixels / (width * height))) + if not math.isfinite(scale) or scale < MIN_USEFUL_RENDER_SCALE: + return None + + # PyMuPDF rounds transformed dimensions. Use conservative ceiling dimensions + # and a small multiplicative correction rather than relying on float equality. + for _ in range(4): + rounded_pixels = math.ceil(width * scale) * math.ceil(height * scale) + if rounded_pixels <= max_pixels: + break + scale *= math.sqrt(max_pixels / rounded_pixels) * (1.0 - 1e-9) + if math.ceil(width * scale) * math.ceil(height * scale) > max_pixels: + return None + if scale < MIN_USEFUL_RENDER_SCALE: + return None + return scale + + +def maybe_ocr_page(page, budget): + render_scale = bounded_render_scale( + page.rect, 2.0, budget.limits["maxRenderPixels"] + ) + if render_scale is None: + return "", f"render_skipped: OCR page {page.number + 1} has unsafe geometry" try: import pytesseract from PIL import Image @@ -41,9 +147,15 @@ def maybe_ocr_page(page): pytesseract.pytesseract.tesseract_cmd = candidate break - pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False) + pix = page.get_pixmap( + matrix=fitz.Matrix(render_scale, render_scale), alpha=False + ) + if pix.width * pix.height > budget.limits["maxRenderPixels"]: + return "", f"render_skipped: OCR page {page.number + 1} exceeded the rounded pixel limit" image = Image.open(io.BytesIO(pix.tobytes("png"))) - return pytesseract.image_to_string(image), None + return pytesseract.image_to_string( + image, timeout=budget.limits["ocrPageTimeoutSeconds"] + ), None except Exception as exc: return "", f"OCR failed on page {page.number + 1}: {type(exc).__name__}: {exc}" @@ -104,20 +216,15 @@ def rect_payload(rect): return [round(rect.x0, 2), round(rect.y0, 2), round(rect.x1, 2), round(rect.y1, 2)] -def write_pixmap(pix, path): - pix.save(path) +def write_pixmap(pix, path, budget): + image_bytes = pix.tobytes("png") + budget.add_artifact(len(image_bytes)) + with open(path, "wb") as handle: + handle.write(image_bytes) return {"width": pix.width, "height": pix.height} -def render_scale_for_rect(rect): - scale = TARGET_CROP_DPI / 72.0 - pixel_area = max(1.0, rect.width * scale * rect.height * scale) - if pixel_area > MAX_RENDER_PIXELS: - scale *= (MAX_RENDER_PIXELS / pixel_area) ** 0.5 - return max(MIN_RENDER_SCALE, min(MAX_RENDER_SCALE, scale)) - - -def save_page_crop(page, rect, output_dir, file_name, source_kind, metadata): +def save_page_crop(page, rect, output_dir, file_name, source_kind, metadata, budget, warnings): page_rect = page.rect clipped = rect & page_rect if clipped.is_empty: @@ -126,13 +233,28 @@ def save_page_crop(page, rect, output_dir, file_name, source_kind, metadata): if clipped.width < 80 or clipped.height < 60: return None - render_scale = render_scale_for_rect(clipped) + render_scale = bounded_render_scale( + clipped, + TARGET_CROP_DPI / 72.0, + budget.limits["maxRenderPixels"], + ) + if render_scale is None: + warnings.append( + f"render_skipped: {source_kind} page {metadata['pageNumber']} has unsafe geometry" + ) + return None + budget.ensure_artifact_slot() pix = page.get_pixmap(matrix=fitz.Matrix(render_scale, render_scale), clip=clipped, alpha=False) + if pix.width * pix.height > budget.limits["maxRenderPixels"]: + warnings.append( + f"render_skipped: {source_kind} page {metadata['pageNumber']} exceeded the rounded pixel limit" + ) + return None if pix.width < 180 or pix.height < 120: return None image_path = os.path.join(output_dir, file_name) - dimensions = write_pixmap(pix, image_path) + dimensions = write_pixmap(pix, image_path, budget) crop_metadata = { **metadata, "bbox": rect_payload(rect), @@ -716,17 +838,20 @@ def fallback_visual_region(page, image_coverage=0.0, ocr_used=False): ) -def extract(pdf_path, output_dir): +def extract(pdf_path, output_dir, budget=None): + budget = budget or ExtractionBudget() os.makedirs(output_dir, exist_ok=True) document = fitz.open(pdf_path) pages = [] images = [] warnings = [] + budget.set_page_count(document.page_count) for page_index, page in enumerate(document): page_number = page_index + 1 text = page.get_text("text", sort=True) or "" ocr_used = False + needs_ocr = False image_coverage = page_image_coverage_ratio(page) # IDX-H4: trigger OCR by text-density relative to image coverage, not only a flat @@ -734,18 +859,22 @@ def extract(pdf_path, output_dir): # rendered as an image with a short caption) are OCR'd and merged with the existing # text layer so caption + table contents are both retained. if should_ocr_page(text, page): - ocr_text, ocr_warning = maybe_ocr_page(page) + ocr_text, ocr_warning = maybe_ocr_page(page, budget) if ocr_warning: warnings.append(ocr_warning) if ocr_text.strip(): text = merge_ocr_text(text, ocr_text) ocr_used = True + else: + needs_ocr = True + budget.add_text(text) pages.append( { "pageNumber": page_number, "text": text, "ocrUsed": ocr_used, + "needsOcr": needs_ocr, "metadata": { "image_coverage_ratio": round(image_coverage, 4), }, @@ -755,6 +884,7 @@ def extract(pdf_path, output_dir): page_image_count = 0 for image_index, image_info in enumerate(page.get_images(full=True)): xref = image_info[0] + budget.ensure_artifact_slot() extracted = document.extract_image(xref) ext = extracted.get("ext", "png") image_bytes = extracted["image"] @@ -765,6 +895,7 @@ def extract(pdf_path, output_dir): image_path = os.path.join( output_dir, f"page-{page_number}-image-{image_index + 1}.{ext}" ) + budget.add_artifact(len(image_bytes)) with open(image_path, "wb") as handle: handle.write(image_bytes) @@ -833,6 +964,8 @@ def extract(pdf_path, output_dir): "extraction_method": table_candidate.get("extraction_method"), "table_index": table_candidate.get("table_index"), }, + budget, + warnings, ) if crop: images.append(crop) @@ -852,6 +985,8 @@ def extract(pdf_path, output_dir): "source_kind": "diagram_crop", "candidate_type": "vector_region", }, + budget, + warnings, ) if crop: images.append(crop) @@ -886,21 +1021,42 @@ def extract(pdf_path, output_dir): "page_visual_weight": page_visual_weight(page), "ocr_used": ocr_used, }, + budget, + warnings, ) if crop: images.append(crop) - return {"pages": pages, "images": images, "warnings": warnings} + return { + "pages": pages, + "images": images, + "warnings": warnings, + "budgetUsage": budget.usage(), + } if __name__ == "__main__": - if len(sys.argv) not in (3, 4): - print("Usage: extract_pdf_assets.py input.pdf output_dir [output.json]", file=sys.stderr) + if len(sys.argv) not in (3, 4, 5): + print( + "Usage: extract_pdf_assets.py input.pdf output_dir [output.json] [budget.json]", + file=sys.stderr, + ) sys.exit(1) - result = extract(sys.argv[1], sys.argv[2]) - if len(sys.argv) == 4: - with open(sys.argv[3], "w", encoding="utf-8") as handle: - json.dump(result, handle) - else: - print(json.dumps(result)) + try: + limits = None + if len(sys.argv) == 5: + with open(sys.argv[4], "r", encoding="utf-8") as handle: + limits = json.load(handle) + extraction_budget = ExtractionBudget(limits) + result = extract(sys.argv[1], sys.argv[2], extraction_budget) + serialized = json.dumps(result) + extraction_budget.ensure_result(serialized) + if len(sys.argv) >= 4: + with open(sys.argv[3], "w", encoding="utf-8") as handle: + handle.write(serialized) + else: + print(serialized) + except ExtractionBudgetExceeded as exc: + print(f"PDF_EXTRACTION_BUDGET_EXCEEDED: {exc}", file=sys.stderr) + sys.exit(3) diff --git a/worker/python/test_extract_pdf_assets_budget.py b/worker/python/test_extract_pdf_assets_budget.py new file mode 100644 index 000000000..b51b5826f --- /dev/null +++ b/worker/python/test_extract_pdf_assets_budget.py @@ -0,0 +1,92 @@ +import os +import sys +import types +import unittest +from unittest.mock import patch + +sys.path.insert(0, os.path.dirname(__file__)) +import extract_pdf_assets as extractor + + +def limits(**overrides): + return {**extractor.DEFAULT_BUDGET, **overrides} + + +class Rect: + def __init__(self, width, height): + self.width = width + self.height = height + + +class PdfExtractionBudgetTests(unittest.TestCase): + def test_exact_render_boundary_is_allowed(self): + scale = extractor.bounded_render_scale(Rect(1000, 1000), 2.0, 4_000_000) + self.assertEqual(scale, 2.0) + self.assertLessEqual(1000 * scale * 1000 * scale, 4_000_000) + + def test_invalid_and_unhelpfully_large_geometry_is_skipped_before_render(self): + self.assertIsNone(extractor.bounded_render_scale(Rect(float("nan"), 100), 2.0, 4_000_000)) + + class Page: + number = 0 + rect = Rect(100_000, 100_000) + + def get_pixmap(self, **_kwargs): + raise AssertionError("unsafe geometry must not render") + + text, warning = extractor.maybe_ocr_page(Page(), extractor.ExtractionBudget()) + self.assertEqual(text, "") + self.assertIn("render_skipped", warning) + + def test_page_text_artifact_and_result_exact_boundaries_then_reject_overflow(self): + budget = extractor.ExtractionBudget( + limits(maxPages=1, maxTextBytes=2, maxArtifacts=1, maxArtifactBytes=2, maxResultBytes=2) + ) + budget.set_page_count(1) + budget.add_text("é") + budget.add_artifact(2) + budget.ensure_result("é") + with self.assertRaises(extractor.ExtractionBudgetExceeded): + budget.add_artifact(1) + + with self.assertRaises(extractor.ExtractionBudgetExceeded): + extractor.ExtractionBudget(limits(maxPages=1)).set_page_count(2) + with self.assertRaises(extractor.ExtractionBudgetExceeded): + extractor.ExtractionBudget(limits(maxTextBytes=1)).add_text("é") + with self.assertRaises(extractor.ExtractionBudgetExceeded): + extractor.ExtractionBudget(limits(maxResultBytes=1)).ensure_result("é") + + def test_ocr_receives_the_per_page_timeout(self): + calls = [] + pytesseract = types.SimpleNamespace( + pytesseract=types.SimpleNamespace(tesseract_cmd=None), + image_to_string=lambda _image, **kwargs: calls.append(kwargs) or "text", + ) + image_module = types.SimpleNamespace(open=lambda _stream: object()) + pil = types.SimpleNamespace(Image=image_module) + + class Pix: + width = 100 + height = 100 + + def tobytes(self, _format): + return b"png" + + class Page: + number = 0 + rect = Rect(100, 100) + + def get_pixmap(self, **_kwargs): + return Pix() + + with patch.dict(sys.modules, {"pytesseract": pytesseract, "PIL": pil}): + text, warning = extractor.maybe_ocr_page( + Page(), extractor.ExtractionBudget(limits(ocrPageTimeoutSeconds=60)) + ) + self.assertEqual(text, "text") + self.assertIsNone(warning) + self.assertEqual(calls, [{"timeout": 60}]) + + +if __name__ == "__main__": + unittest.main()