diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 944e44fd59..06d3ad227d 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v2", "captured_revision": { - "sha": "a683a5a6af3d400574bb8ef55e199e0c8665c60f", - "committed_at": "2026-09-02T11:35:41+00:00" + "sha": "c09543adcbd5f5937bd70e7160b9fe236749a12e", + "committed_at": "2026-09-02T15:36:14+00:00" }, "routes": { "modes": [ @@ -16822,6 +16822,14 @@ "outcome": "Re-check only: still CONFLICTING vs origin/main. Semantic conflicts include privacy/page.tsx, answer-render-policy.ts, answer-request.ts, source-authority-metadata.ts, upload/bulk routes, settings-dialog, drift-manifest (+ more). Merge aborted; no force-resolve.", "checks": "merge origin/main and/or conflict re-check only; no provider-backed checks run" }, + { + "date": "2026-09-02", + "ref": "claude/caring-contacts-vocabulary-tmnc89", + "head": "969cc7f8889181758c93eb725e8eab7be6dc5e1e", + "scope": "prlanded", + "outcome": "Merged and verified. Two-dot content diff between the squash commit and the branch tip 21f4ef3da was empty, so all fifteen commits landed and nothing was orphaned by the squash+auto-merge race. The ~20 queued inbox requests it carried are applied by PR #2559, which rebased onto this squash and absorbed them; #Z5P2BW, #0HYHTH and #AGRAKQ are archived there, and the two follow-ups this branch filed (#686WHW, #1NMMZS) are added.", + "checks": "prlanded content diff empty; full CI green on 21f4ef3da (PR required, Build, Unit coverage, Production UI 1/2/3 + critical, Caring Contacts database, Safety and config checks, Lighthouse, Static PR checks, PR policy, PR mergeability, Semgrep, Gitleaks, GitGuardian); one review thread, resolved" + }, { "date": "2026-07-29", "ref": "claude/latency-fixes-2026-07-29", diff --git a/docs/audit/tenancy-defense-in-depth-review.md b/docs/audit/tenancy-defense-in-depth-review.md index e6f507c5bf..fd0407071d 100644 --- a/docs/audit/tenancy-defense-in-depth-review.md +++ b/docs/audit/tenancy-defense-in-depth-review.md @@ -274,17 +274,87 @@ small, largely-cooperative user set with a public shared corpus. 1. **Make the retrieval RPCs fail-_closed_ on a null owner filter — DONE (2026-07-08, PR #409).** `retrieval_owner_matches` now returns no rows when `owner_filter IS NULL`; the app uses the public sentinel for legitimate unauthenticated paths. Verify: `npm run check:july8-live-batch`. -2. **Add a CI guard against un-scoped owner tables (cheap, high value) — DONE (2026-07-17).** - [`scripts/check-owner-scope-api.mjs`](../../scripts/check-owner-scope-api.mjs) fails when a - `src/app/api/**` handler queries an owner-scoped table (any table with an `owner_id` column in - `supabase/schema.sql`) without a recognised scoping construct in the enclosing handler — - `.eq('owner_id'`, `withOwnerReadScope`, `requireOwnerScope`, `requireOwnedDocument`/`loadOwnedDocument`, - a `documents!inner`+`documents.owner_id` join, or an `owner_id:` write payload. Confirmed-safe - indirect-scope cases live in a documented `OWNER_SCOPE_ALLOWLIST` (today only the two local-origin - `setup-status` existence probes, §3 / TEN-N1). Wired into `npm run check:owner-scope`, - `npm run verify:cheap`, and the CI `static-pr` job; regression-locked by - [`tests/owner-scope-guard.test.ts`](../../tests/owner-scope-guard.test.ts). This directly guards the - regression class the single-layer model is exposed to — a future PR dropping the filter. +2. **Add a CI guard against un-scoped owner tables (cheap, high value) — DONE (2026-07-17), + WIDENED (2026-09-02).** Two layers now: + - [`scripts/check-owner-scope-api.mjs`](../../scripts/check-owner-scope-api.mjs) fails when a + `src/app/api/**` handler queries an owner-scoped table (any table with an `owner_id` column in + `supabase/schema.sql`) without a recognised scoping construct in the enclosing handler — + `.eq('owner_id'`, `withOwnerReadScope`, `requireOwnerScope`, `requireOwnedDocument`/`loadOwnedDocument`, + a `documents!inner`+`documents.owner_id` join, or an `owner_id:` write payload. Confirmed-safe + indirect-scope cases live in a documented `OWNER_SCOPE_ALLOWLIST`. + - [`scripts/lib/tenancy-scan.mjs`](../../scripts/lib/tenancy-scan.mjs) is the mechanical scan the + same command and [`tests/retrieval-owner-filter-guard.test.ts`](../../tests/retrieval-owner-filter-guard.test.ts) + both run. It closes the five blind spots the handler-level regex layer has, described below. + + Wired into `npm run check:owner-scope`, `npm run verify:cheap`, and the CI `static-pr` job; + regression-locked by [`tests/owner-scope-guard.test.ts`](../../tests/owner-scope-guard.test.ts) + and the guard test above. This directly guards the regression class the single-layer model is + exposed to — a future PR dropping the filter. + + **What the mechanical scan now covers (2026-09-02).** + + | Blind spot it closed | Before | Now | + | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | **A — join-through tables were invisible** | Both guards only considered tables with an `owner_id` column, so `document_chunks`, `document_pages`, `document_images`, `ingestion_jobs`, `ingestion_job_stages`, `source_review_events` and `rag_visual_eval_runs` — which hold the actual document text and images — were never checked at all. | A third **derived** tier (has `document_id`, has neither owner column). Every query against one must appear in `DERIVED_QUERY_INVENTORY` with the proof that ownership was established; a new or moved site fails the guard until it is reviewed. | + | **B — `user_id` tenancy was not modelled** | `user_favourites`, `user_favourite_sets` and `user_preferences` were outside both guards. `withOwnerReadScope` cannot be used on them (it filters `owner_id`), so all ~18 call sites hand-roll `.eq("user_id", …)`. | A **user-keyed** tier requiring a `user_id` predicate on the query chain. `owner_id` is explicitly not accepted as a substitute. | + | **C — scope was attributed per FUNCTION** | Both guards asked whether a sanctioned token appeared anywhere in the enclosing function, so a handler that scoped query 1 and forgot query 2 passed. | The predicate must ride the **same fluent chain** as the `.from("table")` call, or that chain must be handed to `withOwnerReadScope`. Genuinely indirect scoping is a declared entry naming its proof — which is how `ingestion/quality`'s `document_index_quality` read went from passing by accident to passing by review. | + | **D — the file set was too narrow** | The `.mjs` guard stopped at `src/app/api`; the AST guard read only files ending `/route.ts`. Server components and observability aggregates were unguarded. | Every `.ts` under `src/app/api` plus a **named** list of server-side read modules: `src/lib/document-detail.ts`, `src/lib/sources/document-source-loader.ts`, `src/lib/observability/answer-slo.ts`, `src/lib/observability/spend-metrics.ts`, and — added 2026-09-02 — `src/lib/document-naming.ts`, the delegate behind the one dynamic table dispatch (blind spot F). A named list rather than a `src/lib/**` glob, so the boundary is a decision and the scan never acquires authority over the protected `src/lib/rag/**` ranking surface. `worker/**`, `scripts/**` and `supabase/functions/**` are out of scope **by decision** — they are job-scoped or operator tooling with a different tenancy model, not an oversight. | + | **E — dynamic RPC dispatch** | The primary retrieval RPCs never appear as `.rpc("literal")`; they go through `callVersionedRetrievalRpc(supabase, versionedName, legacyName, args, signal)` in `src/lib/rag/rag-candidate-sources.ts`, which also rewrites `owner_filter` to `PUBLIC_OWNER_FILTER_SENTINEL` on the legacy public-merge path. Tenancy for the whole retrieval layer sits in that one function and nothing pinned it there. | The guard asserts `callVersionedRetrievalRpc` is the **only** non-literal `.rpc()` call site in `src/`, and that both RPC-name arguments are string literals at every one of its 8 call sites. A second dynamic dispatcher fails the test. | + | **F — dynamic table dispatch** | A `.from(identifier)` whose table is not a string literal matched no tier and left the scan with **no signal at all**. One such site is real: `/api/upload` builds `{ from: (table) => adminSupabase.from(table) }` and hands it to `planDocumentName`, whose `documents` query in `src/lib/document-naming.ts` is the actual owner filter — and that module was in no scanned set, so deleting its `.eq("owner_id", …)` left both phases green. Found by Codex review, 2026-09-02. | Two changes, because a declaration alone would only move the blind spot. The delegate is now in `SCANNED_LIB_MODULES`, so its filter is checked mechanically as an ordinary direct-tier site. And every dynamic dispatch must appear in `DYNAMIC_FROM_DECLARATIONS` naming that delegate, with the entry refused unless the delegate is itself scanned. `Buffer.from`, `Array.from` and `…storage.from(bucket)` are excluded by receiver; every other unrecognised receiver **fails closed**. | + + **How a declared proof is verified.** Five of the eight proof kinds are checked in the AST rather + than trusted from the reason string: `documents-inner-join` (the chain selects `documents!inner` + **and** filters `documents.owner_id`); `owner-pinned-document-id` (the identifier used as this + query's `document_id` filter is the same identifier an owner-scoped `documents` query pinned as + its `id` in the same scope); `owned-document-helper` (that identifier was handed to + `requireOwnedDocument`/`loadOwnedDocument`/`ownedDocumentId`/`ownedDocumentExists`); + `owner-scoped-id-list` (the identifier is declared in this scope and this scope runs an + owner-scoped `documents` query); and `parent-document-verified` (the scope still contains the + owner-scoped parent read the row is validated against). Only `reviewed-indirect` carries no + mechanical check — it is used where the link crosses a function boundary or is not expressible, + and every use of it is listed below. The seventh, `untiered-table`, is the fourth signal added + after the 2026-09-02 security review: it is declaration-only by construction, because a table + carrying no tenancy column has no ownership relation for a proof to check. The eighth, + `dynamic-table-dispatch`, is declaration-only for the same reason — the table is not knowable + at the call site — but it is not proof-free: the entry's `delegate` must be a module in + `SCANNED_LIB_MODULES`, so the declaration points at coverage rather than substituting for it. + + **Residual gaps, recorded rather than closed.** + - **The mechanical proofs are order-insensitive.** `scopeFacts` scans the enclosing scope + without regard to statement order, so a proof is satisfied by an ownership query that runs + **after** the protected read, or whose result is discarded without a branch. It no longer + scans code that cannot run at all: since the 2026-09-02 Codex review the collection stops at + nested function-like nodes the query is not lexically inside, so a never-invoked helper or + callback can no longer donate its owner-scoped `documents` query to the enclosing handler. + Reachability within a single scope is still unmodelled. The "a 404 is returned before this query" + clauses in the reason strings are prose and are not checked. `parent-document-verified` is + the weakest of the five: it requires only that _some_ owner-scoped `documents` query exists + somewhere in the scope, not that its result gates the read. Closing this needs dataflow and + control-flow analysis, not a wider AST match; until then the reason strings are a reviewer's + claim and the scan is a placement check. + - **Tables outside the three tiers.** A table carrying none of `owner_id`, `user_id`, + `document_id` gets no tier and therefore no per-chain rule. This is now **signalled** rather + than silent — see "Mechanical scan: tables outside the three tiers" below, which caught + `clinical_quality_feedback_triage` — but a declaration is a written reason, not a proof. + - **The two phases discover files differently.** Phase 1 lists + `git ls-files src/app/api`; phase 2 walks the filesystem with `readdirSync(..., { recursive: true })`. + They can therefore disagree about what exists. This is deliberate and is not aligned: phase 2 + is the stronger guard and must see a **new, not-yet-tracked** route file, which `git ls-files` + would not list; phase 1 is the cheap independent second opinion and reads what the commit + contains. The direction of the disagreement is the safe one — the stronger phase sees more — + but a file that is tracked and deleted on disk is visible only to phase 1, and a working-tree + file that is never committed is checked locally and not in CI. + - Cross-function proofs (`eval-cases` `ownedChunkReference`, `search/interaction` + `ownedChunkExists`, `documents/[id]/labels` `selectLabels`, `documents/[id]` DELETE's + `updateStorageCleanupJob`, and the `document-source-loader` factory) are declared, not checked. + Moving any of them to a different file or function drops its entry and forces a fresh review, + which is the ratchet that replaces the missing dataflow analysis. + - The scan reads only what a chain lexically shows. A predicate applied to a builder held in a + variable and extended in a later statement is not attributed to the chain, so it would fail + closed (needing an entry) rather than pass silently. + - Item 3 below — a live cross-tenant integration test with two real users — remains the only + thing that proves the boundary end to end. This is a static guard, not a proof of behaviour. + 3. **Add a live cross-tenant integration test (medium value).** Fixtures for user A + user B; for each route family assert B cannot read/mutate A's non-null rows and gets 404/empty. This is the regression harness for the exact property the whole model depends on, and it is what would have @@ -328,6 +398,85 @@ new entry must be added here and to the list in the guard, or the regression tes | `src/app/api/clinical-quality/route.ts` | `clinical_registry_records` | Administrator-gated cross-tenant governance aggregate. `GET`/`PATCH` call `authorizeAndLimit` before helper reads; response excludes raw question, answer, excerpt, and patient text (§6). | | `src/app/api/clinical-quality/route.ts` | `rag_retrieval_logs` | Administrator-gated cross-tenant governance aggregate. `GET`/`PATCH` call `authorizeAndLimit` before helper reads; response excludes raw question, answer, excerpt, and patient text (§6). | +### Mechanical scan: declared scope exemptions (2026-09-02) + +Direct-tier (`owner_id`) and user-keyed (`user_id`) queries whose predicate is **not** on the query +chain. Every row is an entry in `SCOPE_EXEMPTIONS` in +[`scripts/lib/tenancy-scan.mjs`](../../scripts/lib/tenancy-scan.mjs); the guard test fails if an entry +here is missing, stale, or if its mechanical proof no longer holds. + +| File | Table | Scope | Proof | Why it is safe | +| ------------------------------------------------- | ---------------------------------- | --------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/app/api/clinical-quality/route.ts` | `rag_answer_feedback` | `loadClinicalQualitySnapshot` (×2), `verifyQualitySignal` | reviewed-indirect | Administrator-gated cross-tenant governance aggregate behind `authorizeAndLimit`; governance metadata only, never raw question, answer, excerpt, or patient text. | +| `src/app/api/clinical-quality/route.ts` | `clinical_registry_record_sources` | `loadClinicalQualitySnapshot` | reviewed-indirect | Same administrator-gated aggregate; reads link rows only. | +| `src/app/api/clinical-quality/route.ts` | `clinical_registry_records` | `loadClinicalQualitySnapshot` | reviewed-indirect | Same administrator-gated aggregate; reads `id,kind,route` only. | +| `src/app/api/clinical-quality/route.ts` | `rag_retrieval_logs` | `loadClinicalQualitySnapshot` (×2), `verifyQualitySignal` | reviewed-indirect | Same administrator-gated aggregate; retrieval-reach counters only. | +| `src/app/api/documents/[id]/labels/route.ts` | `document_labels` | `selectLabels` | reviewed-indirect | In-file read helper that runs only after `requireOwnedDocument` resolved for the same document id; it consumes an owner-authorized capability id rather than entering from a request. | +| `src/app/api/documents/[id]/route.ts` | `storage_cleanup_jobs` | `updateStorageCleanupJob` | reviewed-indirect | Closes out a ledger row by the `cleanupJobId` the same DELETE handler created for an owner-verified document; the id is never request-supplied. | +| `src/app/api/documents/[id]/table-facts/route.ts` | `document_table_facts` | `GET` | owner-pinned-document-id (`id`) | Facts listed for the document id `withOwnerReadScope` already resolved in this handler; 404 before this query when the caller cannot see it. | +| `src/app/api/documents/route.ts` | `document_labels` | `GET` | owner-scoped-id-list (`ownedIds`, `publicDocumentIds`) | Batched over the owned subset and the null-owner (public corpus) subset of the same `withOwnerReadScope` page; public rows use the redacted projection. | +| `src/app/api/documents/route.ts` | `document_summaries` | `GET` | owner-scoped-id-list (`ownedIds`, `publicDocumentIds`) | As above, with the redacted public summary projection. | +| `src/app/api/ingestion/quality/route.ts` | `document_index_quality` | `GET` | owner-scoped-id-list (`documentIds`) | Read by `.in("document_id", documentIds)` where `documentIds` comes from the `.eq("owner_id", user.id)` query at the top of the handler. This is the canonical blind-spot-C case: safe, but previously passing by accident rather than by review. | +| `src/app/api/setup-status/route.ts` | `documents` | `readSchemaStatus` | reviewed-indirect | Local-origin-gated `.limit(1)` existence probe; status booleans only (§3 / TEN-N1). | +| `src/app/api/setup-status/route.ts` | `import_batches` | `readSchemaStatus` | reviewed-indirect | Local-origin-gated `.limit(1)` existence probe; status booleans only (§3 / TEN-N1). | +| `src/app/api/setup-status/route.ts` | `storage_cleanup_jobs` | `readSchemaStatus` | reviewed-indirect | Local-origin-gated head-count probe; counts only (§3 / TEN-N1). | +| `src/lib/document-detail.ts` | `document_table_facts` | `loadAuthorizedDocumentDetail` | owner-pinned-document-id (`id`) | Child read for the id `withOwnerReadScope` resolved at the top of the loader, which throws 404 before any child query. | +| `src/lib/document-detail.ts` | `document_labels` | `loadAuthorizedDocumentDetail` | owner-pinned-document-id (`id`) | Same owner-resolved document id. | +| `src/lib/document-detail.ts` | `document_summaries` | `loadAuthorizedDocumentDetail` | owner-pinned-document-id (`id`) | Same owner-resolved document id. | +| `src/lib/observability/answer-slo.ts` | `rag_queries` | `base`, `answerSloSnapshot` | reviewed-indirect | Deliberate cross-tenant operator aggregate reached only from `/api/health`'s deep probe behind `HEALTH_DEEP_PROBE_SECRET`. Returns counts and a degraded-RPC identity map; owner filtering would make the answer SLO blind to every tenant but the prober. | +| `src/lib/observability/spend-metrics.ts` | `rag_retrieval_logs` | `spendSnapshot` | reviewed-indirect | Deliberate cross-tenant operator aggregate behind the same deep-probe gate; prices the trailing window from `query_class` and token counters. Per-owner spend is not the question being asked. | +| `src/lib/sources/document-source-loader.ts` | `documents` | `createDocumentSourceQuery` | reviewed-indirect | Factory returning an **unexecuted** PostgREST builder. Its only consumer, `loadVisibleDocumentSourceReferences`, wraps it in `withOwnerReadScope(query, viewerId)` before awaiting it; the indirection is a dependency-injection seam for tests. A second consumer executing the builder directly would invalidate this entry. | + +### Mechanical scan: derived-tier (join-through) query inventory (2026-09-02) + +These tables carry no owner column at all — ownership runs `document_id -> documents.owner_id` — and +they hold the document text and images. Every query site is listed; a new or moved one fails the +guard until it is reviewed. Entries live in `DERIVED_QUERY_INVENTORY` in +[`scripts/lib/tenancy-scan.mjs`](../../scripts/lib/tenancy-scan.mjs). + +| File | Table | Scope | Proof | How ownership is established | +| ------------------------------------------------- | ---------------------- | --------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/app/api/clinical-quality/route.ts` | `source_review_events` | `loadClinicalQualitySnapshot` | reviewed-indirect | Administrator-gated governance aggregate behind `authorizeAndLimit`; review dispositions, no document content. | +| `src/app/api/clinical-quality/route.ts` | `rag_visual_eval_runs` | `loadClinicalQualitySnapshot` (×2), `verifyQualitySignal` | reviewed-indirect | Same administrator-gated aggregate; eval pass/fail counters. | +| `src/app/api/clinical-quality/route.ts` | `document_chunks` | `loadClinicalQualitySnapshot` | reviewed-indirect | Reads only `id,document_id` for chunk ids already named by feedback rows, to map a signal back to its document. No chunk content is selected. | +| `src/app/api/documents/[id]/cover/route.ts` | `document_images` | `GET` (×2) | owner-pinned-document-id (`id`) | Both cover lookups filter `.eq("document_id", id)` for the id `withOwnerReadScope` resolved earlier; 404 before either query otherwise. | +| `src/app/api/documents/[id]/reindex/route.ts` | `ingestion_jobs` | `POST` | owner-pinned-document-id (`id`) | Competing-job diagnostic for the document already loaded with `.eq("owner_id", user.id)`. | +| `src/app/api/documents/[id]/search/route.ts` | `document_chunks` | `GET` | owner-pinned-document-id (`id`) | ILIKE fallback used only when the owner-filtering `search_document_chunks` RPC is unavailable; reads chunks for the id `withOwnerReadScope` resolved above. | +| `src/app/api/documents/[id]/table-facts/route.ts` | `document_images` | `PATCH` (×2) | owned-document-helper (`id`) | Both the source-image read and the review-metadata write are constrained to `.eq("document_id", id)` where `loadOwnedDocument` proved that document belongs to the requesting administrator. The write carried no document constraint until 2026-09-02 and was declared `reviewed-indirect`; restating it turned the repo's narrowest derived-tier write into a mechanically checked proof. | +| `src/app/api/eval-cases/route.ts` | `document_chunks` | `ownedChunkReference` | reviewed-indirect | Read-then-verify: selects only `id,document_id`, then calls `ownedDocumentId` on the returned `document_id` and returns null unless it belongs to the requester. | +| `src/app/api/images/[id]/signed-url/route.ts` | `document_images` | `GET` | parent-document-verified | Image row fetched by id, then its parent document resolved through `withOwnerReadScope`; 404 and nothing signed unless the parent is visible. | +| `src/app/api/images/signed-urls/route.ts` | `document_images` | `POST` | parent-document-verified | Batch form of the same pattern (`/api/documents/images/batch` is a bare re-export of this module); only images whose parent survived `withOwnerReadScope` are signed. | +| `src/app/api/ingestion/jobs/route.ts` | `ingestion_jobs` | `GET` (×2) | documents-inner-join | Page query and active-count query both join `documents!inner` and filter `.eq("documents.owner_id", user.id)` on the chain itself. | +| `src/app/api/ingestion/quality/route.ts` | `ingestion_jobs` | `GET` | owner-scoped-id-list (`documentIds`) | `.in("document_id", documentIds)` from the handler's `.eq("owner_id", user.id)` documents query. | +| `src/app/api/ingestion/quality/route.ts` | `ingestion_job_stages` | `GET` | owner-scoped-id-list (`documentIds`) | Same owner-scoped id list. | +| `src/app/api/ingestion/quality/route.ts` | `document_pages` | `GET` | owner-scoped-id-list (`documentIds`) | Same owner-scoped id list. This one reads page `text`, so it is the highest-value derived read in the handler. | +| `src/app/api/ingestion/quality/route.ts` | `document_images` | `GET` | owner-scoped-id-list (`documentIds`) | Same owner-scoped id list; image counters and metadata. | +| `src/app/api/jobs/route.ts` | `ingestion_jobs` | `GET` | documents-inner-join | `documents!inner` + `.eq("documents.owner_id", user.id)` on the chain. | +| `src/app/api/search/interaction/route.ts` | `document_chunks` | `ownedChunkExists` | reviewed-indirect | Existence probe selecting only `id`, constrained to the document id the caller passed; the POST handler calls `ownedDocumentExists` first and only reaches this helper when that returned true. | +| `src/app/api/setup-status/route.ts` | `ingestion_jobs` | `readSchemaStatus`, `readWorkerStatus` (×2) | reviewed-indirect | Local-origin-gated schema and worker-liveness probes: newest job status plus a head-only count of pending/processing jobs. No job rows or document identity leave the probe (§3 / TEN-N1). | +| `src/lib/document-detail.ts` | `document_chunks` | `loadAuthorizedDocumentDetail` (×2) | owner-pinned-document-id (`id`) | Selected-chunk lookup and chunk window, both for the id `withOwnerReadScope` resolved at the top of the loader, which throws 404 before any child query. This is the read that serves the document viewer's text. | +| `src/lib/document-detail.ts` | `document_pages` | `loadAuthorizedDocumentDetail` | owner-pinned-document-id (`id`) | Page-window read for the same owner-resolved document id. | +| `src/lib/document-detail.ts` | `document_images` | `loadAuthorizedDocumentDetail` | owner-pinned-document-id (`id`) | Image read for the same owner-resolved document id. | + +### Mechanical scan: tables outside the three tiers (2026-09-02) + +A fourth signal, added after the security review of the scanner. The three tiers key off +`owner_id`, `user_id` and `document_id`; a table that carries **none** of those lands in no tier, +and before this signal existed it was simply skipped — zero coverage, and no mention anywhere that +coverage was missing. Any such table queried by a scanned file must now be declared in +`UNTIERED_TABLE_DECLARATIONS` in [`scripts/lib/tenancy-scan.mjs`](../../scripts/lib/tenancy-scan.mjs) +with a reason, or the scan fails. + +**A rename is the way back into this hole.** The tier derivation reads column names, so renaming +`owner_id`, `user_id` or `document_id` on an existing table — or introducing a tenancy column under a +**fourth** name, which is exactly what `owner_role`/`owner_user_id` is — silently drops that table out +of its tier. This list is the only thing that then notices, and it notices by failing rather than by +staying quiet. + +| File | Table | Scope | Why it is safe | +| --------------------------------------- | ---------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/app/api/clinical-quality/route.ts` | `clinical_quality_feedback_triage` | `loadClinicalQualitySnapshot` | Tenancy columns are named `owner_role`/`owner_user_id`, so no tier claims this table. Administrator-gated cross-tenant governance triage queue: `GET`/`PATCH` call `authorizeAndLimit` before this helper runs, and the projection is triage disposition metadata (signal type/id, status, resolution code, reviewer id, timestamps) — never question, answer, excerpt, or patient text. | + --- ## 7. Non-blocking findings diff --git a/docs/scripts-index.md b/docs/scripts-index.md index 4f5d2cacd3..cc2ec17116 100644 --- a/docs/scripts-index.md +++ b/docs/scripts-index.md @@ -1,6 +1,6 @@ # Scripts index -Curated map of `scripts/` (287 files) and the `package.json` script surface (292 entries), +Curated map of `scripts/` (288 files) and the `package.json` script surface (292 entries), grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run ` referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above. diff --git a/scripts/check-owner-scope-api.mjs b/scripts/check-owner-scope-api.mjs index dcc5690ca1..e6bb96130e 100644 --- a/scripts/check-owner-scope-api.mjs +++ b/scripts/check-owner-scope-api.mjs @@ -7,14 +7,35 @@ // 0/33 route gaps, but flagged (§6 item 2) that a *future* handler dropping the owner // filter is the single regression class this design is exposed to. // -// This guard closes that class statically: it fails when a `src/app/api/**` handler -// queries an OWNER-SCOPED table (any table with an `owner_id` column in +// This guard closes that class statically. It runs in TWO phases, and both must pass: +// +// PHASE 1 (this file) — the handler-level regex sweep. It fails when a `src/app/api/**` +// handler queries an OWNER-SCOPED table (any table with an `owner_id` column in // supabase/schema.sql) without a recognised owner-scoping construct in the enclosing // handler — `.eq("owner_id"...)`, `withOwnerReadScope`, `requireOwnerScope`, // `requireOwnedDocument`/`loadOwnedDocument`/`ownedDocumentId`, a `documents!inner` // + `documents.owner_id` join, or an `owner_id:` write payload. Intentional // exceptions (indirect scoping the reviewer confirmed safe) live in -// OWNER_SCOPE_ALLOWLIST with a reason. +// OWNER_SCOPE_ALLOWLIST with a reason. This phase is deliberately coarse: it does not +// parse TypeScript, it considers only owner_id-bearing tables, and it attributes scope +// per HANDLER rather than per query. It is kept as a cheap, independent second opinion. +// +// PHASE 2 (scripts/lib/tenancy-scan.mjs) — the mechanical AST scan, shared verbatim with +// tests/retrieval-owner-filter-guard.test.ts so the two guards cannot drift apart. It is +// strictly stronger than phase 1 on every axis phase 1 covers, and it closes five things +// phase 1 structurally cannot see (see that module's header for the full rationale): +// A join-through tables (`document_chunks`, `document_pages`, `document_images`, +// `ingestion_jobs`, …) have no `owner_id` column, so phase 1 ignores them entirely — +// including in its own self-test below, which still asserts `document_chunks` is not +// flagged. Phase 2 gives them a declared, reviewed inventory instead. +// B `user_id` tenancy (`user_favourites`, `user_favourite_sets`, `user_preferences`). +// C scope attributed per QUERY CHAIN, not per function. +// D a wider file set: every `.ts` under `src/app/api` plus a named list of server-side +// read modules outside it. +// E the one dynamic `.rpc()` dispatcher, `callVersionedRetrievalRpc`. +// The two phases share no code and no allowlist; a table tier in one must never contradict +// the other, which is why phase 2 derives its tiers from src/lib/supabase/database.types.ts +// rather than restating them here. // // Usage: // node scripts/check-owner-scope-api.mjs scan the repo; exit 1 on any violation @@ -24,6 +45,8 @@ import { readFileSync, realpathSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { emptyTierNames, scanRpcDispatch, scanTenancy } from "./lib/tenancy-scan.mjs"; + // Recognised owner-scoping constructs. If any appears in the enclosing handler of an // owner-scoped `.from(...)`, that query is considered scoped. `owner_id` (as a substring) // covers `.eq("owner_id"...)`, `.is("owner_id"...)`, `.or("owner_id.eq...")`, insert/update @@ -269,7 +292,10 @@ function runSelfTest() { "nested helper / sibling handler must not mask an unscoped query", ); - // A non-owner-scoped table is not the guard's concern. + // A non-owner-scoped table is not PHASE 1's concern — it has no owner_id column to filter + // on. This is blind spot A, and it is closed by phase 2's derived-tier inventory, not by + // widening this regex sweep. Do not "fix" this assertion; changing it would only make + // phase 1 flag every join-through query with no way to describe why one is safe. const otherTable = `export async function GET() { const { data } = await supabase.from("document_chunks").select("*"); return data; @@ -281,12 +307,34 @@ function runSelfTest() { const parsed = ownerScopedTablesFromSchema(schema); expect(parsed.has("documents") && !parsed.has("document_images"), "schema parse: owner_id tables only"); + // Phase 2 sanity: the shared scanner must classify the three tiers and must not have been + // reduced to a no-op. Its per-rule pass/fail fixtures live in + // tests/retrieval-owner-filter-guard.test.ts, which drives the same exported functions. + try { + const scan = scanTenancy(process.cwd()); + expect(scan.counts.direct > 0, "phase 2: found no direct-tier queries"); + expect(scan.counts.userKeyed > 0, "phase 2: found no user-keyed queries"); + expect(scan.counts.derived > 0, "phase 2: found no derived-tier queries"); + expect(scan.tiers.derived.has("document_chunks"), "phase 2: document_chunks must be a derived-tier table"); + expect(!scan.tiers.direct.has("document_chunks"), "phase 2: document_chunks must not be a direct-tier table"); + expect(scanRpcDispatch(process.cwd()).dispatcherCallSites.length > 0, "phase 2: found no versioned-RPC call sites"); + // The anti-vacuous rule main() applies: an emptied tier (a database.types.ts reformat + // defeats the indentation-anchored parse) must be reported, not exited 0 on. + expect(emptyTierNames(scan.counts).length === 0, "phase 2: live scan has an empty tier"); + expect( + emptyTierNames({ direct: 0, userKeyed: 0, derived: 0 }).length === 3, + "phase 2: an empty scan must be reported as vacuous, not clean", + ); + } catch (error) { + failures.push(`phase 2 self-test threw: ${error instanceof Error ? error.message : String(error)}`); + } + if (failures.length > 0) { console.error("✗ owner-scope guard self-test FAILED:"); for (const f of failures) console.error(` - ${f}`); process.exit(1); } - console.log("✓ owner-scope guard self-test passed."); + console.log("✓ owner-scope guard self-test passed (phase 1 fixtures + phase 2 scanner sanity)."); } function main() { @@ -298,24 +346,67 @@ function main() { const files = readTrackedApiFiles(); const { ownerTables, violations } = scanRepo({ schemaText, files }); + let failed = false; + if (violations.length === 0) { console.log( - `✓ owner-scope: ${files.length} src/app/api files clean against ${ownerTables.size} owner-scoped tables.`, + `✓ owner-scope phase 1: ${files.length} src/app/api files clean against ${ownerTables.size} owner-scoped tables.`, + ); + } else { + failed = true; + console.error( + `✗ owner-scope phase 1: ${violations.length} query(ies) on owner-scoped tables lack an owner filter:\n`, + ); + for (const v of violations) { + console.error( + ` ${v.file}:${v.line} .from("${v.table}") — no owner_id / withOwnerReadScope / owned-doc guard in this handler`, + ); + } + console.error( + '\nScope the query (.eq("owner_id", …) or withOwnerReadScope/requireOwnedDocument), or, if ownership is enforced\n' + + "indirectly and reviewed, add a documented entry to OWNER_SCOPE_ALLOWLIST in scripts/check-owner-scope-api.mjs.", ); - process.exit(0); } - console.error(`✗ owner-scope: ${violations.length} query(ies) on owner-scoped tables lack an owner filter:\n`); - for (const v of violations) { + // Phase 2: the shared mechanical scan (per-chain scope, three tiers, wider file set). + const tenancy = scanTenancy(process.cwd()); + const rpc = scanRpcDispatch(process.cwd()); + + // ANTI-VACUOUS: the tier derivation parses src/lib/supabase/database.types.ts with + // indentation-anchored patterns, so a reformat of that generated file empties every tier — + // and an empty tier set means zero sites, zero violations and a green exit. Without these + // assertions the shipped gate could print "0 direct, 0 user-keyed and 0 derived-tier + // queries" and pass. A scan that finds nothing is a broken scan, never a clean repo. + const emptyTiers = emptyTierNames(tenancy.counts); + const vacuous = emptyTiers.length > 0; + if (vacuous) { + failed = true; console.error( - ` ${v.file}:${v.line} .from("${v.table}") — no owner_id / withOwnerReadScope / owned-doc guard in this handler`, + `\n✗ owner-scope phase 2: found NO ${emptyTiers.join(", ")} queries. The scan is vacuous — most likely the\n` + + " src/lib/supabase/database.types.ts tier parse stopped matching (its patterns are anchored to the\n" + + " generated file's exact indentation). Fix the parse; do not treat an empty scan as a clean repo.", ); } - console.error( - '\nScope the query (.eq("owner_id", …) or withOwnerReadScope/requireOwnedDocument), or, if ownership is enforced\n' + - "indirectly and reviewed, add a documented entry to OWNER_SCOPE_ALLOWLIST in scripts/check-owner-scope-api.mjs.", - ); - process.exit(1); + + if (!vacuous && tenancy.violations.length === 0 && rpc.violations.length === 0) { + console.log( + `✓ owner-scope phase 2: ${tenancy.counts.direct} direct, ${tenancy.counts.userKeyed} user-keyed, ` + + `${tenancy.counts.derived} derived-tier, ${tenancy.counts.untiered} untiered-table and ` + + `${tenancy.counts.dynamicFrom} dynamic-table-dispatch queries scoped ` + + `on their chain or declared; ${rpc.dispatcherCallSites.length} versioned-RPC call sites, all literal.`, + ); + } else if (tenancy.violations.length > 0 || rpc.violations.length > 0) { + failed = true; + console.error(`\n✗ owner-scope phase 2: ${tenancy.violations.length + rpc.violations.length} finding(s):\n`); + for (const v of [...tenancy.violations, ...rpc.violations]) console.error(` ${v}\n`); + console.error( + "Put the tenancy predicate on the query's own chain, or add a reviewed entry to SCOPE_EXEMPTIONS /\n" + + "DERIVED_QUERY_INVENTORY / UNTIERED_TABLE_DECLARATIONS in scripts/lib/tenancy-scan.mjs AND to the tables in\n" + + "docs/audit/tenancy-defense-in-depth-review.md §6 (a committed test checks both).", + ); + } + + process.exit(failed ? 1 : 0); } // Only run the scan when executed directly (not when imported by the test suite). diff --git a/scripts/lib/tenancy-scan.mjs b/scripts/lib/tenancy-scan.mjs new file mode 100644 index 0000000000..0ab37a6b36 --- /dev/null +++ b/scripts/lib/tenancy-scan.mjs @@ -0,0 +1,1802 @@ +// Mechanical tenancy scanner (audit finding D2 / M6, extended 2026-09-02 for the five +// blind spots recorded in docs/audit/tenancy-defense-in-depth-review.md §6 item 2). +// +// WHY THIS EXISTS +// --------------- +// Migration 20260719070000_align_existing_acls revokes every privilege on every public +// base table from `public`, `anon` and `authenticated` and re-grants only `service_role` +// (supabase/roles.sql makes that the default for future objects). That is deliberate and +// is pinned by tests/supabase-schema.test.ts. Its consequence is that the ~30 RLS policies +// written TO `authenticated` can never be evaluated: every read path uses the +// RLS-bypassing admin client (createAdminClient), so **application code is the only +// tenancy boundary**. One missing owner predicate has nothing behind it. +// +// This module is the mechanical half of that boundary. It is imported by BOTH +// scripts/check-owner-scope-api.mjs (npm run check:owner-scope → verify:cheap + CI) and +// tests/retrieval-owner-filter-guard.test.ts, so the two guards cannot drift apart. +// +// THE THREE TABLE TIERS (derived from src/lib/supabase/database.types.ts, never hand-listed) +// direct — the row itself carries `owner_id` +// user-keyed — the row carries `user_id` and no `owner_id` (account-scoped tables; +// withOwnerReadScope is unusable there because it filters `owner_id`) +// derived — the row carries `document_id` and neither owner column, so ownership +// runs document_id -> documents.owner_id. These join-through tables hold +// the actual document text and images and were invisible to both guards +// before this module existed. +// A queried table that lands in NONE of the three (a tenancy column under a fourth name, the way +// `clinical_quality_feedback_triage` carries `owner_user_id`) gets no per-chain rule at all, so it +// must be declared in UNTIERED_TABLE_DECLARATIONS with a reason or the scan fails. That fourth +// signal exists because such a table previously got zero coverage with zero signal. +// +// SCOPE IS ATTRIBUTED PER QUERY CHAIN, NOT PER FUNCTION. The older guards asked whether a +// sanctioned token appeared anywhere in the enclosing function, so a handler that scoped +// query 1 and forgot query 2 passed. Here the predicate must sit on the same fluent chain +// as the `.from("table")` call (or that chain must be handed to a sanctioned wrapper). +// Genuinely indirect scoping is a DECLARED exemption naming its proof — that is the point. +// +// FILE SET. Every `.ts` under `src/app/api` plus an explicit NAMED list of server-side read +// modules (SCANNED_LIB_MODULES). A named list, not a glob over `src/lib/**`, so the +// boundary is a deliberate decision and the scan never acquires authority over +// `src/lib/rag/**` (a protected ranking surface — see AGENTS.md "RAG ranking protection"). +// +// OUT OF SCOPE BY DECISION, not by oversight: +// worker/** — the ingestion worker runs job-scoped as service_role against rows it +// claimed through claim_ingestion_jobs; its tenancy model is "the job row +// names the document", not "the request names the owner". +// scripts/** — operator tooling run by a human with the service key; it is deliberately +// cross-tenant (reindex, eval, governance sweeps). +// supabase/functions/** — Deno edge functions, same job-scoped model as the worker. +// Adding any of those here would require a different ownership model, not a wider glob. + +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import ts from "@typescript/typescript6"; + +/* ------------------------------------------------------------------ file set */ + +/** + * Server-side read modules outside `src/app/api` that serve owner data to a page or a + * route. Deliberately enumerated one by one; see the header note on why this is not a glob. + */ +export const SCANNED_LIB_MODULES = [ + // Serves the server component src/app/(search-app)/documents/[id]/page.tsx as well as + // the /api/documents/[id] route. + "src/lib/document-detail.ts", + // Serves the five src/app/(search-app)/sources/** pages. + "src/lib/sources/document-source-loader.ts", + // Operator observability aggregates read by /api/health's deep probe. + "src/lib/observability/answer-slo.ts", + "src/lib/observability/spend-metrics.ts", + // The delegate behind the ONE dynamic table dispatch in the scanned set: /api/upload + // hands it `(table) => adminSupabase.from(table)`, so its `documents` query is the real + // owner filter for that path and was previously scanned by nothing (Codex review). + "src/lib/document-naming.ts", +]; + +export const API_DIR_SEGMENTS = ["src", "app", "api"]; + +function toPosix(value) { + return value.split(sep).join("/"); +} + +/** Every `.ts` file under `src/app/api` (not just `route.ts`), plus SCANNED_LIB_MODULES. */ +export function scannedFiles(repoRoot) { + const apiDir = join(repoRoot, ...API_DIR_SEGMENTS); + const apiFiles = readdirSync(apiDir, { recursive: true }) + .map((entry) => String(entry)) + .filter((name) => /\.tsx?$/.test(name) && !name.endsWith(".d.ts")) + .map((name) => join(apiDir, name)); + const libFiles = SCANNED_LIB_MODULES.map((name) => join(repoRoot, ...name.split("/"))); + return [...apiFiles, ...libFiles].sort(); +} + +export function relativeToRepo(repoRoot, file) { + return toPosix(relative(repoRoot, file)); +} + +/* -------------------------------------------------------------- table tiers */ + +/** + * Classify every generated table in src/lib/supabase/database.types.ts into the three + * tenancy tiers. Generalises the older `ownerIdTablesFromDatabaseTypes()`; the parse is + * bounded to the `public.Tables` block so RPC (`Functions`) entries cannot be mistaken + * for tables. + */ +export function tableTiersFromDatabaseTypes(text) { + const lines = text.split(/\r?\n/); + const direct = new Set(); + const userKeyed = new Set(); + const derived = new Set(); + const all = new Set(); + + let inPublic = false; + let inTables = false; + let table = null; + let inRow = false; + let columns = null; + + const flush = () => { + if (!table || !columns) return; + all.add(table); + if (columns.has("owner_id")) direct.add(table); + else if (columns.has("user_id")) userKeyed.add(table); + else if (columns.has("document_id")) derived.add(table); + table = null; + columns = null; + }; + + for (const line of lines) { + if (/^ public: \{$/.test(line)) { + inPublic = true; + continue; + } + if (!inPublic) continue; + if (/^ Tables: \{$/.test(line)) { + inTables = true; + continue; + } + if (/^ (Views|Functions|Enums|CompositeTypes): /.test(line)) { + flush(); + inTables = false; + inPublic = false; + continue; + } + if (!inTables) continue; + + const tableMatch = line.match(/^ ([a-z0-9_]+): \{$/); + if (tableMatch) { + flush(); + table = tableMatch[1]; + columns = new Set(); + inRow = false; + continue; + } + if (!table) continue; + if (/^ Row: \{$/.test(line)) { + inRow = true; + continue; + } + if (/^ (Insert|Update|Relationships): /.test(line)) { + inRow = false; + continue; + } + if (!inRow) continue; + const column = line.match(/^ ([a-z0-9_]+)\??:/); + if (column) columns.add(column[1]); + } + flush(); + + // NOTE: a tenancy column under a FOURTH name (the way `clinical_quality_feedback_triage` + // carries `owner_user_id`) puts its table in `all` and in no tier — which is exactly what + // the untiered-table declaration list exists to surface. Renaming `owner_id`,`user_id` or + // `document_id` on an existing table would likewise drop it out of its tier silently, and + // the untiered list would then be the only thing that notices. + return { direct, userKeyed, derived, all }; +} + +/* ------------------------------------------------------------- AST helpers */ + +const OWNER_COLUMNS = new Set(["owner_id", "documents.owner_id"]); +const USER_COLUMNS = new Set(["user_id"]); + +function isStringLiteral(node) { + return Boolean(node) && ts.isStringLiteralLike(node); +} + +/* --------------------------------------------- PostgREST `or=` disjunctions (P1-2) */ + +/** + * Split a PostgREST filter string on its TOP-LEVEL commas, keeping `and(...)` / `or(...)` + * groups intact. The one real producer of this shape, `withOwnerReadScope` + * (src/lib/public-api-access.ts), emits exactly such a nested filter: + * `owner_id.eq.,and(owner_id.is.null,metadata->>public_corpus.eq.true)` + */ +function splitTopLevelTerms(filter) { + const terms = []; + let depth = 0; + let current = ""; + for (const character of filter) { + if (character === "(") depth += 1; + if (character === ")") depth -= 1; + if (character === "," && depth === 0) { + terms.push(current); + current = ""; + continue; + } + current += character; + } + terms.push(current); + return terms.map((term) => term.trim()).filter((term) => term.length > 0); +} + +/** Does one term of a PostgREST filter restrict `column`? */ +function termConstrainsColumn(term, column) { + const group = term.match(/^(and|or)\(([\s\S]*)\)$/); + if (group) { + const inner = splitTopLevelTerms(group[2]); + if (inner.length === 0) return false; + // A conjunction is restricted as soon as ONE of its terms restricts the column; a + // nested disjunction only when EVERY one of its terms does. Anything else — a `not.` + // prefix in particular — falls through to the literal test below and fails closed. + return group[1] === "and" + ? inner.some((part) => termConstrainsColumn(part, column)) + : inner.every((part) => termConstrainsColumn(part, column)); + } + return new RegExp(`^${column}\\.(eq|is)\\.`).test(term); +} + +/** + * PostgREST `or=` is a DISJUNCTION, so a single owner term restricts nothing unless every + * alternative restricts. Before 2026-09-02 any literal merely CONTAINING `owner_id.eq.` or + * `owner_id.is.` counted as an owner proof, which accepted + * `.or("owner_id.is.null,status.eq.indexed")` and `.or("owner_id.eq.,id.eq.abc")` — + * both of which return other tenants' rows (security review P1-2). + */ +export function orFilterConstrainsColumn(filter, column) { + const terms = splitTopLevelTerms(filter); + return terms.length > 0 && terms.every((term) => termConstrainsColumn(term, column)); +} + +/* ------------------------------- sanctioned wrappers / owning helpers (P2-4) */ + +/** + * Helpers whose call proves the caller owns the document id it was handed, mapped to the + * module an import of that name must come from — or `null` when the name has no sanctioned + * module and must therefore be a FILE-LOCAL definition that itself carries an owner + * predicate. All four are file-local in this repo today (`loadOwnedDocument` is a local + * function in src/app/api/documents/[id]/table-facts/route.ts), so the idiom is normalised + * and matching on identifier TEXT alone would let a local no-op of the same name vouch for + * a query it never scoped. + */ +export const OWNING_DOCUMENT_HELPERS = new Map([ + ["requireOwnedDocument", null], + ["loadOwnedDocument", null], + ["ownedDocumentId", null], + ["ownedDocumentExists", null], +]); + +/** Wrappers that apply the owner predicate to a query builder passed as their first argument. */ +export const SANCTIONED_QUERY_WRAPPERS = new Map([["withOwnerReadScope", /(?:^|\/)public-api-access$/]]); + +/** The parameter or property name that carries the document id in an owning-helper call. */ +const DOCUMENT_ID_PARAMETER = /^document_?id$/i; + +/** Strip wrappers that do not change the value an expression denotes. */ +function unwrapExpression(node) { + let current = node; + while ( + current && + (ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isNonNullExpression(current) || + (typeof ts.isSatisfiesExpression === "function" && ts.isSatisfiesExpression(current))) + ) { + current = current.expression; + } + return current; +} + +/** Local name -> module specifier for every import in this file. */ +function importedNames(sourceFile) { + const imports = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !isStringLiteral(statement.moduleSpecifier)) continue; + const specifier = statement.moduleSpecifier.text; + if (statement.importClause?.name) imports.set(statement.importClause.name.text, specifier); + const bindings = statement.importClause?.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) imports.set(element.name.text, specifier); + } + if (bindings && ts.isNamespaceImport(bindings)) imports.set(bindings.name.text, specifier); + } + return imports; +} + +/** Every function-like declaration of `name` in this file. */ +function localFunctionDeclarations(sourceFile, name) { + const declarations = []; + const visit = (node) => { + if (ts.isFunctionDeclaration(node) && node.name?.text === name && node.body) declarations.push(node); + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === name && + node.initializer && + (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + ) { + declarations.push(node.initializer); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return declarations; +} + +/** Does this subtree apply an owner predicate to a query builder? */ +function containsOwnerPredicate(node) { + let found = false; + const visit = (current) => { + if (found || !current) return; + if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) { + const method = current.expression.name.text; + const [first] = current.arguments; + if ( + (method === "eq" || method === "in" || method === "is") && + isStringLiteral(first) && + OWNER_COLUMNS.has(first.text) + ) { + found = true; + return; + } + if (method === "or" && isStringLiteral(first) && orFilterConstrainsColumn(first.text, "owner_id")) { + found = true; + return; + } + } + ts.forEachChild(current, visit); + }; + visit(node); + return found; +} + +/** + * Resolve the sanctioned wrapper and owning-helper names available in ONE source file. A + * name counts only when it is imported from its sanctioned module, or defined in this file + * by a declaration that itself carries an owner predicate. Identifier text is never enough. + */ +export function resolveSanctionedNames(sourceFile) { + const imports = importedNames(sourceFile); + const wrappers = new Set(); + const helpers = new Map(); + + const owningLocalDeclarations = (name) => { + const declarations = localFunctionDeclarations(sourceFile, name); + if (declarations.length === 0) return null; + return declarations.every((declaration) => containsOwnerPredicate(declaration)) ? declarations : null; + }; + const importSatisfies = (specifier, modulePattern) => + Boolean(modulePattern) && modulePattern.test(specifier.replace(/\.[cm]?[tj]sx?$/, "")); + + for (const [name, modulePattern] of SANCTIONED_QUERY_WRAPPERS) { + const specifier = imports.get(name); + if (specifier !== undefined) { + if (importSatisfies(specifier, modulePattern)) wrappers.add(name); + continue; + } + if (owningLocalDeclarations(name)) wrappers.add(name); + } + + for (const [name, modulePattern] of OWNING_DOCUMENT_HELPERS) { + const specifier = imports.get(name); + if (specifier !== undefined) { + // An imported helper's parameter names are not visible here, so only a + // `documentId:`-keyed object argument can register an owning-document argument. + if (importSatisfies(specifier, modulePattern)) helpers.set(name, { parameters: [] }); + continue; + } + const declarations = owningLocalDeclarations(name); + if (!declarations) continue; + helpers.set(name, { + parameters: declarations[0].parameters.map((parameter) => + ts.isIdentifier(parameter.name) ? parameter.name.text : null, + ), + }); + } + + return { wrappers, helpers }; +} + +/** Walk OUTWARD from `.from(...)` to the top of the fluent chain that contains it. */ +function chainTop(node) { + let current = node; + while (current.parent) { + const parent = current.parent; + if ( + (ts.isPropertyAccessExpression(parent) || + ts.isCallExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isParenthesizedExpression(parent)) && + parent.expression === current + ) { + current = parent; + continue; + } + break; + } + return current; +} + +/** Walk INWARD over the chain's method calls, newest first. */ +function chainMethodCalls(top) { + const calls = []; + let current = top; + while (current) { + if (ts.isCallExpression(current)) { + if (ts.isPropertyAccessExpression(current.expression)) { + calls.push({ name: current.expression.name.text, args: current.arguments, node: current }); + current = current.expression.expression; + continue; + } + current = current.expression; + continue; + } + if ( + ts.isPropertyAccessExpression(current) || + ts.isNonNullExpression(current) || + ts.isParenthesizedExpression(current) + ) { + current = current.expression; + continue; + } + break; + } + return calls; +} + +/** The nearest enclosing function-like node that has a derivable name; else the source file. */ +function enclosingNamedScope(node) { + let current = node.parent; + while (current) { + if (ts.isFunctionDeclaration(current) && current.name) return current.name.text; + if (ts.isMethodDeclaration(current) && current.name && ts.isIdentifier(current.name)) return current.name.text; + if ( + (ts.isFunctionExpression(current) || ts.isArrowFunction(current)) && + current.parent && + ts.isVariableDeclaration(current.parent) && + ts.isIdentifier(current.parent.name) + ) { + return current.parent.name.text; + } + current = current.parent; + } + return ""; +} + +/** Every enclosing named scope, innermost first — a `.rpc()` inside a nested arrow still + * belongs to the exported function that contains it. */ +function enclosingNamedScopeChain(node) { + const scopes = []; + let current = node.parent; + while (current) { + if (ts.isFunctionDeclaration(current) && current.name) scopes.push(current.name.text); + else if (ts.isMethodDeclaration(current) && current.name && ts.isIdentifier(current.name)) + scopes.push(current.name.text); + else if ( + (ts.isFunctionExpression(current) || ts.isArrowFunction(current)) && + current.parent && + ts.isVariableDeclaration(current.parent) && + ts.isIdentifier(current.parent.name) + ) { + scopes.push(current.parent.name.text); + } + current = current.parent; + } + return scopes; +} + +/** The nearest enclosing function-like NODE (named or not) — used for same-scope fact lookup. */ +function enclosingNamedScopeNode(node) { + let current = node.parent; + let fallback = null; + while (current) { + if (ts.isSourceFile(current)) return fallback ?? current; + if (ts.isFunctionDeclaration(current) && current.name) return current; + if (ts.isMethodDeclaration(current) && current.name && ts.isIdentifier(current.name)) return current; + if ( + (ts.isFunctionExpression(current) || ts.isArrowFunction(current)) && + current.parent && + ts.isVariableDeclaration(current.parent) && + ts.isIdentifier(current.parent.name) + ) { + return current; + } + if (!fallback && ts.isFunctionLike(current)) fallback = current; + current = current.parent; + } + return fallback; +} + +/** + * The identifier a filter value is rooted at, with trailing method applications stripped: + * `ownedIds.slice(i, i + 100)` -> `ownedIds`, `args.documentId` -> `args.documentId`, + * `documents.map((d) => d.id)` -> `documents`. This is what lets an inventory entry name + * the SAME identifier the ownership proof pinned and have that identity checked in the AST + * rather than trusted from a reason string. + */ +function valueExpressionText(node) { + if (!node) return null; + if (ts.isIdentifier(node)) return node.text; + if (ts.isStringLiteralLike(node)) return JSON.stringify(node.text); + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + return valueExpressionText(node.expression.expression); + } + if (ts.isPropertyAccessExpression(node)) return node.getText(); + if (ts.isNonNullExpression(node) || ts.isParenthesizedExpression(node)) return valueExpressionText(node.expression); + return null; +} + +/** + * Does this expression stamp `owner_id:` / `user_id:` as a TOP-LEVEL property of the row(s) + * it writes? Deliberately bounded: the previous implementation recursed the entire argument + * subtree, so an `owner_id` key buried inside a JSON `metadata` column counted as a stamp + * (security review P1-1). An array must stamp EVERY element; a `.map()`/`.flatMap()` must + * stamp every row its callback returns. + */ +function expressionStampsColumn(node, columns) { + const expression = unwrapExpression(node); + if (!expression) return false; + if (ts.isObjectLiteralExpression(expression)) { + return expression.properties.some((property) => { + if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return false; + const name = property.name; + const text = ts.isIdentifier(name) ? name.text : ts.isStringLiteralLike(name) ? name.text : null; + return Boolean(text && columns.has(text)); + }); + } + if (ts.isArrayLiteralExpression(expression)) { + return ( + expression.elements.length > 0 && expression.elements.every((element) => expressionStampsColumn(element, columns)) + ); + } + if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression)) { + const method = expression.expression.name.text; + if (method === "map" || method === "flatMap") { + const callback = expression.arguments.find( + (argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument), + ); + if (callback) return callbackReturnsStampedRow(callback, columns); + } + } + return false; +} + +/** Every row a `.map()` callback can return must carry the stamp. */ +function callbackReturnsStampedRow(callback, columns) { + if (!callback.body) return false; + if (!ts.isBlock(callback.body)) return expressionStampsColumn(callback.body, columns); + let sawReturn = false; + let allStamped = true; + const visit = (node) => { + if (ts.isFunctionLike(node)) return; + if (ts.isReturnStatement(node)) { + sawReturn = true; + if (!node.expression || !expressionStampsColumn(node.expression, columns)) allStamped = false; + return; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(callback.body, visit); + return sawReturn && allStamped; +} + +/** + * The initializer of `identifier`, resolved LEXICALLY AT ITS USE SITE: the nearest + * enclosing scope that binds the name wins — its own block, then each enclosing block, + * then the function, then a top-level `const` at module scope. + * + * Two earlier versions were both unsound. The first searched the whole file for any + * variable of that name, so an owner-stamped `rows` in helper A vouched for + * `rows = body.rows` in handler B (security review P2-3). The second bounded that to the + * enclosing function but still scanned its entire body, so a declaration in a SIBLING + * block — including an unreachable one — bound at a use site it never reaches, and + * `if (false) { const rows = [{ owner_id: user.id }]; }` made a later `insert(rows)` over + * an untrusted payload pass (Codex review). + * + * A binding whose value cannot be read — a parameter, a destructuring pattern, a `let`, + * a declaration with no initializer — returns null and STOPS the walk rather than falling + * through to an outer declaration of the same name. The inner binding shadows, so falling + * through would attribute the outer one's owner stamp to a value that never held it. + */ +function lexicalInitializer(identifier) { + const name = identifier.text; + + /** Does this binding name — identifier or destructuring pattern — bind `name`? */ + const bindsName = (bindingName) => { + if (!bindingName) return false; + if (ts.isIdentifier(bindingName)) return bindingName.text === name; + if (ts.isObjectBindingPattern(bindingName) || ts.isArrayBindingPattern(bindingName)) { + return bindingName.elements.some((element) => ts.isBindingElement(element) && bindsName(element.name)); + } + return false; + }; + + // Declarations made DIRECTLY in this statement list. Deliberately NOT recursive: that + // recursion is exactly what let a sibling block's `const` bind at this use site. + const directDeclaration = (statements) => { + for (const statement of statements ?? []) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (bindsName(declaration.name)) return declaration; + } + } + return null; + }; + + const scopeStatements = (node) => { + if (ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node)) return node.statements; + if (ts.isCaseClause(node) || ts.isDefaultClause(node)) return node.statements; + return null; + }; + + /** Bindings a scope introduces other than through its own statement list. */ + const bindsUnreadably = (node) => { + if (ts.isFunctionLike(node) && (node.parameters ?? []).some((parameter) => bindsName(parameter.name))) return true; + if (ts.isCatchClause(node) && bindsName(node.variableDeclaration?.name)) return true; + return Boolean( + (ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) && + node.initializer && + ts.isVariableDeclarationList(node.initializer) && + node.initializer.declarations.some((declaration) => bindsName(declaration.name)), + ); + }; + + // Only a `const` bound to a plain identifier yields a value worth reasoning about. A + // `let` can be reassigned between its declaration and the write, so its initializer + // proves nothing about what is actually sent. + const readableInitializer = (declaration) => { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) return null; + const list = declaration.parent; + if (!list || !ts.isVariableDeclarationList(list) || !(list.flags & ts.NodeFlags.Const)) return null; + return declaration.initializer; + }; + + let current = identifier.parent; + while (current) { + if (bindsUnreadably(current)) return null; + const declaration = directDeclaration(scopeStatements(current)); + if (declaration) return readableInitializer(declaration); + if (ts.isSourceFile(current)) break; + current = current.parent; + } + return null; +} + +/** + * Resolve a write-payload argument. A literal object (or array/`map` of them) is read + * directly; a plain identifier is resolved through `lexicalInitializer` so the very common + * "build the rows above, then `.upsert(rows)`" idiom is still recognised as stamping the + * owner column rather than forced into an exemption it does not need. + */ +function payloadStampsColumn(argument, columns) { + if (!argument) return false; + if (expressionStampsColumn(argument, columns)) return true; + const identifier = unwrapExpression(argument); + if (!identifier || !ts.isIdentifier(identifier)) return false; + const initializer = lexicalInitializer(identifier); + return Boolean(initializer) && expressionStampsColumn(initializer, columns); +} + +/* ---------------------------------------------------------- chain analysis */ + +/** + * Inspect one `.from("table")` chain and report which tenancy predicates ride on it. + * Recognised in-chain forms, all of which exist in this codebase: + * .eq("owner_id", x) / .eq("user_id", x) / .eq("documents.owner_id", x) + * .is("owner_id", null) + * .or("…") where EVERY top-level disjunct constrains the column + * an owner_id:/user_id: key at the top level of an .insert()/.upsert() payload + * plus the chain being handed to a sanctioned wrapper: withOwnerReadScope(chain, ownerId). + */ +function analyzeChain(sourceFile, fromCall, context) { + const top = chainTop(fromCall); + const calls = chainMethodCalls(top); + const proofs = []; + let documentFilter = null; + let idFilter = null; + let innerJoinsDocuments = false; + + for (const call of calls) { + const [first, second] = call.args; + if ((call.name === "eq" || call.name === "in") && isStringLiteral(first)) { + const column = first.text; + if (OWNER_COLUMNS.has(column)) proofs.push(`${call.name}:${column}`); + if (USER_COLUMNS.has(column)) proofs.push(`${call.name}:${column}`); + if (column === "document_id") documentFilter = { method: call.name, argument: valueExpressionText(second) }; + if (column === "id") idFilter = { method: call.name, argument: valueExpressionText(second) }; + } + if (call.name === "is" && isStringLiteral(first) && OWNER_COLUMNS.has(first.text)) { + proofs.push(`is:${first.text}`); + } + if (call.name === "or" && isStringLiteral(first)) { + if (orFilterConstrainsColumn(first.text, "owner_id")) proofs.push("or:owner_id"); + if (orFilterConstrainsColumn(first.text, "user_id")) proofs.push("or:user_id"); + } + if (call.name === "select" && isStringLiteral(first) && /documents!inner/.test(first.text)) { + innerJoinsDocuments = true; + } + // `insert`/`upsert` create the row already owned, so an `owner_id:` key in the payload + // IS the tenancy fact. `update` is deliberately ABSENT: there the key is the value + // WRITTEN, and says nothing about which rows are matched — + // `.update({ owner_id: user.id, title })` with no filter reassigns every tenant's rows + // to the caller, and was reported as ownerScoped before 2026-09-02 (security review + // P1-1). An update is scoped by its `.eq(...)` filters or not at all. + if (call.name === "insert" || call.name === "upsert") { + if (payloadStampsColumn(first, new Set(["owner_id"]))) proofs.push(`${call.name}:owner_id`); + if (payloadStampsColumn(first, new Set(["user_id"]))) proofs.push(`${call.name}:user_id`); + } + } + + // The chain handed straight to withOwnerReadScope(chain, ownerId). + const parent = top.parent; + if ( + parent && + ts.isCallExpression(parent) && + parent.arguments[0] === top && + ts.isIdentifier(parent.expression) && + context.wrappers.has(parent.expression.text) + ) { + proofs.push(`wrapper:${parent.expression.text}`); + } + + return { proofs, documentFilter, idFilter, innerJoinsDocuments }; +} + +const OWNER_PROOF = /^(eq|in|is):(owner_id|documents\.owner_id)$|^(insert|upsert):owner_id$|^or:owner_id$|^wrapper:/; +const USER_PROOF = /^(eq|in):user_id$|^(insert|upsert):user_id$|^or:user_id$/; + +/* --------------------------------------------------------- scope-level facts */ + +/** + * Facts about one enclosing scope that a derived-tier query may lean on for its ownership + * proof: which identifiers were pinned by an owner-scoped `documents` query, which were + * handed to an owning-document helper, and which are locally declared. + */ +function scopeFacts(sourceFile, scopeNode, context, reachableScopes = new Set()) { + const ownerScopedDocumentIds = new Set(); + const owningHelperArguments = new Set(); + const localDeclarations = new Set(); + let hasOwnerScopedDocumentsQuery = false; + + const visit = (node) => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "from" && + node.arguments.length === 1 && + isStringLiteral(node.arguments[0]) && + node.arguments[0].text === "documents" + ) { + const chain = analyzeChain(sourceFile, node, context); + if (chain.proofs.some((proof) => OWNER_PROOF.test(proof))) { + hasOwnerScopedDocumentsQuery = true; + if (chain.idFilter?.argument) ownerScopedDocumentIds.add(chain.idFilter.argument); + } + } + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && context.helpers.has(node.expression.text)) { + // Only the DOCUMENT-ID-shaped argument registers. Collecting every identifier and + // shorthand argument meant `loadOwnedDocument({ supabase, documentId: id })` + // registered `supabase` as an owning-document argument too (security review P2-4). + const helper = context.helpers.get(node.expression.text); + node.arguments.forEach((argument, index) => { + const value = unwrapExpression(argument); + if (!value) return; + if (ts.isObjectLiteralExpression(value)) { + for (const property of value.properties) { + if (ts.isShorthandPropertyAssignment(property) && DOCUMENT_ID_PARAMETER.test(property.name.text)) { + owningHelperArguments.add(property.name.text); + } else if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + DOCUMENT_ID_PARAMETER.test(property.name.text) && + ts.isIdentifier(property.initializer) + ) { + owningHelperArguments.add(property.initializer.text); + } + } + return; + } + // A positional call is resolved against the helper's own parameter names, which is + // possible only because the helper resolved to a file-local definition. + if (ts.isIdentifier(value) && DOCUMENT_ID_PARAMETER.test(helper.parameters[index] ?? "")) { + owningHelperArguments.add(value.text); + } + }); + } + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) localDeclarations.add(node.name.text); + ts.forEachChild(node, (child) => { + // A nested function's body is NOT part of this scope's execution unless the query + // being proved sits inside it. Descending unconditionally let a never-invoked helper + // or callback donate its owner-scoped `documents` query to the enclosing handler, so + // an otherwise unscoped derived-tier read passed OWNER_PINNED_DOCUMENT_ID on the + // strength of code that never runs (Codex review). `reachableScopes` is the chain of + // function-like nodes between the query and this scope — the ones it is lexically + // inside, and therefore the ones that demonstrably run when it does. + if (ts.isFunctionLike(child) && !reachableScopes.has(child)) return; + visit(child); + }); + }; + if (scopeNode) visit(scopeNode); + + return { ownerScopedDocumentIds, owningHelperArguments, localDeclarations, hasOwnerScopedDocumentsQuery }; +} + +/* ------------------------------------------------- dynamic table dispatch */ + +/** The stand-in table name for a `.from(identifier)` whose table cannot be read statically. */ +export const DYNAMIC_TABLE = "(dynamic)"; + +// `.from()` is not a PostgREST-only method name. These receivers are the built-ins whose +// `.from` has nothing to do with the database, and excluding them by name is safe because +// they are globals that cannot be a Supabase client. +const NON_POSTGREST_FROM_RECEIVERS = new Set([ + "Array", + "ArrayBuffer", + "BigInt64Array", + "BigUint64Array", + "Buffer", + "Date", + "Float32Array", + "Float64Array", + "Int8Array", + "Int16Array", + "Int32Array", + "Map", + "Number", + "Object", + "Set", + "String", + "Uint8Array", + "Uint16Array", + "Uint32Array", +]); + +/** + * Is this `.from()` receiver plausibly a PostgREST client, rather than a built-in or a + * Storage handle? Storage's `.from(bucket)` names a bucket, not a table: it carries no + * tenancy column and is governed by bucket policy, so it is not this scan's business. + * Everything unrecognised is treated as a database client, so the check FAILS CLOSED — + * an unfamiliar receiver is reported rather than silently dropped. + */ +function isPostgrestFromReceiver(receiver) { + if (!receiver) return false; + if (ts.isIdentifier(receiver) && NON_POSTGREST_FROM_RECEIVERS.has(receiver.text)) return false; + if (ts.isPropertyAccessExpression(receiver) && receiver.name.text === "storage") return false; + return true; +} + +/* ------------------------------------------------------------------- scan */ + +/** + * Scan one source file and return every table query site with its per-chain tenancy + * verdict. `tiers` comes from tableTiersFromDatabaseTypes. + */ +export function analyzeSource({ relativePath, source, tiers }) { + const sourceFile = ts.createSourceFile( + relativePath, + source, + ts.ScriptTarget.Latest, + /*setParentNodes*/ true, + ts.ScriptKind.TS, + ); + const sites = []; + const factsCache = new Map(); + const context = resolveSanctionedNames(sourceFile); + // Every table the generated types declare. A table that lands in NO tenancy tier gets no + // coverage at all from the three tiers, so it must be declared rather than pass silently + // (security review P2-5). + const allTables = tiers.all ?? new Set([...tiers.direct, ...tiers.userKeyed, ...tiers.derived]); + + const factsFor = (node) => { + const scopeNode = enclosingNamedScopeNode(node) ?? sourceFile; + // Innermost first, so the cache key below is the tightest scope containing the query. + const reachableScopes = new Set(); + let current = node.parent; + while (current && current !== scopeNode) { + if (ts.isFunctionLike(current)) reachableScopes.add(current); + current = current.parent; + } + // Two sites in the same nested callback share a key; two sites in DIFFERENT callbacks + // under one named scope do not, because they can see different facts. + const cacheKey = reachableScopes.values().next().value ?? scopeNode; + if (!factsCache.has(cacheKey)) { + factsCache.set(cacheKey, scopeFacts(sourceFile, scopeNode, context, reachableScopes)); + } + return factsCache.get(cacheKey); + }; + + const visit = (node) => { + const isFromCall = + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "from" && + node.arguments.length === 1; + if (isFromCall && !isStringLiteral(node.arguments[0]) && isPostgrestFromReceiver(node.expression.expression)) { + // A table named through an identifier matches no tier, so before this the query left + // the scan with no signal whatsoever — and one such site is real: /api/upload builds + // `{ from: (table) => adminSupabase.from(table) }` and hands it to planDocumentName, + // whose `documents` query is the actual owner filter. Deleting that filter left both + // phases green (Codex review). Every dynamic dispatch must now be declared, naming + // where the owner filter it delegates to actually lives. + sites.push({ + file: relativePath, + table: DYNAMIC_TABLE, + tier: "dynamic-from", + scope: enclosingNamedScope(node), + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + argument: node.arguments[0].getText(sourceFile), + proofs: [], + ownerScopedChain: false, + userScopedChain: false, + documentFilter: null, + innerJoinsDocuments: false, + facts: factsFor(node), + }); + } + if (isFromCall && isStringLiteral(node.arguments[0])) { + const table = node.arguments[0].text; + const tier = tiers.direct.has(table) + ? "direct" + : tiers.userKeyed.has(table) + ? "user-keyed" + : tiers.derived.has(table) + ? "derived" + : allTables.has(table) + ? "untiered" + : null; + if (tier) { + const chain = analyzeChain(sourceFile, node, context); + sites.push({ + file: relativePath, + table, + tier, + scope: enclosingNamedScope(node), + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + proofs: chain.proofs, + ownerScopedChain: chain.proofs.some((proof) => OWNER_PROOF.test(proof)), + userScopedChain: chain.proofs.some((proof) => USER_PROOF.test(proof)), + documentFilter: chain.documentFilter, + innerJoinsDocuments: chain.innerJoinsDocuments, + facts: factsFor(node), + }); + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return sites; +} + +/* --------------------------------------------------- dynamic RPC dispatch */ + +export const DYNAMIC_RPC_DISPATCHER = { + file: "src/lib/rag/rag-candidate-sources.ts", + fn: "callVersionedRetrievalRpc", +}; + +/** + * The primary retrieval RPCs never appear as `.rpc("literal")` — they go through + * `callVersionedRetrievalRpc(supabase, versionedName, legacyName, args, signal)`, which + * also rewrites `owner_filter` to PUBLIC_OWNER_FILTER_SENTINEL on the public-merge + * fallback path. Tenancy for the whole retrieval layer therefore sits in that ONE + * function, and a second dynamic dispatch site would move it somewhere unreviewed. + * + * Returns every `.rpc()` whose first argument is not a string literal, and every + * `callVersionedRetrievalRpc` call site whose two RPC-name arguments are not literals. + */ +export function analyzeRpcDispatch({ relativePath, source }) { + const sourceFile = ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const dynamicRpcCalls = []; + const dispatcherCallSites = []; + + const visit = (node) => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "rpc" && + node.arguments.length > 0 && + !isStringLiteral(node.arguments[0]) + ) { + dynamicRpcCalls.push({ + file: relativePath, + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + scope: enclosingNamedScope(node), + scopes: enclosingNamedScopeChain(node), + argument: node.arguments[0].getText(sourceFile), + }); + } + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === DYNAMIC_RPC_DISPATCHER.fn + ) { + const [, versioned, legacy] = node.arguments; + dispatcherCallSites.push({ + file: relativePath, + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + literalNames: isStringLiteral(versioned) && isStringLiteral(legacy), + names: [versioned?.getText(sourceFile) ?? "", legacy?.getText(sourceFile) ?? ""], + }); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return { dynamicRpcCalls, dispatcherCallSites }; +} + +/** Every `.ts`/`.tsx` file under `src`, excluding declaration and generated type files. */ +export function allSourceFiles(repoRoot) { + const srcDir = join(repoRoot, "src"); + return readdirSync(srcDir, { recursive: true }) + .map((entry) => String(entry)) + .filter((name) => /\.tsx?$/.test(name) && !name.endsWith(".d.ts") && !name.includes("database.types")) + .map((name) => join(srcDir, name)) + .sort(); +} + +export function readFile(file) { + return readFileSync(file, "utf8"); +} + +/* ------------------------------------------------- declared tenancy proofs */ + +// How an entry proves ownership. The first FIVE are VERIFIED IN THE AST — the entry names an +// identifier and the scanner checks that the very same identifier carries the proof — so the +// reason string cannot drift away from the code. `reviewed-indirect` is the escape hatch for links +// no AST check can express; it carries a written reason and nothing else, which is why it is used +// as sparingly as possible. `untiered-table` is declaration-only by construction: a table carrying +// no tenancy column has no ownership relation for a proof to check. +// +// All five mechanical proofs are ORDER-INSENSITIVE: scopeFacts scans the whole enclosing scope, so +// a proof is satisfied by an ownership query that runs AFTER the protected read or whose result is +// discarded. The "a 404 is returned before this query" clauses below are prose, not checked — +// recorded as a residual gap in docs/audit/tenancy-defense-in-depth-review.md §6. +export const PROOF_KINDS = { + // The query's own chain joins `documents!inner` and filters `documents.owner_id`. + DOCUMENTS_INNER_JOIN: "documents-inner-join", + // `identifier` is filtered as document_id here AND was the id an owner-scoped + // `documents` query pinned (`.eq("id", identifier)`) in the same scope. + OWNER_PINNED_DOCUMENT_ID: "owner-pinned-document-id", + // `identifier` is filtered as document_id here AND was handed to an owning-document + // helper (requireOwnedDocument / loadOwnedDocument / ownedDocumentId / ownedDocumentExists). + OWNED_DOCUMENT_HELPER: "owned-document-helper", + // `identifier` is filtered as document_id here, is declared in this scope, and this + // scope runs an owner-scoped `documents` query the list is derived from. + OWNER_SCOPED_ID_LIST: "owner-scoped-id-list", + // The row is read first and its parent document is verified afterwards by an + // owner-scoped `documents` query in the same scope; nothing is returned until it passes. + PARENT_DOCUMENT_VERIFIED: "parent-document-verified", + // No mechanical link is expressible. Written reason only. + REVIEWED_INDIRECT: "reviewed-indirect", + // The table carries none of the three tenancy columns, so no tier covers it at all and no + // mechanical proof is even definable. Declaration-only, and the declaration is the signal. + UNTIERED_TABLE: "untiered-table", + // The table name is not a string literal, so no tier can claim the query and no chain + // predicate can be read. Declaration-only: the entry must name the scanned module the + // dispatch delegates to, and that module is where the owner filter is then checked. + DYNAMIC_TABLE_DISPATCH: "dynamic-table-dispatch", +}; + +const CLINICAL_QUALITY_REASON = + "Administrator-gated cross-tenant governance aggregate. GET and PATCH call authorizeAndLimit before these helpers run and the response carries governance metadata only — never raw question, answer, excerpt, or patient text. Per-owner filtering would defeat the oversight purpose (tenancy review §6)."; +const SETUP_STATUS_REASON = + "Local-origin-gated setup/health existence probe. It returns status booleans and counts only, never owner rows, so a fresh deployment can diagnose missing setup before any corpus exists (tenancy review §3 / TEN-N1)."; + +/** + * Direct-tier (`owner_id`) and user-keyed (`user_id`) queries whose predicate is NOT on the + * query chain. Every entry names its proof; the mechanical kinds are re-checked in the AST. + */ +export const SCOPE_EXEMPTIONS = [ + { + file: "src/app/api/clinical-quality/route.ts", + table: "rag_answer_feedback", + fn: "loadClinicalQualitySnapshot", + queries: 2, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: CLINICAL_QUALITY_REASON, + }, + { + file: "src/app/api/clinical-quality/route.ts", + table: "clinical_registry_record_sources", + fn: "loadClinicalQualitySnapshot", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: CLINICAL_QUALITY_REASON, + }, + { + file: "src/app/api/clinical-quality/route.ts", + table: "clinical_registry_records", + fn: "loadClinicalQualitySnapshot", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: CLINICAL_QUALITY_REASON, + }, + { + file: "src/app/api/clinical-quality/route.ts", + table: "rag_retrieval_logs", + fn: "loadClinicalQualitySnapshot", + queries: 2, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: CLINICAL_QUALITY_REASON, + }, + { + file: "src/app/api/clinical-quality/route.ts", + table: "rag_answer_feedback", + fn: "verifyQualitySignal", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: CLINICAL_QUALITY_REASON, + }, + { + file: "src/app/api/clinical-quality/route.ts", + table: "rag_retrieval_logs", + fn: "verifyQualitySignal", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: CLINICAL_QUALITY_REASON, + }, + { + file: "src/app/api/documents/[id]/labels/route.ts", + table: "document_labels", + fn: "selectLabels", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "In-file read helper that never runs before requireOwnedDocument has resolved for the same document id; it consumes an owner-authorized capability id rather than entering from a request. Moving it to another module or route drops this entry and forces a fresh tenancy review.", + }, + { + file: "src/app/api/documents/[id]/route.ts", + table: "storage_cleanup_jobs", + fn: "updateStorageCleanupJob", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "Closes out a cleanup ledger row by the cleanupJobId the same DELETE handler created moments earlier for an owner-verified document; the id is never request-supplied. Moving it out of this file drops this entry.", + }, + { + file: "src/app/api/documents/[id]/table-facts/route.ts", + table: "document_table_facts", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: + "Facts are listed for the document id that withOwnerReadScope already resolved in this handler; a 404 is returned before this query when the caller does not own (or publicly share) that document.", + }, + { + file: "src/app/api/documents/route.ts", + table: "document_labels", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "ownedIds", + reason: + "Labels are batched over ownedIds, the subset of the withOwnerReadScope document page the caller actually owns (callerOwnsDocumentRow). No id reaches this query that the owner-scoped list query did not return.", + }, + { + file: "src/app/api/documents/route.ts", + table: "document_labels", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "publicDocumentIds", + reason: + "Labels are batched over publicDocumentIds — the null-owner rows of the same withOwnerReadScope page, which are the deliberately shared public corpus — and read through the redacted PUBLIC_LABEL_LIST_COLUMNS projection.", + }, + { + file: "src/app/api/documents/route.ts", + table: "document_summaries", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "ownedIds", + reason: + "Summaries are batched over ownedIds, the subset of the withOwnerReadScope document page the caller actually owns (callerOwnsDocumentRow).", + }, + { + file: "src/app/api/documents/route.ts", + table: "document_summaries", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "publicDocumentIds", + reason: + "Summaries are batched over publicDocumentIds — the null-owner rows of the same withOwnerReadScope page — through the redacted PUBLIC_SUMMARY_LIST_COLUMNS projection.", + }, + { + file: "src/app/api/ingestion/quality/route.ts", + table: "document_index_quality", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "documentIds", + reason: + 'Quality rows are read by `.in("document_id", documentIds)` where documentIds comes from the `.eq("owner_id", user.id)` documents query at the top of the same handler, and the handler returns early when that list is empty. This is the canonical per-function-attribution case the chain-level rule exposed: it is safe, but it was previously passing by accident rather than by review.', + }, + { + file: "src/app/api/setup-status/route.ts", + table: "documents", + fn: "readSchemaStatus", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: SETUP_STATUS_REASON, + }, + { + file: "src/app/api/setup-status/route.ts", + table: "import_batches", + fn: "readSchemaStatus", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: SETUP_STATUS_REASON, + }, + { + file: "src/app/api/setup-status/route.ts", + table: "storage_cleanup_jobs", + fn: "readSchemaStatus", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: SETUP_STATUS_REASON, + }, + { + file: "src/lib/document-detail.ts", + table: "document_table_facts", + fn: "loadAuthorizedDocumentDetail", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: + "Child tables are read for the document id withOwnerReadScope already resolved at the top of this loader; it throws a 404 PublicApiError before any child query when the row is not visible to the caller.", + }, + { + file: "src/lib/document-detail.ts", + table: "document_labels", + fn: "loadAuthorizedDocumentDetail", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: "Same owner-resolved document id as the loader's other child reads; 404 is thrown before any child query.", + }, + { + file: "src/lib/document-detail.ts", + table: "document_summaries", + fn: "loadAuthorizedDocumentDetail", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: "Same owner-resolved document id as the loader's other child reads; 404 is thrown before any child query.", + }, + { + file: "src/lib/observability/answer-slo.ts", + table: "rag_queries", + fn: "base", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "Deliberate cross-tenant operator aggregate: a head-only `count` of answered queries in the trailing window, reached only from /api/health's deep probe behind HEALTH_DEEP_PROBE_SECRET. It returns counts, never rows. Owner filtering would make the answer SLO blind to every tenant but the prober.", + }, + { + file: "src/lib/observability/answer-slo.ts", + table: "rag_queries", + fn: "answerSloSnapshot", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "Deliberate cross-tenant operator aggregate: reads only `metadata` for rows carrying a hybrid_rpc_errors map, to name which retrieval RPC degraded. Same HEALTH_DEEP_PROBE_SECRET gate; no query text, answer text, or owner identity leaves the probe.", + }, + { + file: "src/lib/observability/spend-metrics.ts", + table: "rag_retrieval_logs", + fn: "spendSnapshot", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "Deliberate cross-tenant operator aggregate: reads `query_class` and token counters from answer-path metadata to price the trailing window, behind the same HEALTH_DEEP_PROBE_SECRET gate. Per-owner spend is not the question being asked and no row content is returned.", + }, + { + file: "src/lib/sources/document-source-loader.ts", + table: "documents", + fn: "createDocumentSourceQuery", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "Factory that returns an UNEXECUTED PostgREST builder. Its only consumer, loadVisibleDocumentSourceReferences, wraps it in withOwnerReadScope(query, viewerId) before awaiting it, so no caller can execute the unscoped builder. The indirection is a dependency-injection seam for tests; if a second consumer ever executes the builder directly this entry must be revisited.", + }, +]; + +/** + * Every query against a join-through (derived-tier) table. These tables carry the document + * text and images and have no owner column at all, so nothing about them is self-evident: + * each site must be listed here with the proof that ownership was established. + */ +export const DERIVED_QUERY_INVENTORY = [ + { + file: "src/app/api/clinical-quality/route.ts", + table: "source_review_events", + fn: "loadClinicalQualitySnapshot", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: CLINICAL_QUALITY_REASON, + }, + { + file: "src/app/api/clinical-quality/route.ts", + table: "rag_visual_eval_runs", + fn: "loadClinicalQualitySnapshot", + queries: 2, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: CLINICAL_QUALITY_REASON, + }, + { + file: "src/app/api/clinical-quality/route.ts", + table: "document_chunks", + fn: "loadClinicalQualitySnapshot", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "Administrator-gated governance aggregate. Reads only `id,document_id` for chunk ids already named by feedback rows, to map a signal back to its document. No chunk content is selected.", + }, + { + file: "src/app/api/clinical-quality/route.ts", + table: "rag_visual_eval_runs", + fn: "verifyQualitySignal", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: CLINICAL_QUALITY_REASON, + }, + { + file: "src/app/api/documents/[id]/cover/route.ts", + table: "document_images", + fn: "GET", + queries: 2, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: + 'Both cover lookups filter `.eq("document_id", id)` for the id withOwnerReadScope resolved earlier in the handler; a 404 is returned before either query when that document is not visible to the caller.', + }, + { + file: "src/app/api/documents/[id]/reindex/route.ts", + table: "ingestion_jobs", + fn: "POST", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: + 'Competing-job diagnostic read for the document id the handler already loaded with `.eq("owner_id", user.id)`; a 404 is returned before this point when the caller does not own it.', + }, + { + file: "src/app/api/documents/[id]/search/route.ts", + table: "document_chunks", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: + "ILIKE fallback used only when the owner-filtering search_document_chunks RPC is unavailable. It reads chunks for the id withOwnerReadScope resolved above, and the handler has already returned early for a document that is not indexed.", + }, + { + file: "src/app/api/documents/[id]/table-facts/route.ts", + table: "document_images", + fn: "PATCH", + queries: 1, + proof: PROOF_KINDS.OWNED_DOCUMENT_HELPER, + identifier: "id", + reason: + 'Reads the fact\'s source image constrained to `.eq("document_id", id)` where loadOwnedDocument already proved that document belongs to the administrator making the request.', + }, + { + file: "src/app/api/documents/[id]/table-facts/route.ts", + table: "document_images", + fn: "PATCH", + queries: 1, + proof: PROOF_KINDS.OWNED_DOCUMENT_HELPER, + identifier: "id", + reason: + 'Writes review metadata back to `fact.source_image_id`, constrained by `.eq("document_id", id)` on the write chain itself, where `loadOwnedDocument` already proved that document belongs to the administrator making the request. Until 2026-09-02 this write filtered by image id alone and was declared `reviewed-indirect`; restating the document constraint turned the repo\'s narrowest derived-tier write into a mechanically checked proof.', + }, + { + file: "src/app/api/eval-cases/route.ts", + table: "document_chunks", + fn: "ownedChunkReference", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "Read-then-verify: selects only `id,document_id` for a caller-supplied chunk id, then calls ownedDocumentId on the returned document_id and returns null unless it belongs to the requester. No chunk content is selected and nothing is returned for a chunk the caller does not own.", + }, + { + file: "src/app/api/images/[id]/signed-url/route.ts", + table: "document_images", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.PARENT_DOCUMENT_VERIFIED, + reason: + "Images carry no owner column, so the row is fetched by id first and its parent document is then resolved through withOwnerReadScope; the route returns 404 and signs nothing unless that parent is visible to the caller.", + }, + { + file: "src/app/api/images/signed-urls/route.ts", + table: "document_images", + fn: "POST", + queries: 1, + proof: PROOF_KINDS.PARENT_DOCUMENT_VERIFIED, + reason: + "Batch form of the single-image route (and the module /api/documents/images/batch re-exports): images are fetched by id, their distinct document ids are resolved through withOwnerReadScope, and only images whose parent survived that filter are signed.", + }, + { + file: "src/app/api/ingestion/jobs/route.ts", + table: "ingestion_jobs", + fn: "GET", + queries: 2, + proof: PROOF_KINDS.DOCUMENTS_INNER_JOIN, + reason: + 'Both the page query and the active-count query join `documents!inner` and filter `.eq("documents.owner_id", user.id)`, so ownership rides on the query chain itself.', + }, + { + file: "src/app/api/ingestion/quality/route.ts", + table: "ingestion_jobs", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "documentIds", + reason: + 'Read by `.in("document_id", documentIds)`, the ids returned by the `.eq("owner_id", user.id)` documents query at the top of the handler; the handler returns early when that list is empty.', + }, + { + file: "src/app/api/ingestion/quality/route.ts", + table: "ingestion_job_stages", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "documentIds", + reason: "Same owner-scoped documentIds list as the handler's other child reads.", + }, + { + file: "src/app/api/ingestion/quality/route.ts", + table: "document_pages", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "documentIds", + reason: + "Same owner-scoped documentIds list. This one reads page `text`, so it is the highest-value derived read in the handler and the reason it is inventoried rather than assumed.", + }, + { + file: "src/app/api/ingestion/quality/route.ts", + table: "document_images", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "documentIds", + reason: "Same owner-scoped documentIds list; reads image counters and metadata for the quality review.", + }, + { + file: "src/app/api/jobs/route.ts", + table: "ingestion_jobs", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.DOCUMENTS_INNER_JOIN, + reason: 'Joins `documents!inner` and filters `.eq("documents.owner_id", user.id)` on the query chain itself.', + }, + { + file: "src/app/api/search/interaction/route.ts", + table: "document_chunks", + fn: "ownedChunkExists", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "Existence probe selecting only `id`, constrained to the document id the caller passed. The POST handler calls ownedDocumentExists first and only calls this helper when that returned true, so the document constraint is already an ownership constraint. The link crosses a function boundary and is therefore declared rather than checked.", + }, + { + file: "src/app/api/setup-status/route.ts", + table: "ingestion_jobs", + fn: "readSchemaStatus", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: SETUP_STATUS_REASON, + }, + { + file: "src/app/api/setup-status/route.ts", + table: "ingestion_jobs", + fn: "readWorkerStatus", + queries: 2, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: + "Local-origin-gated worker-liveness probe: the newest job's status/updated_at and a head-only count of pending or processing jobs. It returns a worker health verdict, never job rows or document identity (tenancy review §3 / TEN-N1).", + }, + { + file: "src/lib/document-detail.ts", + table: "document_chunks", + fn: "loadAuthorizedDocumentDetail", + queries: 2, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: + 'The selected-chunk lookup and the chunk window both filter `.eq("document_id", id)` for the id withOwnerReadScope resolved at the top of the loader, which throws a 404 before any child query runs. This is the read that serves the document viewer\'s text, so it is the single highest-value derived query in the codebase.', + }, + { + file: "src/lib/document-detail.ts", + table: "document_pages", + fn: "loadAuthorizedDocumentDetail", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: "Page-window read for the same owner-resolved document id; 404 is thrown before any child query.", + }, + { + file: "src/lib/document-detail.ts", + table: "document_images", + fn: "loadAuthorizedDocumentDetail", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: "Image read for the same owner-resolved document id; 404 is thrown before any child query.", + }, +]; + +/** + * Tables the scanned files query that carry NONE of the three tenancy columns, and so land + * in no tier. Before 2026-09-02 such a table got zero coverage with zero signal: it was + * simply skipped. `clinical_quality_feedback_triage` is exactly that case — its tenancy + * columns are `owner_role`/`owner_user_id`, a fourth naming convention — and it was read by + * a route with no entry in any list (security review P2-5). Any new one fails the scan + * until it is declared here with a reason. + */ +export const UNTIERED_TABLE_DECLARATIONS = [ + { + file: "src/app/api/clinical-quality/route.ts", + table: "clinical_quality_feedback_triage", + fn: "loadClinicalQualitySnapshot", + queries: 1, + proof: PROOF_KINDS.UNTIERED_TABLE, + reason: + "Tenancy columns are named `owner_role`/`owner_user_id`, so no tier claims this table. Administrator-gated cross-tenant governance triage queue: GET and PATCH call authorizeAndLimit before this helper runs, the projection is triage disposition metadata (signal type/id, status, resolution code, reviewer id and timestamps) and never question, answer, excerpt or patient text, and per-owner filtering would defeat the oversight purpose (tenancy review §6).", + }, +]; + +/** + * Every `.from(identifier)` in the scanned set whose table cannot be read statically. The + * list is exhaustive and ratcheting: a new dynamic dispatch fails the scan until someone + * writes down where its owner filter lives. `delegate` must be a module the scan actually + * reads (SCANNED_LIB_MODULES), so the declaration points at coverage rather than replacing + * it — a delegate outside the scanned set would be exactly the blind spot this closes. + */ +export const DYNAMIC_FROM_DECLARATIONS = [ + { + file: "src/app/api/upload/route.ts", + table: DYNAMIC_TABLE, + fn: "POST", + queries: 1, + proof: PROOF_KINDS.DYNAMIC_TABLE_DISPATCH, + delegate: "src/lib/document-naming.ts", + reason: + 'A one-method adapter — `{ from: (table) => adminSupabase.from(table) }` — narrowing the admin client to the shape planDocumentName accepts. The route passes no table name of its own; the only table the delegate reaches is `documents`, and its query filters `.eq("owner_id", args.ownerId)` on its own chain from the id the route resolved through requireOwnerScope. That delegate is in SCANNED_LIB_MODULES, so the filter is checked mechanically as a direct-tier site rather than trusted from this prose.', + }, +]; + +/* -------------------------------------------------------- proof verification */ + +/** Does `site` satisfy the mechanical proof `entry` declares? Returns null, or the reason it does not. */ +export function proofFailure(entry, site) { + const filterArgument = site.documentFilter?.argument ?? null; + switch (entry.proof) { + case PROOF_KINDS.DOCUMENTS_INNER_JOIN: + if (!site.innerJoinsDocuments) return "the chain does not select `documents!inner`"; + if (!site.proofs.includes("eq:documents.owner_id")) return "the chain does not filter `documents.owner_id`"; + return null; + case PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID: + if (filterArgument !== entry.identifier) + return `document_id is filtered by ${filterArgument ?? "no identifier"}, not the declared ${entry.identifier}`; + if (!site.facts.ownerScopedDocumentIds.has(entry.identifier)) + return `no owner-scoped documents query in this scope pins \`${entry.identifier}\` as its id`; + return null; + case PROOF_KINDS.OWNED_DOCUMENT_HELPER: + if (filterArgument !== entry.identifier) + return `document_id is filtered by ${filterArgument ?? "no identifier"}, not the declared ${entry.identifier}`; + if (!site.facts.owningHelperArguments.has(entry.identifier)) + return `no owning-document helper in this scope was handed \`${entry.identifier}\``; + return null; + case PROOF_KINDS.OWNER_SCOPED_ID_LIST: + if (filterArgument !== entry.identifier) + return `document_id is filtered by ${filterArgument ?? "no identifier"}, not the declared ${entry.identifier}`; + if (!site.facts.localDeclarations.has(entry.identifier)) + return `\`${entry.identifier}\` is not declared in this scope, so it may not be derived from the owner-scoped query`; + if (!site.facts.hasOwnerScopedDocumentsQuery) + return "this scope runs no owner-scoped documents query for the id list to come from"; + return null; + case PROOF_KINDS.PARENT_DOCUMENT_VERIFIED: + if (!site.facts.hasOwnerScopedDocumentsQuery) + return "this scope runs no owner-scoped documents query, so the parent document is never verified"; + return null; + case PROOF_KINDS.REVIEWED_INDIRECT: + return null; + case PROOF_KINDS.DYNAMIC_TABLE_DISPATCH: + // Nothing on the chain can be verified — the table is not knowable here. What IS + // verified is that the named delegate is inside the scanned set, so the filter this + // entry points at is itself under the gate rather than taken on trust. + if (!SCANNED_LIB_MODULES.includes(entry.delegate)) + return `delegate ${entry.delegate ?? "(none declared)"} is not in SCANNED_LIB_MODULES, so its owner filter is not scanned by anything`; + return null; + case PROOF_KINDS.UNTIERED_TABLE: + if (site.tier !== "untiered") return `\`${site.table}\` is in the ${site.tier} tier, not outside every tier`; + return null; + default: + return `unknown proof kind \`${entry.proof}\``; + } +} + +function entryKey(entry) { + return `${entry.file}|${entry.table}|${entry.fn}`; +} + +function describe(site) { + return `${site.file}:${site.line} ${site.table} (${site.tier}, in ${site.scope})`; +} + +/** + * Match declared entries against the sites actually found, and report every mismatch: + * an undeclared query, a declared entry that matches nothing, a wrong query count, or a + * mechanical proof that no longer holds. + */ +function reconcile(entries, sites, label, remedy = "Scope it on the chain, or add") { + const violations = []; + const grouped = new Map(); + for (const site of sites) { + const key = `${site.file}|${site.table}|${site.scope}`; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(site); + } + const declared = new Map(); + for (const entry of entries) { + const key = entryKey(entry); + if (!declared.has(key)) declared.set(key, []); + declared.get(key).push(entry); + } + + for (const [key, group] of declared) { + // Mechanical proofs are matched first so `reviewed-indirect` cannot absorb a site that + // a stricter entry in the same group was written for. + const ordered = [...group].sort( + (left, right) => + Number(left.proof === PROOF_KINDS.REVIEWED_INDIRECT) - Number(right.proof === PROOF_KINDS.REVIEWED_INDIRECT), + ); + let remaining = grouped.get(key) ?? []; + for (const entry of ordered) { + const matched = []; + const unmatched = []; + for (const site of remaining) { + if (matched.length < entry.queries && !proofFailure(entry, site)) matched.push(site); + else unmatched.push(site); + } + if (matched.length !== entry.queries) { + const why = remaining.map((site) => `${describe(site)} — ${proofFailure(entry, site) ?? "already matched"}`); + violations.push( + `${label} entry ${key} (proof ${entry.proof}) declares ${entry.queries} query(ies) but matched ${matched.length}.` + + (why.length + ? `\n candidates: ${why.join("; ")}` + : " No query site matched it at all — remove the stale entry."), + ); + } + remaining = unmatched; + } + grouped.set(key, remaining); + } + + for (const [key, remaining] of grouped) { + for (const site of remaining) { + violations.push( + `${describe(site)} — undeclared ${label} query. ${remedy} a reviewed ${label} entry (key ${key}) naming its ownership proof in scripts/lib/tenancy-scan.mjs.`, + ); + } + } + return violations; +} + +/** + * Full mechanical tenancy scan. Returns the sites it inspected plus every violation. + * `repoRoot` defaults to the current working directory (npm scripts run at the repo root). + */ +export function scanTenancy(repoRoot = process.cwd()) { + const tiers = tableTiersFromDatabaseTypes(readFile(join(repoRoot, "src", "lib", "supabase", "database.types.ts"))); + const sites = []; + for (const file of scannedFiles(repoRoot)) { + const relativePath = relativeToRepo(repoRoot, file); + sites.push(...analyzeSource({ relativePath, source: readFile(file), tiers })); + } + + return { tiers, sites, ...evaluateSites({ sites }) }; +} + +/** + * Apply the declared exemptions and derived inventory to a set of scanned sites. Exported + * so the guard test can drive it with synthetic fixtures and prove each rule actually + * fails on the thing it claims to catch. + */ +export function evaluateSites({ + sites, + exemptions = SCOPE_EXEMPTIONS, + inventory = DERIVED_QUERY_INVENTORY, + untiered = UNTIERED_TABLE_DECLARATIONS, + dynamicFrom = DYNAMIC_FROM_DECLARATIONS, +}) { + const unscopedDirect = sites.filter((site) => site.tier === "direct" && !site.ownerScopedChain); + const unscopedUserKeyed = sites.filter((site) => site.tier === "user-keyed" && !site.userScopedChain); + const derivedSites = sites.filter((site) => site.tier === "derived"); + const untieredSites = sites.filter((site) => site.tier === "untiered"); + const dynamicFromSites = sites.filter((site) => site.tier === "dynamic-from"); + + const violations = [ + ...reconcile(exemptions, [...unscopedDirect, ...unscopedUserKeyed], "scope-exemption"), + ...reconcile(inventory, derivedSites, "derived-inventory"), + ...reconcile( + untiered, + untieredSites, + "untiered-table", + "This table carries no owner_id, user_id or document_id column, so no tier covers it. Give it a tenancy column, or add", + ), + ...reconcile( + dynamicFrom, + dynamicFromSites, + "dynamic-from", + "The table name is not a string literal, so no tier claims this query and no chain predicate can be read. Name the table literally, or add", + ), + ]; + + return { + violations, + counts: { + direct: sites.filter((site) => site.tier === "direct").length, + userKeyed: sites.filter((site) => site.tier === "user-keyed").length, + derived: derivedSites.length, + untiered: untieredSites.length, + dynamicFrom: dynamicFromSites.length, + unscopedDirect: unscopedDirect.length, + unscopedUserKeyed: unscopedUserKeyed.length, + }, + }; +} + +/** + * The tiers that found NOTHING. A scan that finds nothing is a broken scan, never a clean + * repo: the tier derivation parses src/lib/supabase/database.types.ts with patterns anchored + * to that generated file's exact indentation, so a reformat empties every tier and the whole + * gate silently becomes a no-op that exits 0 (security review P2-5). Both the shipped command + * and the guard test assert this is empty. + */ +export function emptyTierNames(counts) { + return ["direct", "userKeyed", "derived"].filter((tier) => (counts?.[tier] ?? 0) === 0); +} + +/** + * Which tables each tier actually sees in the scanned file set. Used to assert the + * configured tier sets exactly equal what the code queries, so a new table cannot enter + * the codebase unclassified. + */ +export function queriedTablesByTier(repoRoot = process.cwd()) { + const { sites, tiers } = scanTenancy(repoRoot); + const bucket = (tier) => [...new Set(sites.filter((site) => site.tier === tier).map((site) => site.table))].sort(); + return { + tiers, + direct: bucket("direct"), + userKeyed: bucket("user-keyed"), + derived: bucket("derived"), + untiered: bucket("untiered"), + }; +} + +/** Scan all of `src/` for dynamic `.rpc()` dispatch (blind spot E). */ +export function scanRpcDispatch(repoRoot = process.cwd()) { + const dynamicRpcCalls = []; + const dispatcherCallSites = []; + for (const file of allSourceFiles(repoRoot)) { + const relativePath = relativeToRepo(repoRoot, file); + const result = analyzeRpcDispatch({ relativePath, source: readFile(file) }); + dynamicRpcCalls.push(...result.dynamicRpcCalls); + dispatcherCallSites.push(...result.dispatcherCallSites); + } + const violations = []; + for (const call of dynamicRpcCalls) { + if (call.file === DYNAMIC_RPC_DISPATCHER.file && call.scopes.includes(DYNAMIC_RPC_DISPATCHER.fn)) continue; + violations.push( + `${call.file}:${call.line} — dynamic .rpc(${call.argument}) outside ${DYNAMIC_RPC_DISPATCHER.fn}. ` + + "Tenancy for the retrieval layer lives in that one wrapper (it rewrites owner_filter to the public sentinel on the legacy merge path); a second dispatch site moves it somewhere unreviewed.", + ); + } + for (const site of dispatcherCallSites) { + if (!site.literalNames) { + violations.push( + `${site.file}:${site.line} — ${DYNAMIC_RPC_DISPATCHER.fn} called with non-literal RPC names (${site.names.join(", ")}). ` + + "Both RPC-name arguments must be string literals so the retrieval RPC surface stays enumerable.", + ); + } + } + return { dynamicRpcCalls, dispatcherCallSites, violations }; +} diff --git a/src/app/api/documents/[id]/table-facts/route.ts b/src/app/api/documents/[id]/table-facts/route.ts index dd1255013b..e8fc0babe9 100644 --- a/src/app/api/documents/[id]/table-facts/route.ts +++ b/src/app/api/documents/[id]/table-facts/route.ts @@ -179,7 +179,11 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id metadata: { ...metadataRecord(sourceImage.metadata), ...reviewMetadata }, searchable: parsed.reviewClass === "clinical_useful" || parsed.reviewClass === "reference", }) - .eq("id", fact.source_image_id); + .eq("id", fact.source_image_id) + // `document_images` has no owner column, so restate the document constraint on the + // write chain itself rather than relying on the preceding read having confirmed it. + // `id` is the document `loadOwnedDocument` proved belongs to this administrator. + .eq("document_id", id); if (imageUpdateError) throw new Error(imageUpdateError.message); } diff --git a/src/lib/health-response.ts b/src/lib/health-response.ts index d8a1000507..9327e2662b 100644 --- a/src/lib/health-response.ts +++ b/src/lib/health-response.ts @@ -60,7 +60,13 @@ export async function healthResponse(request: Request, options: HealthResponseOp const admin = createAdminClient(); const health = await probeSupabaseHealth(admin); checks.supabase = health.ok ? "ok" : "error"; - if (health.ok && options.includeSlo !== false) { + // `tokenAuthorized &&` matches `spendSnapshot` below and makes the gate real: the + // SLO aggregate is a deliberate CROSS-TENANT read (see its entry in + // scripts/lib/tenancy-scan.mjs, which states this exact gate). Without it the + // snapshot also ran for any caller passing `allowUnauthenticatedDeep`, so the only + // thing holding the claim true was `/api/health/ready` opting out via + // `includeSlo: false` — one flag at one caller, not a gate. + if (health.ok && tokenAuthorized && options.includeSlo !== false) { try { // Avoid recursively instantiating the full generated PostgREST // client type against the intentionally tiny SLO query surface. diff --git a/tests/health-response-deep-probe.test.ts b/tests/health-response-deep-probe.test.ts index c7bf76639d..88afc0f04c 100644 --- a/tests/health-response-deep-probe.test.ts +++ b/tests/health-response-deep-probe.test.ts @@ -121,6 +121,39 @@ describe("authorized deep health probe diagnostics", () => { expect(response.headers.get("Cache-Control")).toBe("no-store"); }); + it("omits slo for an unauthenticated deep probe even when includeSlo is not passed", async () => { + // `answer-slo`'s tenancy exemption (scripts/lib/tenancy-scan.mjs) states that this + // deliberate cross-tenant aggregate is reached "only from /api/health's deep probe behind + // HEALTH_DEEP_PROBE_SECRET". Until 2026-09-02 the SLO branch was gated on + // `health.ok && options.includeSlo !== false` with no `tokenAuthorized`, so it also ran for + // any caller passing `allowUnauthenticatedDeep`. The claim held only because the sole such + // caller (/api/health/ready) opts out with `includeSlo: false` — one flag at one caller, + // not a gate. This pins the gate itself, with the opt-out deliberately omitted. + mockEnv(); + mockSupabase(true); + const answerSloSnapshot = vi.fn(async () => ({ windowMinutes: 60, answers: 12 })); + const spendSnapshot = vi.fn(async () => ({ totalUsd: 1 })); + vi.doMock("@/lib/observability/answer-slo", () => ({ answerSloSnapshot })); + vi.doMock("@/lib/observability/spend-metrics", () => ({ spendSnapshot })); + const { healthResponse } = await import("../src/lib/health-response"); + + const response = await healthResponse(new Request("http://localhost/api/health/ready"), { + forceDeep: true, + allowUnauthenticatedDeep: true, + }); + const body = (await response.json()) as Record; + + expect(response.status).toBe(200); + expect(body.checks).toMatchObject({ supabase: "ok" }); + expect(body.slo, "an unauthenticated deep probe must not receive the cross-tenant SLO aggregate").toBeUndefined(); + expect(answerSloSnapshot).not.toHaveBeenCalled(); + // The other operator-gated snapshots were already token-gated; assert they stay that way. + expect(body.spend).toBeUndefined(); + expect(spendSnapshot).not.toHaveBeenCalled(); + expect(body.cache).toBeUndefined(); + expect(body.coalescing).toBeUndefined(); + }); + it("suppresses opted-out snapshots for an authorized caller", async () => { mockEnv(); mockSupabase(true); diff --git a/tests/retrieval-owner-filter-guard.test.ts b/tests/retrieval-owner-filter-guard.test.ts index 6254218f52..efd38791b8 100644 --- a/tests/retrieval-owner-filter-guard.test.ts +++ b/tests/retrieval-owner-filter-guard.test.ts @@ -4,6 +4,24 @@ import { join } from "node:path"; import ts from "@typescript/typescript6"; import { describe, expect, it } from "vitest"; +import { + DERIVED_QUERY_INVENTORY, + DYNAMIC_FROM_DECLARATIONS, + DYNAMIC_TABLE, + PROOF_KINDS, + SCANNED_LIB_MODULES, + SCOPE_EXEMPTIONS, + UNTIERED_TABLE_DECLARATIONS, + analyzeRpcDispatch, + analyzeSource, + emptyTierNames, + evaluateSites, + queriedTablesByTier, + scanRpcDispatch, + scanTenancy, + tableTiersFromDatabaseTypes, +} from "../scripts/lib/tenancy-scan.mjs"; + // Guard for the retrieval owner-scope boundary (48h-review finding #3). // // The SQL `retrieval_owner_matches(owner_filter, row_owner_id)` now fails CLOSED when @@ -289,3 +307,1190 @@ describe("owner-scoped API table guard", () => { ).toEqual([]); }); }); + +/* + * --------------------------------------------------------------------------------------- + * Mechanical tenancy scan (the five blind spots closed 2026-09-02). + * + * The suites above check the OLD contract: owner_filter RPC sources, and owner_id-bearing + * tables in `route.ts` files scoped somewhere in the enclosing function. Those assertions + * stay exactly as they were. The suites below add the stricter contract that + * scripts/lib/tenancy-scan.mjs implements — three table tiers, scope attributed per query + * CHAIN rather than per function, a declared inventory for join-through tables, a wider + * file set, and a pin on the one dynamic RPC dispatcher — and every rule here ships with a + * synthetic fixture proving it FAILS on the thing it claims to catch. + * --------------------------------------------------------------------------------------- + */ + +type ScanTiers = ReturnType; + +/** Scan a synthetic module as if it lived at `relativePath`, using the given tier sets. */ +function scanFixture(relativePath: string, source: string, tiers: Partial = {}) { + const direct = tiers.direct ?? new Set(["documents"]); + const userKeyed = tiers.userKeyed ?? new Set(["user_favourites"]); + const derived = tiers.derived ?? new Set(["document_chunks"]); + return analyzeSource({ + relativePath, + source, + // `all` is every table the generated types declare, tiered or not; a table in `all` and + // in no tier is the untiered case the fourth signal exists for. + tiers: { direct, userKeyed, derived, all: tiers.all ?? new Set([...direct, ...userKeyed, ...derived]) }, + }); +} + +const FIXTURE_FILE = "src/app/api/synthetic/route.ts"; + +// Sanctioned names are resolved to an import or to a file-local definition that itself +// carries an owner predicate — never matched on identifier text — so a fixture that means +// to USE one has to actually bring it into scope (security review P2-4). +const IMPORT_WRAPPER = 'import { withOwnerReadScope } from "@/lib/public-api-access";\n'; +const LOCAL_OWNED_DOCUMENT_HELPER = `async function loadOwnedDocument(args) { + return args.supabase + .from("documents") + .select("id") + .eq("id", args.documentId) + .eq("owner_id", args.ownerId) + .maybeSingle(); + } + `; + +describe("tenancy table tiers", () => { + it("splits generated table types into direct, user-keyed and derived tiers", () => { + const generated = [ + "export type Database = {", + " public: {", + " Tables: {", + " documents: {", + " Row: {", + " id: string;", + " owner_id: string;", + " };", + " Insert: {", + " id?: string;", + " };", + " };", + " user_favourites: {", + " Row: {", + " content_key: string;", + " user_id: string;", + " };", + " Insert: {", + " content_key: string;", + " };", + " };", + " document_chunks: {", + " Row: {", + " id: string;", + " document_id: string;", + " };", + " Insert: {", + " id?: string;", + " };", + " };", + " api_versions: {", + " Row: {", + " id: string;", + " };", + " Insert: {", + " id?: string;", + " };", + " };", + " };", + " Functions: {", + " match_document_chunks: {", + " Row: {", + " owner_id: string;", + " };", + " };", + " };", + " };", + "};", + ].join("\n"); + + const tiers = tableTiersFromDatabaseTypes(generated); + expect([...tiers.direct]).toEqual(["documents"]); + expect([...tiers.userKeyed]).toEqual(["user_favourites"]); + expect([...tiers.derived]).toEqual(["document_chunks"]); + // A Functions entry that happens to name owner_id must not be mistaken for a table. + expect(tiers.direct.has("match_document_chunks")).toBe(false); + // A table with none of the three columns belongs to no tenancy tier. + expect( + tiers.direct.has("api_versions") || tiers.userKeyed.has("api_versions") || tiers.derived.has("api_versions"), + ).toBe(false); + }); + + it("keeps each configured tier exactly equal to the tables the scanned files query", () => { + const { tiers, direct, userKeyed, derived } = queriedTablesByTier(process.cwd()); + // The tier sets are DERIVED from database.types.ts, so the assertion that matters is the + // other direction: every table the scanned files actually query must land in a tier, and + // the tier it lands in must match the generated column shape. An unclassified table + // queried by a scanned file would be absent from all three buckets below. + for (const table of direct) expect(tiers.direct.has(table), `${table} is not a direct-tier table`).toBe(true); + for (const table of userKeyed) expect(tiers.userKeyed.has(table), `${table} is not a user-keyed table`).toBe(true); + for (const table of derived) expect(tiers.derived.has(table), `${table} is not a derived-tier table`).toBe(true); + expect(direct.length + userKeyed.length + derived.length).toBeGreaterThan(0); + + // The user-keyed tier is small and fully enumerated: withOwnerReadScope cannot be used + // on it (it filters owner_id), so every call site hand-rolls .eq("user_id", …). + expect([...tiers.userKeyed].sort()).toEqual(["user_favourite_sets", "user_favourites", "user_preferences"]); + expect(userKeyed).toEqual(["user_favourite_sets", "user_favourites", "user_preferences"]); + }); +}); + +describe("per-chain owner scope (direct and user-keyed tiers)", () => { + it("accepts an owner predicate on the query's own chain and a sanctioned wrapper", () => { + const onChain = scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("documents").select("id").eq("owner_id", user.id); + }`, + ); + const wrapped = scanFixture( + FIXTURE_FILE, + IMPORT_WRAPPER + + `export async function GET() { + return withOwnerReadScope(supabase.from("documents").select("id"), access.ownerId); + }`, + ); + const stamped = scanFixture( + FIXTURE_FILE, + `export async function POST() { + return supabase.from("documents").insert({ owner_id: user.id, title }); + }`, + ); + const publicOverlay = scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("documents").select("id").or("owner_id.eq." + id + ",owner_id.is.null"); + }`, + ); + + for (const sites of [onChain, wrapped, stamped]) { + expect(sites).toHaveLength(1); + expect(sites[0].ownerScopedChain, JSON.stringify(sites[0].proofs)).toBe(true); + } + // `.or("owner_id.eq…")` is only recognised from a string literal; a concatenation is not. + expect(publicOverlay[0].ownerScopedChain).toBe(false); + expect( + scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("documents").select("id").or("owner_id.eq.abc,owner_id.is.null"); + }`, + )[0].ownerScopedChain, + ).toBe(true); + }); + + it("attributes scope per QUERY, not per function — a scoped sibling query no longer covers an unscoped one", () => { + // This is blind spot C. Both guards that existed before asked whether a sanctioned token + // appeared anywhere in the enclosing function, so this handler passed. + const sites = scanFixture( + FIXTURE_FILE, + `export async function GET() { + const owned = await supabase.from("documents").select("id").eq("owner_id", user.id); + const everything = await supabase.from("documents").select("*"); + return { owned, everything }; + }`, + ); + expect(sites).toHaveLength(2); + expect(sites.filter((site) => site.ownerScopedChain)).toHaveLength(1); + + const { violations } = evaluateSites({ dynamicFrom: [], sites, exemptions: [], inventory: [], untiered: [] }); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain("undeclared scope-exemption query"); + }); + + it("requires a user_id predicate on the chain for user-keyed tables (blind spot B)", () => { + const scoped = scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("user_favourites").select("content_key").eq("user_id", user.id); + }`, + ); + const unscoped = scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("user_favourites").select("content_key").eq("content_type", type); + }`, + ); + expect(scoped[0].userScopedChain).toBe(true); + expect(unscoped[0].userScopedChain).toBe(false); + expect( + evaluateSites({ dynamicFrom: [], sites: unscoped, exemptions: [], inventory: [], untiered: [] }).violations, + ).toHaveLength(1); + // owner_id is NOT a substitute: these tables have no owner_id column at all. + const wrongColumn = scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("user_favourites").select("content_key").eq("owner_id", user.id); + }`, + ); + expect(wrongColumn[0].userScopedChain).toBe(false); + }); + + it("resolves an owner_id stamp through a locally built write payload", () => { + const sites = scanFixture( + FIXTURE_FILE, + `export async function POST() { + const labelRows = documents.map((document) => ({ owner_id: user.id, document_id: document.id })); + return supabase.from("documents").upsert(labelRows, { onConflict: "id" }); + }`, + ); + expect(sites[0].ownerScopedChain).toBe(true); + + const unstamped = scanFixture( + FIXTURE_FILE, + `export async function POST() { + const labelRows = documents.map((document) => ({ document_id: document.id })); + return supabase.from("documents").upsert(labelRows, { onConflict: "id" }); + }`, + ); + expect(unstamped[0].ownerScopedChain).toBe(false); + }); +}); + +describe("derived-tier inventory (blind spot A)", () => { + const derivedSource = + IMPORT_WRAPPER + + `export async function GET() { + const { data: document } = await withOwnerReadScope( + supabase.from("documents").select("id").eq("id", id), + access.ownerId, + ).maybeSingle(); + if (!document) return notFound(); + return supabase.from("document_chunks").select("content").eq("document_id", id); + }`; + + it("fails a join-through query that is not declared, even when the handler is owner-scoped", () => { + // Both older guards ignored document_chunks entirely: it has no owner_id column. + // check-owner-scope-api.mjs's own self-test still asserts the regex tier does not flag it. + const sites = scanFixture(FIXTURE_FILE, derivedSource); + const derived = sites.filter((site) => site.tier === "derived"); + expect(derived).toHaveLength(1); + + const { violations } = evaluateSites({ dynamicFrom: [], sites, exemptions: [], inventory: [], untiered: [] }); + expect(violations.filter((violation) => violation.includes("undeclared derived-inventory query"))).toHaveLength(1); + }); + + it("accepts the same query once it is declared with a verifiable owner-pinned document id", () => { + const sites = scanFixture(FIXTURE_FILE, derivedSource); + const inventory = [ + { + file: FIXTURE_FILE, + table: "document_chunks", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: "fixture", + }, + ]; + expect(evaluateSites({ dynamicFrom: [], sites, exemptions: [], inventory, untiered: [] }).violations).toEqual([]); + + // The identity is checked in the AST, not trusted from the reason string: declaring a + // different identifier than the one the owner-scoped query pinned must fail. + const wrongIdentifier = evaluateSites({ + dynamicFrom: [], + sites, + exemptions: [], + inventory: [{ ...inventory[0], identifier: "otherId" }], + untiered: [], + }).violations; + // Two violations: the entry matches nothing, and the query is therefore undeclared. + expect(wrongIdentifier).toHaveLength(2); + expect(wrongIdentifier.join("\n")).toContain("not the declared otherId"); + expect(wrongIdentifier.join("\n")).toContain("undeclared derived-inventory query"); + }); + + it("fails owner-pinned-document-id when the ownership proof is dropped from the scope", () => { + const sites = scanFixture( + FIXTURE_FILE, + `export async function GET() { + const { data: document } = await supabase.from("documents").select("id").eq("id", id).maybeSingle(); + if (!document) return notFound(); + return supabase.from("document_chunks").select("content").eq("document_id", id); + }`, + ); + const violations = evaluateSites({ + dynamicFrom: [], + sites, + exemptions: [], + inventory: [ + { + file: FIXTURE_FILE, + table: "document_chunks", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_PINNED_DOCUMENT_ID, + identifier: "id", + reason: "fixture", + }, + ], + }).violations; + // The documents query lost its owner filter, so nothing pins `id` any more. + expect(violations.join("\n")).toContain("no owner-scoped documents query in this scope pins `id`"); + }); + + it("verifies the documents!inner proof and fails when the owner filter is dropped", () => { + const joined = scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase + .from("document_chunks") + .select("*, documents!inner(owner_id)") + .eq("documents.owner_id", user.id); + }`, + ); + const entry = { + file: FIXTURE_FILE, + table: "document_chunks", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.DOCUMENTS_INNER_JOIN, + reason: "fixture", + }; + expect( + evaluateSites({ dynamicFrom: [], sites: joined, exemptions: [], inventory: [entry], untiered: [] }).violations, + ).toEqual([]); + + const unfiltered = scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("document_chunks").select("*, documents!inner(owner_id)").eq("status", "indexed"); + }`, + ); + const violations = evaluateSites({ + dynamicFrom: [], + sites: unfiltered, + exemptions: [], + inventory: [entry], + untiered: [], + }).violations; + expect(violations.join("\n")).toContain("does not filter `documents.owner_id`"); + }); + + it("verifies the owned-document-helper proof and fails when the helper call is removed", () => { + const entry = { + file: FIXTURE_FILE, + table: "document_chunks", + fn: "PATCH", + queries: 1, + proof: PROOF_KINDS.OWNED_DOCUMENT_HELPER, + identifier: "id", + reason: "fixture", + }; + const withHelper = scanFixture( + FIXTURE_FILE, + LOCAL_OWNED_DOCUMENT_HELPER + + `export async function PATCH() { + const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id }); + if (!document) return notFound(); + return supabase.from("document_chunks").select("id").eq("document_id", id); + }`, + ); + expect( + evaluateSites({ dynamicFrom: [], sites: withHelper, exemptions: [], inventory: [entry], untiered: [] }) + .violations, + ).toEqual([]); + + const withoutHelper = scanFixture( + FIXTURE_FILE, + `export async function PATCH() { + return supabase.from("document_chunks").select("id").eq("document_id", id); + }`, + ); + expect( + evaluateSites({ + dynamicFrom: [], + sites: withoutHelper, + exemptions: [], + inventory: [entry], + untiered: [], + }).violations.join("\n"), + ).toContain("no owning-document helper in this scope was handed `id`"); + }); + + it("verifies the owner-scoped-id-list proof and fails when the list is not locally derived", () => { + const entry = { + file: FIXTURE_FILE, + table: "document_chunks", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.OWNER_SCOPED_ID_LIST, + identifier: "documentIds", + reason: "fixture", + }; + const derivedList = scanFixture( + FIXTURE_FILE, + `export async function GET() { + const { data } = await supabase.from("documents").select("id").eq("owner_id", user.id); + const documentIds = data.map((row) => row.id); + return supabase.from("document_chunks").select("content").in("document_id", documentIds); + }`, + ); + expect( + evaluateSites({ dynamicFrom: [], sites: derivedList, exemptions: [], inventory: [entry], untiered: [] }) + .violations, + ).toEqual([]); + + const requestSuppliedList = scanFixture( + FIXTURE_FILE, + `export async function GET() { + const { data } = await supabase.from("documents").select("id").eq("owner_id", user.id); + return supabase.from("document_chunks").select("content").in("document_id", body.documentIds); + }`, + ); + expect( + evaluateSites({ + dynamicFrom: [], + sites: requestSuppliedList, + exemptions: [], + inventory: [entry], + untiered: [], + }).violations.join("\n"), + ).toContain("not the declared documentIds"); + }); + + it("verifies the parent-document-verified proof and fails when no owner-scoped parent read remains", () => { + const entry = { + file: FIXTURE_FILE, + table: "document_chunks", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.PARENT_DOCUMENT_VERIFIED, + reason: "fixture", + }; + const verified = scanFixture( + FIXTURE_FILE, + IMPORT_WRAPPER + + `export async function GET() { + const { data: chunk } = await supabase.from("document_chunks").select("document_id").eq("id", id).maybeSingle(); + const { data: document } = await withOwnerReadScope( + supabase.from("documents").select("id").eq("id", chunk.document_id), + access.ownerId, + ).maybeSingle(); + return document ? chunk : notFound(); + }`, + ); + expect( + evaluateSites({ dynamicFrom: [], sites: verified, exemptions: [], inventory: [entry], untiered: [] }).violations, + ).toEqual([]); + + const unverified = scanFixture( + FIXTURE_FILE, + `export async function GET() { + const { data: chunk } = await supabase.from("document_chunks").select("document_id").eq("id", id).maybeSingle(); + return chunk; + }`, + ); + expect( + evaluateSites({ + dynamicFrom: [], + sites: unverified, + exemptions: [], + inventory: [entry], + untiered: [], + }).violations.join("\n"), + ).toContain("the parent document is never verified"); + }); + + it("fails a stale entry that no longer matches any query site", () => { + const violations = evaluateSites({ + dynamicFrom: [], + sites: [], + exemptions: [], + inventory: [ + { + file: FIXTURE_FILE, + table: "document_chunks", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: "fixture", + }, + ], + }).violations; + expect(violations.join("\n")).toContain("No query site matched it at all"); + }); + + it("fails when a scope grows an extra derived query beyond its declared count", () => { + const sites = scanFixture( + FIXTURE_FILE, + `export async function GET() { + const a = supabase.from("document_chunks").select("content").eq("document_id", id); + const b = supabase.from("document_chunks").select("metadata").eq("document_id", id); + return [a, b]; + }`, + ); + const violations = evaluateSites({ + dynamicFrom: [], + sites, + exemptions: [], + inventory: [ + { + file: FIXTURE_FILE, + table: "document_chunks", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.REVIEWED_INDIRECT, + reason: "fixture", + }, + ], + }).violations; + expect(violations.join("\n")).toContain("undeclared derived-inventory query"); + }); +}); + +/* + * --------------------------------------------------------------------------------------- + * Security review of the scanner (2026-09-02). Every fixture below is a query the reviewer + * constructed that is GENUINELY CROSS-TENANT and that the scanner REPORTED AS SCOPED. None + * is load-bearing in the repo today, so these were latent guard defects — but a guard that + * passes when it should fail is precisely the failure this work exists to prevent. Each + * `it` asserts the scanner now rejects the shape it used to accept. + * --------------------------------------------------------------------------------------- + */ + +describe("P1-1 — an update payload is a written VALUE, not a filter", () => { + it("rejects an unfiltered mass reassignment of every tenant's rows to the caller", () => { + const massReassign = scanFixture( + FIXTURE_FILE, + `export async function PATCH() { + return supabase.from("documents").update({ owner_id: user.id, title }); + }`, + ); + expect(massReassign).toHaveLength(1); + // Previously `update:owner_id` was an OWNER_PROOF, so this reported ownerScoped with + // zero violations while rewriting every other tenant's documents to the caller. + expect(massReassign[0].ownerScopedChain, JSON.stringify(massReassign[0].proofs)).toBe(false); + expect( + evaluateSites({ dynamicFrom: [], sites: massReassign, exemptions: [], inventory: [], untiered: [] }).violations, + ).toHaveLength(1); + }); + + it("rejects an owner_id write filtered only by a request-supplied id", () => { + const byRequestId = scanFixture( + FIXTURE_FILE, + `export async function PATCH() { + return supabase.from("documents").update({ owner_id: user.id }).eq("id", body.id); + }`, + ); + expect(byRequestId[0].ownerScopedChain).toBe(false); + }); + + it("rejects the same shape on a user-keyed table", () => { + const userKeyed = scanFixture( + FIXTURE_FILE, + `export async function PATCH() { + return supabase.from("user_favourites").update({ user_id: user.id, content_key: key }); + }`, + ); + expect(userKeyed[0].userScopedChain).toBe(false); + }); + + it("still accepts an update whose OWN CHAIN carries the owner predicate", () => { + const filtered = scanFixture( + FIXTURE_FILE, + `export async function PATCH() { + return supabase.from("documents").update({ title }).eq("id", body.id).eq("owner_id", user.id); + }`, + ); + expect(filtered[0].ownerScopedChain).toBe(true); + }); + + it("does not count an owner_id key buried in a nested JSON column as a stamp", () => { + const nested = scanFixture( + FIXTURE_FILE, + `export async function POST() { + return supabase.from("documents").insert({ title, metadata: { owner_id: user.id } }); + }`, + ); + // subtreeStampsColumn used to recurse the whole argument, so a metadata key counted. + expect(nested[0].ownerScopedChain, JSON.stringify(nested[0].proofs)).toBe(false); + + const topLevel = scanFixture( + FIXTURE_FILE, + `export async function POST() { + return supabase.from("documents").insert({ owner_id: user.id, metadata: { source: "upload" } }); + }`, + ); + expect(topLevel[0].ownerScopedChain).toBe(true); + }); + + it("requires every element of an array payload to carry the stamp", () => { + const mixed = scanFixture( + FIXTURE_FILE, + `export async function POST() { + return supabase.from("documents").insert([{ owner_id: user.id }, { title }]); + }`, + ); + expect(mixed[0].ownerScopedChain).toBe(false); + }); +}); + +describe("P1-2 — `.or()` is a disjunction and cannot be an owner proof on its own", () => { + const orFixture = (filter: string) => + scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("documents").select("id").or(${JSON.stringify(filter)}); + }`, + )[0]; + + it("rejects a disjunct that does not constrain owner_id", () => { + // Both of these return other tenants' rows; both were accepted before 2026-09-02 + // because the literal merely CONTAINED `owner_id.is.` / `owner_id.eq.`. + expect(orFixture("owner_id.is.null,status.eq.indexed").ownerScopedChain).toBe(false); + expect(orFixture("owner_id.eq.11111111-1111-4111-8111-111111111111,id.eq.abc").ownerScopedChain).toBe(false); + expect(orFixture("status.eq.indexed,owner_id.eq.abc").ownerScopedChain).toBe(false); + }); + + it("accepts the nested shape withOwnerReadScope actually emits", () => { + // src/lib/public-api-access.ts:113-116 — the split must be parenthesis-aware, and the + // `and(...)` group is restricted as soon as one of ITS terms restricts owner_id. + expect( + orFixture( + "owner_id.eq.11111111-1111-4111-8111-111111111111,and(owner_id.is.null,metadata->>public_corpus.eq.true)", + ).ownerScopedChain, + ).toBe(true); + expect(orFixture("owner_id.eq.abc,owner_id.is.null").ownerScopedChain).toBe(true); + }); + + it("rejects an and() group that constrains no owner column, and a negated group", () => { + expect(orFixture("owner_id.eq.abc,and(status.eq.indexed,id.eq.x)").ownerScopedChain).toBe(false); + expect(orFixture("owner_id.eq.abc,not.and(owner_id.is.null,id.eq.x)").ownerScopedChain).toBe(false); + }); + + it("applies the same rule to user-keyed tables", () => { + const leaky = scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("user_favourites").select("content_key").or("user_id.eq.abc,content_type.eq.tool"); + }`, + ); + expect(leaky[0].userScopedChain).toBe(false); + }); +}); + +describe("P2-3 — write-payload identifiers resolve lexically, not file-wide by name", () => { + it("does not let an owner-stamped `rows` in one function vouch for `rows` in another", () => { + const sites = scanFixture( + FIXTURE_FILE, + `async function stampOwnedRows(supabase, user) { + const rows = documents.map((document) => ({ owner_id: user.id, document_id: document.id })); + return supabase.from("documents").insert(rows); + } + + export async function POST(request) { + const rows = body.rows; + return supabase.from("documents").insert(rows); + }`, + ); + expect(sites).toHaveLength(2); + const byScope = new Map(sites.map((site) => [site.scope, site])); + expect(byScope.get("stampOwnedRows")?.ownerScopedChain).toBe(true); + // Before the fix payloadStampsColumn walked the whole file for any variable of this + // name, so the helper's stamped `rows` vouched for the handler's `body.rows`. + expect(byScope.get("POST")?.ownerScopedChain, JSON.stringify(byScope.get("POST")?.proofs)).toBe(false); + }); + + it("still resolves a payload built in the same function, and a module-level const", () => { + const sameFunction = scanFixture( + FIXTURE_FILE, + `export async function POST() { + const rows = documents.map((document) => ({ owner_id: user.id })); + return supabase.from("documents").insert(rows); + }`, + ); + expect(sameFunction[0].ownerScopedChain).toBe(true); + + const moduleConst = scanFixture( + FIXTURE_FILE, + `const seedRow = { owner_id: SYSTEM_OWNER_ID, title: "seed" }; + export async function POST() { + return supabase.from("documents").insert(seedRow); + }`, + ); + expect(moduleConst[0].ownerScopedChain).toBe(true); + }); +}); + +describe("Codex-1 — a payload identifier binds where it is declared, not anywhere in the function", () => { + it("does not let a sibling block's const vouch for an untrusted payload", () => { + const sites = scanFixture( + FIXTURE_FILE, + `export async function POST(request) { + const body = await request.json(); + if (SHOULD_SEED) { + const rows = [{ owner_id: user.id }]; + void rows; + } + return supabase.from("documents").insert(body.rows); + }`, + ); + // The earlier fix bounded resolution to the enclosing function but still scanned its + // whole body, so this block's `rows` bound at a use site it never reaches. + expect(sites[0].ownerScopedChain, JSON.stringify(sites[0].proofs)).toBe(false); + }); + + it("does not let a `let` that is reassigned before the write count as a stamp", () => { + const sites = scanFixture( + FIXTURE_FILE, + `export async function POST(request) { + let rows = [{ owner_id: user.id }]; + rows = await request.json(); + return supabase.from("documents").insert(rows); + }`, + ); + expect(sites[0].ownerScopedChain, JSON.stringify(sites[0].proofs)).toBe(false); + }); + + it("does not fall through a shadowing parameter to an outer const of the same name", () => { + const sites = scanFixture( + FIXTURE_FILE, + `const rows = [{ owner_id: SYSTEM_OWNER_ID }]; + export async function POST(rows) { + return supabase.from("documents").insert(rows); + }`, + ); + expect(sites[0].ownerScopedChain, JSON.stringify(sites[0].proofs)).toBe(false); + }); + + it("still resolves the binding that really is in scope at the write", () => { + const sameBlock = scanFixture( + FIXTURE_FILE, + `export async function POST() { + if (ready) { + const rows = [{ owner_id: user.id }]; + return supabase.from("documents").insert(rows); + } + return null; + }`, + ); + expect(sameBlock[0].ownerScopedChain).toBe(true); + + const outerBlock = scanFixture( + FIXTURE_FILE, + `export async function POST() { + const rows = [{ owner_id: user.id }]; + if (ready) { + return supabase.from("documents").insert(rows); + } + return null; + }`, + ); + expect(outerBlock[0].ownerScopedChain).toBe(true); + }); +}); + +describe("Codex-2 — ownership facts stop at nested functions the query is not inside", () => { + it("does not let a never-invoked nested helper pin the document id", () => { + const sites = scanFixture( + FIXTURE_FILE, + `export async function GET(request) { + const never = async () => { + return supabase.from("documents").select("id").eq("id", id).eq("owner_id", ownerId).maybeSingle(); + }; + void never; + return supabase.from("document_chunks").select("content").eq("document_id", id); + }`, + ); + const chunks = sites.find((site) => site.table === "document_chunks"); + // Before the fix this collection recursed into every nested function, so an + // owner-scoped query that never runs satisfied OWNER_PINNED_DOCUMENT_ID for a read + // that had no scoping of its own. + expect(chunks?.facts.ownerScopedDocumentIds.has("id")).toBe(false); + expect(chunks?.facts.hasOwnerScopedDocumentsQuery).toBe(false); + }); + + it("keeps the facts a query can genuinely see, in its own scope and in its own callback", () => { + const sameScope = scanFixture( + FIXTURE_FILE, + `export async function GET(request) { + const owned = await supabase.from("documents").select("id").eq("id", id).eq("owner_id", ownerId).maybeSingle(); + if (!owned.data) return notFound(); + return supabase.from("document_chunks").select("content").eq("document_id", id); + }`, + ); + expect(sameScope.find((site) => site.table === "document_chunks")?.facts.ownerScopedDocumentIds.has("id")).toBe( + true, + ); + + const ownCallback = scanFixture( + FIXTURE_FILE, + `export async function GET(request) { + return Promise.all( + ids.map(async (id) => { + const owned = await supabase + .from("documents") + .select("id") + .eq("id", id) + .eq("owner_id", ownerId) + .maybeSingle(); + if (!owned.data) return null; + return supabase.from("document_chunks").select("content").eq("document_id", id); + }), + ); + }`, + ); + expect(ownCallback.find((site) => site.table === "document_chunks")?.facts.ownerScopedDocumentIds.has("id")).toBe( + true, + ); + }); +}); + +describe("Codex-3 — a table named through an identifier fails closed", () => { + const dynamicFixture = `export async function POST() { + const naming = { from: (table) => adminSupabase.from(table) }; + const buffer = Buffer.from(await file.arrayBuffer()); + const ids = Array.from(new Set(list)); + await adminSupabase.storage.from(env.SUPABASE_DOCUMENT_BUCKET).upload(path, buffer); + return planDocumentName({ supabase: naming, ownerId, ids }); + }`; + + it("reports the dynamic dispatch and ignores Buffer.from, Array.from and storage buckets", () => { + const sites = scanFixture(FIXTURE_FILE, dynamicFixture).filter((site) => site.tier === "dynamic-from"); + expect(sites).toHaveLength(1); + expect(sites[0].argument).toBe("table"); + }); + + it("fails an undeclared dynamic dispatch", () => { + const { violations } = evaluateSites({ + sites: scanFixture(FIXTURE_FILE, dynamicFixture), + exemptions: [], + inventory: [], + untiered: [], + dynamicFrom: [], + }); + expect(violations.join("\n")).toMatch(/undeclared dynamic-from query/); + }); + + it("refuses a declaration whose delegate is not itself scanned", () => { + const { violations } = evaluateSites({ + sites: scanFixture(FIXTURE_FILE, dynamicFixture), + exemptions: [], + inventory: [], + untiered: [], + dynamicFrom: [ + { + file: FIXTURE_FILE, + table: DYNAMIC_TABLE, + fn: "POST", + queries: 1, + proof: PROOF_KINDS.DYNAMIC_TABLE_DISPATCH, + delegate: "src/lib/not-scanned.ts", + reason: "a delegate nothing reads is exactly the blind spot this closes", + }, + ], + }); + // A declaration pointing at an unscanned module would move the blind spot rather than + // close it, so the entry has to name a module the scan actually reads. + expect(violations.join("\n")).toMatch(/is not in SCANNED_LIB_MODULES/); + }); + + it("sees the real dynamic dispatch the review found, declared against a scanned delegate", () => { + expect(DYNAMIC_FROM_DECLARATIONS).toHaveLength(1); + const [entry] = DYNAMIC_FROM_DECLARATIONS; + expect(entry.file).toBe("src/app/api/upload/route.ts"); + expect(SCANNED_LIB_MODULES).toContain(entry.delegate); + // The delegate is scanned as an ordinary direct-tier site, so its owner filter is + // proven mechanically rather than asserted in the declaration's prose. + const documentNaming = scanTenancy(process.cwd()).sites.filter( + (site) => site.file === entry.delegate && site.table === "documents", + ); + expect(documentNaming.length).toBeGreaterThan(0); + expect(documentNaming.every((site) => site.ownerScopedChain)).toBe(true); + }); +}); + +describe("P2-4 — sanctioned names are resolved, not matched as text", () => { + it("rejects a file-local no-op named withOwnerReadScope", () => { + const shadowed = scanFixture( + FIXTURE_FILE, + `function withOwnerReadScope(query) { + return query; + } + + export async function GET() { + return withOwnerReadScope(supabase.from("documents").select("id"), access.ownerId); + }`, + ); + expect(shadowed[0].ownerScopedChain, JSON.stringify(shadowed[0].proofs)).toBe(false); + }); + + it("rejects the wrapper imported from a module that is not the sanctioned one", () => { + const wrongModule = scanFixture( + FIXTURE_FILE, + 'import { withOwnerReadScope } from "@/lib/somewhere-else";\n' + + `export async function GET() { + return withOwnerReadScope(supabase.from("documents").select("id"), access.ownerId); + }`, + ); + expect(wrongModule[0].ownerScopedChain).toBe(false); + }); + + it("accepts a file-local wrapper that itself carries the owner predicate", () => { + const realLocal = scanFixture( + FIXTURE_FILE, + `function withOwnerReadScope(query, ownerId) { + return query.eq("owner_id", ownerId); + } + + export async function GET() { + return withOwnerReadScope(supabase.from("documents").select("id"), access.ownerId); + }`, + ); + expect(realLocal[0].ownerScopedChain).toBe(true); + }); + + it("rejects a file-local no-op named loadOwnedDocument", () => { + const entry = { + file: FIXTURE_FILE, + table: "document_chunks", + fn: "PATCH", + queries: 1, + proof: PROOF_KINDS.OWNED_DOCUMENT_HELPER, + identifier: "id", + reason: "fixture", + }; + const shadowed = scanFixture( + FIXTURE_FILE, + `async function loadOwnedDocument(args) { + return { id: args.documentId }; + } + + export async function PATCH() { + const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id }); + if (!document) return notFound(); + return supabase.from("document_chunks").select("id").eq("document_id", id); + }`, + ); + expect( + evaluateSites({ + dynamicFrom: [], + sites: shadowed, + exemptions: [], + inventory: [entry], + untiered: [], + }).violations.join("\n"), + ).toContain("no owning-document helper in this scope was handed `id`"); + }); + + it("registers only the documentId-shaped argument as an owning-document argument", () => { + const sites = scanFixture( + FIXTURE_FILE, + LOCAL_OWNED_DOCUMENT_HELPER + + `export async function PATCH() { + const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id }); + if (!document) return notFound(); + return supabase.from("document_chunks").select("id").eq("document_id", id); + }`, + ); + const derivedSite = sites.find((site) => site.table === "document_chunks"); + const owningArguments = derivedSite!.facts.owningHelperArguments; + expect([...owningArguments]).toEqual(["id"]); + // Every shorthand/identifier argument used to be collected, so `supabase` counted as an + // owning-document argument and could satisfy an entry declaring it as the identifier. + expect(owningArguments.has("supabase")).toBe(false); + expect(owningArguments.has("user")).toBe(false); + }); + + it("resolves a positional owning-helper call through the helper's own parameter names", () => { + const sites = scanFixture( + FIXTURE_FILE, + `async function requireOwnedDocument(supabase, documentId, ownerId) { + const { data } = await supabase + .from("documents") + .select("id") + .eq("id", documentId) + .eq("owner_id", ownerId) + .maybeSingle(); + if (!data) throw new PublicApiError("Document not found.", 404); + } + + export async function PATCH() { + await requireOwnedDocument(supabase, id, user.id); + return supabase.from("document_chunks").select("id").eq("document_id", id); + }`, + ); + const derivedSite = sites.find((site) => site.table === "document_chunks"); + expect([...derivedSite!.facts.owningHelperArguments]).toEqual(["id"]); + }); +}); + +describe("P2-5 — a table outside the three tiers is signalled, never silently uncovered", () => { + const TRIAGE = "clinical_quality_feedback_triage"; + const untieredFixture = () => + scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("${TRIAGE}").select("signal_id,status,owner_user_id"); + }`, + { all: new Set(["documents", "user_favourites", "document_chunks", TRIAGE]) }, + ); + + it("fails an undeclared query on a table that lands in no tenancy tier", () => { + const sites = untieredFixture(); + expect(sites).toHaveLength(1); + expect(sites[0].tier).toBe("untiered"); + const { violations, counts } = evaluateSites({ + dynamicFrom: [], + sites, + exemptions: [], + inventory: [], + untiered: [], + }); + expect(counts.untiered).toBe(1); + // Before the fourth signal this query produced NO site, NO violation and no mention + // anywhere: zero coverage with zero signal. + expect(violations.join("\n")).toContain("undeclared untiered-table query"); + expect(violations.join("\n")).toContain("carries no owner_id, user_id or document_id column"); + }); + + it("passes once the table is declared with a reason", () => { + const violations = evaluateSites({ + dynamicFrom: [], + sites: untieredFixture(), + exemptions: [], + inventory: [], + untiered: [ + { + file: FIXTURE_FILE, + table: TRIAGE, + fn: "GET", + queries: 1, + proof: PROOF_KINDS.UNTIERED_TABLE, + reason: "fixture", + }, + ], + }).violations; + expect(violations).toEqual([]); + }); + + it("refuses an untiered declaration pointed at a table that IS in a tier", () => { + const sites = scanFixture( + FIXTURE_FILE, + `export async function GET() { + return supabase.from("documents").select("id"); + }`, + ); + const violations = evaluateSites({ + dynamicFrom: [], + sites, + exemptions: [], + inventory: [], + untiered: [ + { + file: FIXTURE_FILE, + table: "documents", + fn: "GET", + queries: 1, + proof: PROOF_KINDS.UNTIERED_TABLE, + reason: "fixture", + }, + ], + }).violations; + expect(violations.join("\n")).toContain("No query site matched it at all"); + }); + + it("sees the real untiered table the review found, and it is declared", () => { + const { untiered, tiers } = queriedTablesByTier(process.cwd()); + expect(untiered, "the scanned files query no untiered table — has the tier parse changed?").toContain(TRIAGE); + for (const table of untiered) { + expect(tiers.direct.has(table) || tiers.userKeyed.has(table) || tiers.derived.has(table)).toBe(false); + expect( + UNTIERED_TABLE_DECLARATIONS.some((entry) => entry.table === table), + `${table} is queried, is in no tenancy tier, and is not declared in UNTIERED_TABLE_DECLARATIONS`, + ).toBe(true); + } + }); + + it("treats an empty tier as a broken scan, not a clean repo", () => { + // The database.types.ts parse is anchored to that generated file's exact indentation. + // De-indent it and every tier empties, every site disappears and the scan reports zero + // violations — so the shipped command asserts non-empty tier counts, and so does this. + expect(emptyTierNames({ direct: 0, userKeyed: 0, derived: 0 })).toEqual(["direct", "userKeyed", "derived"]); + expect(emptyTierNames({ direct: 5, userKeyed: 0, derived: 2 })).toEqual(["userKeyed"]); + expect(emptyTierNames(scanTenancy(process.cwd()).counts)).toEqual([]); + }); +}); + +describe("dynamic retrieval RPC dispatch (blind spot E)", () => { + it("flags a second dynamic .rpc() dispatch site and a non-literal dispatcher name", () => { + const secondDispatcher = analyzeRpcDispatch({ + relativePath: "src/lib/synthetic-retrieval.ts", + source: `export async function callSomething(client, name, args) { + return client.rpc(name, args); + }`, + }); + expect(secondDispatcher.dynamicRpcCalls).toHaveLength(1); + expect(secondDispatcher.dynamicRpcCalls[0].scopes).toContain("callSomething"); + expect(secondDispatcher.dynamicRpcCalls[0].scopes).not.toContain("callVersionedRetrievalRpc"); + + const literalCall = analyzeRpcDispatch({ + relativePath: "src/lib/rag/synthetic.ts", + source: `const result = await callVersionedRetrievalRpc(supabase, "match_v2", "match", args, signal);`, + }); + expect(literalCall.dispatcherCallSites).toHaveLength(1); + expect(literalCall.dispatcherCallSites[0].literalNames).toBe(true); + + const computedCall = analyzeRpcDispatch({ + relativePath: "src/lib/rag/synthetic.ts", + source: `const result = await callVersionedRetrievalRpc(supabase, versionedName, legacyName, args, signal);`, + }); + expect(computedCall.dispatcherCallSites[0].literalNames).toBe(false); + }); + + it("pins callVersionedRetrievalRpc as the only dynamic dispatcher in src/", () => { + const { dynamicRpcCalls, dispatcherCallSites, violations } = scanRpcDispatch(process.cwd()); + expect(dynamicRpcCalls.length, "found no .rpc() calls at all — has the client API changed?").toBeGreaterThan(0); + expect( + dispatcherCallSites.length, + "found no callVersionedRetrievalRpc call sites — the retrieval RPC surface moved", + ).toBeGreaterThan(0); + expect(dispatcherCallSites.every((site) => site.literalNames)).toBe(true); + expect(violations, violations.join("\n")).toEqual([]); + }); +}); + +describe("mechanical tenancy scan over the real surface", () => { + it("covers every .ts under src/app/api plus the named server-side read modules", () => { + const { sites } = scanTenancy(process.cwd()); + const scannedPaths = new Set(sites.map((site) => site.file)); + for (const modulePath of SCANNED_LIB_MODULES) { + expect(scannedPaths.has(modulePath), `${modulePath} produced no scanned query — is it still a read module?`).toBe( + true, + ); + } + // /api/documents/images/batch is a bare re-export of /api/images/signed-urls and so has + // no query of its own; the re-exported module is what carries the inventory entry. + expect(scannedPaths.has("src/app/api/documents/images/batch/route.ts")).toBe(false); + expect(scannedPaths.has("src/app/api/images/signed-urls/route.ts")).toBe(true); + }); + + it("keeps every direct, user-keyed and derived query scoped on its chain or declared", () => { + const { violations, counts } = scanTenancy(process.cwd()); + expect(counts.direct, "found no direct-tier queries — is the tier derivation stale?").toBeGreaterThan(0); + expect(counts.userKeyed, "found no user-keyed queries — is the tier derivation stale?").toBeGreaterThan(0); + expect(counts.derived, "found no derived-tier queries — is the tier derivation stale?").toBeGreaterThan(0); + expect( + violations, + "Every query on an owner_id / user_id / document_id table must carry its tenancy predicate on its own " + + "query chain, or be declared in SCOPE_EXEMPTIONS / DERIVED_QUERY_INVENTORY " + + `(scripts/lib/tenancy-scan.mjs) with the proof of ownership:\n${violations.join("\n")}`, + ).toEqual([]); + }); + + it("documents every declared exemption and inventory entry in the tenancy review", () => { + const review = readFileSync(join(process.cwd(), "docs", "audit", "tenancy-defense-in-depth-review.md"), "utf8"); + const lines = review.split("\n"); + for (const entry of [...SCOPE_EXEMPTIONS, ...DERIVED_QUERY_INVENTORY, ...UNTIERED_TABLE_DECLARATIONS]) { + expect(entry.reason.length, `${entry.file} / ${entry.table} has no reason`).toBeGreaterThan(40); + const documented = lines.some((line) => line.includes(entry.file) && line.includes(entry.table)); + expect( + documented, + `${entry.file} / ${entry.table} (${entry.fn}) is not documented in docs/audit/tenancy-defense-in-depth-review.md`, + ).toBe(true); + } + }); +});