diff --git a/.changeset/eql-v3-supabase-adapter.md b/.changeset/eql-v3-supabase-adapter.md new file mode 100644 index 000000000..43e5a4a67 --- /dev/null +++ b/.changeset/eql-v3-supabase-adapter.md @@ -0,0 +1,65 @@ +--- +'@cipherstash/stack': minor +--- + +Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter. It is +now a connect-time-async factory: `await encryptedSupabaseV3(url, key)` (or +`(client)`) introspects the database over `DATABASE_URL`, detects EQL v3 columns +by their Postgres domain (`information_schema.columns.domain_name`), and derives +each column's encryption config from its domain — callers no longer pass a +schema to `from()`. `select('*')` is supported (expanded from the introspected +column list, and aliased back to each declared column's JS property name so a +property→DB rename round-trips). A column using an EQL v3 domain this SDK version does not model +(e.g. `public.json`, `*_ord_ope`) throws at construction rather than silently +passing through. Supplying `schemas` remains optional and adds compile-time +types plus startup verification of the declared tables against the database. +Requires a Postgres connection for introspection (`pg` is a new optional peer), +so it cannot run in a Worker or the browser. + +Every column name a query carries — filters, `match`, `not`, raw `filter`, +`or()`, `order()`, and the `onConflict` option — is now resolved from its JS +property name to its DB column name in a single pass before the query is built, +so a declared rename round-trips everywhere rather than only on the paths that +remembered to translate. + +`order()` on ANY encrypted v3 column is now rejected — at compile time when +`schemas` is supplied, and at runtime otherwise. The EQL v3 domains are +`DOMAIN … AS jsonb` and the bundle declares no btree operator class on them, so +`ORDER BY col` resolves through jsonb's default `jsonb_cmp` and sorts by the +envelope's byte structure: a stable, plausible-looking, meaningless row order, +with no error. Correct ordering is `ORDER BY eql_v3.ord_term(col)`, which +PostgREST's `order=` cannot express. Order by a plaintext column, expose +`eql_v3.ord_term()` as a generated column or view, or use the EQL v3 Drizzle +integration, which emits `ord_term` directly. Note `gte`/`lte` filters remain +correct: the comparison operators *are* declared on the ord domains, and only +sorting resolves through the missing operator class. + +`.or()` now understands PostgREST's `column.not..` negation. It was +previously parsed as `{ op: 'not', value: '.' }`, so on an encrypted +column `or('nickname.not.in.(ada,grace)')` encrypted the literal string +`in.(ada,grace)` as a single plaintext and produced a filter that silently +matched nothing. + +Free-text search on the v3 builder is `contains(column, value)`. `like`/`ilike` +are not exposed, because EQL v3 free-text search is token containment over a +bloom filter (`@>`, backed by `eql_v3.contains`) rather than SQL wildcard +matching — `%` is tokenized like any other character, so a `like` pattern is a +category error. This matches the v3 Drizzle integration, which omits them for +the same reason. On an encrypted column `like`/`ilike` now throw and name +`contains`; on a plaintext column they remain ordinary PostgREST filters. + +`contains` is narrowed at compile time to columns whose domain carries the +`freeTextSearch` capability (`public.text_match`, `public.text_search`), and +guarded at runtime for the untyped surface. A raw `filter(column, operator, …)` +on an encrypted v3 column now derives its query type from the operator instead +of always encrypting an equality term, so `filter('bio', 'cs', …)` on a +`public.text_match` column works rather than being rejected, and an unsupported +operator throws instead of silently encrypting the wrong term. + +Substring `contains` still matches only when the needle equals the stored value +or is exactly the tokenizer's window (3 characters): the operand is a storage +envelope whose bloom carries the whole needle as an `include_original` token. +This is shared with v3 Drizzle's `contains` and tracked upstream in EQL. + +v2 (`encryptedSupabase`) is unchanged: it keeps `like`/`ilike` (`eql_v2.like`, +`~~`) and its raw-`filter` query-type mapping, so no v2 ciphertext moves. diff --git a/.changeset/supabase-is-null-operands.md b/.changeset/supabase-is-null-operands.md new file mode 100644 index 000000000..46478aa24 --- /dev/null +++ b/.changeset/supabase-is-null-operands.md @@ -0,0 +1,27 @@ +--- +'@cipherstash/stack': patch +--- + +Fix the Supabase adapter encrypting `is` and `null` filter operands. + +`is` is a SQL predicate — PostgREST accepts only `null`/`true`/`false` after it +— and a `null` operand is SQL NULL, never a value to search for. Only the direct +`.is()` filter skipped encryption; `not()`, `or()`, `match()`, raw `filter()`, +and the `in()` element list all encrypted whatever they were handed. So +`or('age.is.null')` emitted `age.is."("null")"` and `eq('email', null)` emitted +`email=("null")` — operands PostgREST rejects. A null plaintext is stored as a +NULL column rather than ciphertext, so it is found with an unencrypted +`IS NULL`; encrypting the operand could never match. + +A single `isEncryptableTerm(operator, value)` predicate now guards every term +collector. Affects both `encryptedSupabase` (v2) and `encryptedSupabaseV3`. On +v3 this additionally removes a spurious `does not support equality queries` +error, which `is` raised because it maps to the `equality` query type and so hit +the column-capability guard — `or('active.is.null')` on a storage-only column +threw rather than querying. + +Relatedly, an `or()` string is now rebuilt whenever a condition *references* an +encrypted column, not only when one of its values was encrypted. An `is` on an +encrypted column encrypts nothing, and the old condition sent it down the +verbatim path, forwarding the caller's JS property name to a database that only +knows the column's DB name. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b2b215e18..4719b2cc5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,6 +87,34 @@ jobs: echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env + # PostgREST for `supabase-v3-pgrest-live.test.ts` — the only suite that + # executes the supabase v3 adapter against a real server rather than a + # mock that records strings. + # + # Started as a STEP, not a `services:` entry. The role it connects as + # (`authenticator`, a non-superuser member of `anon`) does not exist in + # the postgres image, and service containers all start before the repo is + # checked out — so there is no point at which a `services:` PostgREST + # could find it. A step runs after the roles exist, deterministically. + # + # `PGRST_DB_ANON_ROLE: anon` is the whole point: pointing it at the DB + # owner would make every grant check pass vacuously. + - name: Start PostgREST + run: | + PGPASSWORD=password psql -h localhost -U cipherstash -d cipherstash \ + -v ON_ERROR_STOP=1 -f ./local/postgrest-roles.sql + docker run -d --name postgrest --network host \ + -e PGRST_DB_URI="postgres://authenticator:authpass@localhost:5432/cipherstash" \ + -e PGRST_DB_SCHEMAS=public \ + -e PGRST_DB_ANON_ROLE=anon \ + -e PGRST_DB_CHANNEL_ENABLED=true \ + postgrest/postgrest:v12.2.12 + for i in $(seq 1 30); do + curl -sf -o /dev/null http://localhost:3000/ && exit 0 + sleep 1 + done + echo "PostgREST did not become ready"; docker logs postgrest; exit 1 + - name: Create .env file in ./packages/stack/ run: | touch ./packages/stack/.env @@ -95,6 +123,7 @@ jobs: echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/stack/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env + echo "PGRST_URL=http://localhost:3000" >> ./packages/stack/.env - name: Create .env file in ./packages/protect-dynamodb/ run: | diff --git a/docs/superpowers/specs/2026-07-09-supabase-v3-introspection-design.md b/docs/superpowers/specs/2026-07-09-supabase-v3-introspection-design.md new file mode 100644 index 000000000..9a6f04c84 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-supabase-v3-introspection-design.md @@ -0,0 +1,388 @@ +# Supabase v3 adapter: schema introspection at connect time + +**Status:** design +**Date:** 2026-07-09 +**Package:** `@cipherstash/stack/supabase` + +## Summary + +`encryptedSupabaseV3` becomes a connect-time-async factory that reads the +database schema, detects EQL v3 columns by their Postgres domain, and derives +each column's encryption config from its domain. Callers stop passing a schema +to `from()`. + +```ts +const supabase = await encryptedSupabaseV3(supabaseUrl, supabaseKey) + +await supabase.from('users').insert({ email: 'alice@example.com' }) + +const { data } = await supabase + .from('users') + .select() + .eq('email', 'alice@example.com') +``` + +Declaring schemas remains available, and adds compile-time types plus +startup verification of the database against the declaration. + +## Motivation + +Today both `encryptedSupabase` and `encryptedSupabaseV3` require the caller to +build an `EncryptionClient`, build a Supabase client, and pass a schema object +to every `from()` call. For v3 the schema object is largely redundant: a v3 +column's Postgres domain (`public.text_search`, `public.integer_ord`, …) *is* +its encryption config. The database already holds every fact the adapter needs. + +## Background: how the v2 adapter works + +`encryptedSupabase({ encryptionClient, supabaseClient })` returns an object with +one member, `from(tableName, schema)`, constructing an +`EncryptedQueryBuilderImpl`. That builder mirrors supabase-js's chainable +surface, but every method is a *recorder* — it pushes onto `this.filters` / +`this.transforms` / `this.mutation` and returns `this`. Nothing executes. + +The builder is `PromiseLike`. Awaiting it calls `execute()` +(`query-builder.ts:343`), which: + +1. Encrypts mutation data (`encryptModel` / `bulkEncryptModels`), then wraps + each value as `{ data: … }` for the `eql_v2_encrypted` composite. +2. Builds the select string, appending `::jsonb` to encrypted columns. + `select('*')` throws — there is no column list to cast. +3. Walks the filter buckets, collects encryptable terms into a flat `terms[]` + with a parallel `termMap[]` recording provenance, and batch-encrypts them in + one `encryptQuery()` call. +4. Replays the recorded chain onto the real Supabase builder, substituting + encrypted values where a term existed. +5. Decrypts result rows. + +The schema is consulted for exactly three things: the set of encrypted column +**names**, the column **builder** for each name (which carries the index +config), and the **table** object as encryption context. + +`EncryptedQueryBuilderV3Impl` overrides protected seams on this machinery — +full-envelope filter operands, raw jsonb mutation payloads, `like` → `cs`, +property↔DB name resolution, `Date` reconstruction from `cast_as`. The +recording and replay machinery is shared. + +Only the three schema lookups change under this design. The query mechanism +does not. + +## Design + +### 1. Load the type + +Every v3 domain is `CREATE DOMAIN public. AS jsonb`. For a domain column, +`information_schema.columns` populates `domain_schema` and `domain_name`; both +`data_type` and `udt_name` report the *base* type. + +Measured against Postgres 17 (spike, 2026-07-09): + +| column | `data_type` | `udt_name` | `domain_schema` | `domain_name` | +|---|---|---|---|---| +| `id serial` | integer | int4 | — | — | +| `email spike.text_search` | jsonb | jsonb | spike | text_search | +| `note text` | text | text | — | — | +| `meta jsonb` | jsonb | jsonb | — | — | + +Three consequences: + +- **`domain_name` is unqualified**, with `domain_schema` returned separately. +- **`udt_name` is `jsonb`**, so the v2 detection + (`udt_name === 'eql_v2_encrypted'`, + `packages/cli/src/commands/init/lib/introspect.ts:88`) cannot be adapted by + swapping the compared string — it would compare against `jsonb` forever. + `eql_v2_encrypted` has `typtype = 'c'` (composite), which is why it surfaces + in `udt_name`; domains surface in `domain_name`. +- **A plain `jsonb` column has `domain_name` NULL**, so encrypted and + unencrypted jsonb columns are cleanly distinguishable. A domain carrying a + `CHECK` constraint — as every EQL domain does — reports identically to one + without. + +```sql +SELECT c.table_name, c.column_name, c.domain_name +FROM information_schema.columns c +JOIN information_schema.tables t + ON t.table_name = c.table_name AND t.table_schema = c.table_schema +WHERE c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' +ORDER BY c.table_name, c.ordinal_position +``` + +Plaintext columns are retained, not filtered out. This buys three things: + +- `from('unknown_table')` throws rather than silently passing through. +- A table with zero encrypted columns is a *known* passthrough. +- `select('*')` becomes expandable — emit the full column list with `::jsonb` on + the encrypted ones — so the current `select('*')` throw is removed. + +A `domain_name` absent from the registry (a user's own domain, or a newer EQL +release) is treated as plaintext. Not an error. + +### 2. Use the type + +`packages/stack/src/eql/v3/columns.ts` defines 47 domain constants, each +`{ eqlType, castAs, capabilities }`. `public.integer_ord` is +`castAs: 'number'`, order-and-range. That *is* the encryption config. + +Add a `DOMAIN_REGISTRY: Record AnyEncryptedV3Column>` +keyed by unqualified domain name, whose values are the **existing `types` +factories** (`eql/v3/types.ts`) rather than direct `new EncryptedXColumn(...)` +calls. The factories pass the literal domain constants +(`Integer: (name) => new EncryptedIntegerColumn(name, INTEGER)`), and that +literal is what keeps the domains nominally distinct. Constructing the classes +directly would create a second source of truth for exactly the thing the class +comments warn about drifting. `types.TextSearch` has a different arity — +`(name) => new EncryptedTextSearchColumn(name)`, no constant — so the registry +maps values, not a mechanical transform. + +**Key normalization.** `eqlType` is qualified (`'public.text_search'`), while +`information_schema` returns `domain_schema = 'public'` and +`domain_name = 'text_search'` as separate columns. The registry is keyed on the +unqualified name; the exhaustiveness test must strip the `public.` prefix from +each `eqlType` before comparing, or it will pass while matching nothing. + +A test asserts every `eqlType` in `columns.ts` has a registry entry, and that +each entry round-trips: `registry[strip(eqlType)]('c').getEqlType() === eqlType`. + +Introspected rows group by table into synthesized `EncryptedTable` instances, +which feed `EncryptionV3({ schemas })`. + +Because introspection yields DB column names directly, the JS property name +equals the DB column name. `propToDb` is the identity, and the `prop:db::jsonb` +aliasing branch in `addJsonbCastsV3` becomes a no-op for synthesized tables. It +stays in place for declared tables, which may still map `createdAt → created_on`. + +### 3. Construction + +```ts +type V3Schemas = Record + +type EncryptedSupabaseV3Options = { + /** Defaults to `process.env.DATABASE_URL`. */ + databaseUrl?: string + /** Passed through to `EncryptionV3`. */ + config?: ClientConfig + /** Optional. See "Optional schemas". */ + schemas?: S +} + +// url + key +export async function encryptedSupabaseV3( + supabaseUrl: string, + supabaseKey: string, + options: EncryptedSupabaseV3Options & { schemas: S }, +): Promise> +export async function encryptedSupabaseV3( + supabaseUrl: string, + supabaseKey: string, + options?: EncryptedSupabaseV3Options, +): Promise + +// existing client +export async function encryptedSupabaseV3( + supabaseClient: SupabaseClientLike, + options: EncryptedSupabaseV3Options & { schemas: S }, +): Promise> +export async function encryptedSupabaseV3( + supabaseClient: SupabaseClientLike, + options?: EncryptedSupabaseV3Options, +): Promise +``` + +The `schemas`-bearing overload precedes the bare one in declaration order, so +TypeScript selects it whenever `schemas` is present and infers `S` from the +value. + +The factory creates (or accepts) a Supabase client, introspects over +`databaseUrl ?? process.env.DATABASE_URL`, synthesizes tables, builds the +encryption client via `EncryptionV3`, and returns `{ from(tableName) }`. + +Accepting an existing client is required: SSR apps hand the adapter a client +that already carries an auth session, and `withLockContext` depends on that +identity. + +If neither `databaseUrl` nor `DATABASE_URL` is present, throw at construction +with a message naming both. + +`from()` resolves the table from the introspected map and passes the synthesized +`EncryptedTable` to `EncryptedQueryBuilderV3Impl`, unchanged. + +### 4. Optional schemas + +Types cannot be derived from an `await`. Introspection runs at runtime, so +`from('users')` sees only a string literal at compile time. Deriving the schema +from the database therefore *necessarily* costs compile-time type safety. + +The resolution is to invert introspection's role when a schema is supplied: it +verifies rather than derives. + +```ts +const users = encryptedTable('users', { + email: types.TextSearch('email'), + amount: types.IntegerOrd('amount'), + active: types.Boolean('active'), +}) + +const supabase = await encryptedSupabaseV3(url, key, { schemas: { users } }) +``` + +`from('users')` still takes no schema argument. The instance is generic over +`typeof schemas`, so the table name is constrained to the declared keys and the +builder resolves to `EncryptedQueryBuilderV3>`. + +This mirrors `createClient(url, key)` — schema on the client, naked +`from()` — except the generic is inferred from a value rather than supplied by +hand. It matches Drizzle's `drizzle(client, { schema })`. + +Two overloads: + +- **Without `schemas`** — `from(tableName: string)` accepts an optional row + generic, returning the untyped builder. Rows are `Record`. +- **With `schemas: S`** — `from(table: K)` returns + `EncryptedQueryBuilderV3>`. + +Adoption is a **gradient**, not a switch: declare one table, leave the rest +introspected. Declared tables get types; undeclared tables behave exactly as +they would with no `schemas` at all. + +#### Verification + +For every declared column, assert the column exists and its introspected +`domain_name` matches the declared `eqlType`. Mismatch throws at construction, +naming table, column, declared domain, and actual domain. + +`types.TextSearch('email')` against a column that is actually `public.text_eq` +fails at startup, instead of a `23514` CHECK violation on the first query. +Neither the current code nor codegen offers this: codegen'd types are correct +only until the next migration. + +Declaring a table that does not exist, or a column that does not exist, is an +error. Declaring a *subset* of a table's encrypted columns is not — undeclared +columns are synthesized from their domains. + +#### Runtime effect of declaring + +For 46 of the 47 domains, a synthesized column and a declared column emit a +**byte-identical** encrypt config. `columns.ts` defines 42 subclasses and +exactly one `override build()`; every other class inherits + +```ts +build(): ColumnSchema { + return { + cast_as: this.definition.castAs, + indexes: indexesForCapabilities(this.definition.capabilities, this.definition.castAs), + } +} +``` + +— a pure function of `{ castAs, capabilities }`, which is a pure function of the +domain name. Declaring such a column adds types and verification, nothing else. + +`EncryptedTextSearchColumn` (`columns.ts:470`) is the sole exception: it carries +`matchOpts` and overrides `build()` to replace the `match` index block. Its +constructor initialises `defaultMatchOpts()`, and `indexesForCapabilities` +(`columns.ts:355`) emits the same defaults for the freeTextSearch capability — +so even a `text_search` column is byte-identical when synthesized, **unless the +caller invoked `.freeTextSearch(opts)`**. + +Therefore the rule is: + +- **Column declared** — verify the domain matches, then use the *declared* + builder, which carries any tuned match options. +- **Column not declared** — synthesize from the introspected domain. + +The single observable consequence: the `include_original: false` caveat +documented on `EncryptedQueryBuilderV3Impl` can only be honoured on a declared +`text_search` column. Substring `like` against an undeclared `text_search` +column will not match, because the default `include_original: true` puts the +whole pattern into the bloom filter as an extra token. + +This asymmetry must be documented on the `schemas` option, not only here. + +### 5. Dependencies + +`pg` becomes a dependency of the Supabase entrypoint. It is already a CLI +dependency. Declare it as an optional peer and load it with a dynamic import so +bundlers do not pull it in unless introspection runs. + +This means `encryptedSupabaseV3` cannot run in a Worker or the browser: it needs +a Postgres socket. That is a real narrowing of where the adapter runs, and it is +inherent to introspecting at connect time. Supplying `schemas` does not avoid +it — verification still connects. + +## Components + +| Unit | Responsibility | Depends on | +|---|---|---| +| `eql/v3/domain-registry.ts` | `domain_name` → column factory | `eql/v3/types` (the `types` factories) | +| `supabase/introspect.ts` | Read `information_schema`, return tables + columns + domains | `pg` | +| `supabase/schema-builder.ts` | Rows → `EncryptedTable[]`; merge declared over synthesized | registry, `eql/v3/table` | +| `supabase/verify.ts` | Declared schema vs introspected reality | introspect output | +| `supabase/index.ts` | Factory: client, introspect, verify, `EncryptionV3`, `from()` | all of the above | + +`query-builder.ts` and `query-builder-v3.ts` are unchanged apart from removing +the `select('*')` throw and threading the full column list for expansion. + +## Data flow + +``` +encryptedSupabaseV3(url, key, { schemas? }) + ├─ createClient(url, key) (or accept a client) + ├─ introspect(databaseUrl) → [{ table, column, domain_name }] + ├─ registry lookup → synthesized EncryptedTable[] + ├─ if schemas: verify + override → declared builders win + ├─ EncryptionV3({ schemas: tables }) → EncryptionClient + └─ { from(tableName) } → EncryptedQueryBuilderV3Impl +``` + +## Error handling + +| Condition | Behaviour | +|---|---| +| No `databaseUrl` and no `DATABASE_URL` | Throw at construction, naming both | +| Introspection connection failure | Throw at construction, with the pg error | +| Declared table absent from database | Throw, naming the table | +| Declared column absent from table | Throw, naming table + column | +| Declared domain ≠ actual domain | Throw, naming both domains | +| Unknown `domain_name` in database | Treated as plaintext, no error | +| `from()` on an unknown table | Throw, naming the table | +| Filter on storage-only column | Compile error if declared; runtime throw otherwise (existing behaviour, `query-builder-v3.ts:180`) | + +## Testing + +- **Unit** — `DOMAIN_REGISTRY` exhaustiveness against every `eqlType` in + `columns.ts`. Rows → `EncryptedTable[]` grouping. Verification: match, + wrong-domain, missing column, missing table. Declared-over-synthesized + override, including that a declared `TextSearch` retains its tuner options. +- **Live Postgres** — introspection against a table with mixed encrypted and + plaintext columns, asserting the domain→builder round-trip. + + **Setup dependency.** The compose image (`postgres-eql:17-2.3.1`, + `local/docker-compose.yml`) reports `eql_v3.version()` as `DEV` but contains + **zero domains** in `public` — the concrete-domain surface is absent. The 47 + domains exist only after applying this branch's + `packages/cli/src/sql/cipherstash-encrypt-v3.sql`. Live introspection tests + must apply that SQL first, as the existing v3 pg tests do; they cannot run + against the stock image. +- **Wire encoding** — existing `supabase-v3-builder.test.ts` mock-client tests + continue to pass unchanged, with the builder constructed from a synthesized + table rather than a declared one. +- **Types** — `supabase-v3.test-d.ts` splits: the existing four guarantees are + re-pinned under `{ schemas }`, and a new block asserts the untyped surface + without `schemas` (rows are `Record`; `from` accepts any + string). + +## Out of scope + +- **Codegen.** `stash gen types` emitting the v3 table objects from the same + `information_schema` query. The API does not change when it lands — callers + stop hand-writing `users` and import it instead. Deferred deliberately: make + it work before generating it. +- **v2.** `encryptedSupabase` keeps its current signature and behaviour. +- **PostgREST-only introspection.** Neither `@supabase/supabase-js` nor + `@supabase/postgrest-js` exposes any introspection API (verified: no + `openapi`/`swagger`/`introspect` surface in either package; `schema()` only + swaps the `Accept-Profile` header). PostgREST's OpenAPI root document is the + only non-Postgres runtime source, and whether it names a column's domain or + flattens it to `jsonb` is unverified. If it names the domain, a future + increment can drop the `pg` dependency and restore Worker/browser support. diff --git a/local/docker-compose.yml b/local/docker-compose.yml index bec67077c..c670c1e61 100644 --- a/local/docker-compose.yml +++ b/local/docker-compose.yml @@ -11,6 +11,10 @@ services: POSTGRES_PASSWORD: password ports: - 5432:5432 + volumes: + # Creates anon / authenticated / service_role / postgres / authenticator + # before PostgREST first connects. See the file for why. + - ./postgrest-roles.sql:/docker-entrypoint-initdb.d/00-postgrest-roles.sql:ro deploy: resources: limits: @@ -22,3 +26,22 @@ services: interval: 1s timeout: 5s retries: 10 + + postgrest: + # Pinned, matching the discipline the postgres service already follows. + image: postgrest/postgrest:v12.2.12 + depends_on: + postgres: + condition: service_healthy + environment: + # Connect as `authenticator`, then SET ROLE to `anon` for unauthenticated + # requests. Running as the DB OWNER instead would make every grant check + # pass vacuously — the suite exists to prove the Supabase grants work. + PGRST_DB_URI: "postgres://authenticator:authpass@postgres:5432/cipherstash" + PGRST_DB_SCHEMAS: public + PGRST_DB_ANON_ROLE: anon + # Lets `NOTIFY pgrst, 'reload schema'` reach the server. + PGRST_DB_CHANNEL_ENABLED: "true" + ports: + - 3000:3000 + restart: always diff --git a/local/postgrest-roles.sql b/local/postgrest-roles.sql new file mode 100644 index 000000000..d6800e303 --- /dev/null +++ b/local/postgrest-roles.sql @@ -0,0 +1,35 @@ +-- Roles PostgREST needs, created at database init. +-- +-- `authenticator` is the role PostgREST CONNECTS as; it must be able to +-- `SET ROLE` to `anon`, so it is a member of it and NOINHERIT (it gets anon's +-- rights only after switching, never ambiently). This is PostgREST's documented +-- convention and it is what makes the live suite exercise the Supabase grants +-- rather than the owner's ambient superuser rights — pointing +-- PGRST_DB_ANON_ROLE at the owner would make every permission check pass +-- vacuously and prove nothing. +-- +-- `postgres` exists only because the shipped grants block says +-- `ALTER DEFAULT PRIVILEGES FOR ROLE postgres`; a plain Postgres image has no +-- such role. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') THEN + CREATE ROLE postgres NOLOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticator') THEN + CREATE ROLE authenticator LOGIN PASSWORD 'authpass' NOINHERIT; + END IF; +END +$$; + +GRANT anon, authenticated TO authenticator; +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; diff --git a/packages/cli/src/__tests__/installer.test.ts b/packages/cli/src/__tests__/installer.test.ts index cef478beb..9bd9e8afa 100644 --- a/packages/cli/src/__tests__/installer.test.ts +++ b/packages/cli/src/__tests__/installer.test.ts @@ -234,10 +234,33 @@ describe('EQLInstaller', () => { expect(otherCalls[0]).toContain('eql_v3') expect(otherCalls[0]).not.toContain('CREATE OPERATOR CLASS') expect(otherCalls[0]).not.toContain('CREATE OPERATOR FAMILY') - // The grants are keyed to eql_v3, not eql_v2. + // The grants are keyed to eql_v3, not eql_v2. The installed block must be + // the SAME string the Supabase migration file embeds — the installer used + // to rebuild it from the schema name alone, letting the two drift. expect(otherCalls[1]).toBe(SUPABASE_PERMISSIONS_SQL_V3) expect(SUPABASE_PERMISSIONS_SQL_V3).toContain('eql_v3') expect(SUPABASE_PERMISSIONS_SQL_V3).not.toContain('eql_v2') + + // `eql_v3.eq_term`/`ord_term`/`match_term` are SECURITY INVOKER and + // qualify `eql_v3_internal.*` in their bodies, so without USAGE on that + // schema every encrypted filter fails for anon/authenticated with + // "permission denied for schema eql_v3_internal". See + // `supabaseInternalPermissionsSql`, and the live proof in + // packages/stack/__tests__/supabase-v3-grants-pg.test.ts. + expect(SUPABASE_PERMISSIONS_SQL_V3).toContain( + 'GRANT USAGE ON SCHEMA eql_v3_internal TO anon, authenticated, service_role;', + ) + expect(SUPABASE_PERMISSIONS_SQL_V3).toContain( + 'GRANT EXECUTE ON ALL ROUTINES IN SCHEMA eql_v3_internal TO anon, authenticated, service_role;', + ) + }) + + // `eql_v2` has no internal schema; the v3-only addition must not leak into + // the v2 block, where it would fail with "schema does not exist". + it('does not grant an internal schema in the v2 permissions block', async () => { + const { SUPABASE_PERMISSIONS_SQL } = await import('@/installer/index.ts') + + expect(SUPABASE_PERMISSIONS_SQL).not.toContain('_internal') }) it('installs the full v3 bundle (with operator classes) without supabase', async () => { diff --git a/packages/cli/src/installer/grants.ts b/packages/cli/src/installer/grants.ts new file mode 100644 index 000000000..ee9ec00af --- /dev/null +++ b/packages/cli/src/installer/grants.ts @@ -0,0 +1,82 @@ +/** + * The Supabase grants blocks, as pure strings. + * + * Deliberately import-free. `installer/index.ts` pulls in `pg` and the EQL SQL + * bundle, which no other package depends on; keeping the grants here lets the + * live proof in `packages/stack/__tests__/supabase-v3-grants-pg.test.ts` assert + * against the EXACT SQL this package ships, without `@cipherstash/stack` taking + * a dependency on `stash` (which would be a cycle — `stash` already depends on + * `@cipherstash/stack`). Re-exported from `installer/index.ts`, which remains + * the public entry point. + */ + +/** EQL v2's operator schema. It has no internal schema. */ +export const EQL_SCHEMA_NAME = 'eql_v2' + +/** + * EQL v3 installs its operator functions into `eql_v3` (constructors live in + * `eql_v3_internal`; the scalar type domains live in `public`). The `eql_v3` + * schema is the install-detection target, and BOTH schemas are grant targets — + * see {@link supabaseInternalPermissionsSql}. + */ +export const EQL_V3_SCHEMA_NAME = 'eql_v3' +export const EQL_V3_INTERNAL_SCHEMA_NAME = 'eql_v3_internal' + +/** + * Build the SQL block that grants an EQL schema, tables, routines, and + * sequences to Supabase's built-in roles (`anon`, `authenticated`, + * `service_role`). + * + * Supabase uses dedicated roles that don't own the schema, so explicit grants + * are required. Returned as a single multi-statement string so it can be + * executed in one `client.query()` (Postgres accepts multi-statement strings) + * AND embedded directly into a Supabase migration file. One source of truth + * for both the runtime install path and the generated migration file, shared + * by the v2 (`eql_v2`) and v3 (`eql_v3`) installs. + */ +export function supabasePermissionsSql(schemaName: string): string { + return `GRANT USAGE ON SCHEMA ${schemaName} TO anon, authenticated, service_role; +GRANT SELECT ON ALL TABLES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; +GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; +GRANT USAGE ON ALL SEQUENCES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT SELECT ON TABLES TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT EXECUTE ON ROUTINES TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT USAGE ON SEQUENCES TO anon, authenticated, service_role; +` +} + +/** + * Grants a *supporting* EQL schema that holds only routines and types — no + * tables or sequences to grant. + * + * Load-bearing for EQL v3. Every public entry point the query path touches + * (`eql_v3.eq_term`, `ord_term`, `match_term` — 68 of their 69 overloads) is + * SECURITY INVOKER and qualifies `eql_v3_internal.*` by name in its body. + * Postgres resolves those names with the CALLER's privileges, and schema USAGE + * is checked at name resolution. Without USAGE on `eql_v3_internal`, `anon` and + * `authenticated` get `permission denied for schema eql_v3_internal` on EVERY + * encrypted filter — `=`, `>=`, and `@>` alike, since each routes through a + * term extractor. The default PUBLIC EXECUTE on functions means USAGE is the + * only real barrier; EXECUTE is granted too so an install into a database that + * has revoked EXECUTE from PUBLIC still works. + * + * `eql_v2` has no internal schema, so this applies to v3 only. + */ +export function supabaseInternalPermissionsSql(schemaName: string): string { + return `GRANT USAGE ON SCHEMA ${schemaName} TO anon, authenticated, service_role; +GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT EXECUTE ON ROUTINES TO anon, authenticated, service_role; +` +} + +/** The v2 (`eql_v2`) Supabase grants block. See {@link supabasePermissionsSql}. */ +export const SUPABASE_PERMISSIONS_SQL = supabasePermissionsSql(EQL_SCHEMA_NAME) + +/** + * The v3 Supabase grants block: `eql_v3` (the public surface) AND + * `eql_v3_internal` (which its function bodies reach into). See + * {@link supabaseInternalPermissionsSql} for why the second block is required. + */ +export const SUPABASE_PERMISSIONS_SQL_V3 = + supabasePermissionsSql(EQL_V3_SCHEMA_NAME) + + supabaseInternalPermissionsSql(EQL_V3_INTERNAL_SCHEMA_NAME) diff --git a/packages/cli/src/installer/index.ts b/packages/cli/src/installer/index.ts index bd75afa83..b5f96d626 100644 --- a/packages/cli/src/installer/index.ts +++ b/packages/cli/src/installer/index.ts @@ -1,17 +1,31 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import pg from 'pg' +import { + EQL_SCHEMA_NAME, + EQL_V3_SCHEMA_NAME, + SUPABASE_PERMISSIONS_SQL, + SUPABASE_PERMISSIONS_SQL_V3, +} from './grants.js' // EQL release, pinned to match the EQL payload format this package emits. // Bump in lockstep with @cipherstash/protect-ffi. const EQL_VERSION = 'eql-2.3.1' const EQL_INSTALL_URL = `https://github.com/cipherstash/encrypt-query-language/releases/download/${EQL_VERSION}/cipherstash-encrypt.sql` const EQL_INSTALL_NO_OPERATOR_FAMILY_URL = `https://github.com/cipherstash/encrypt-query-language/releases/download/${EQL_VERSION}/cipherstash-encrypt-supabase.sql` -const EQL_SCHEMA_NAME = 'eql_v2' -// EQL v3 installs its operator functions into `eql_v3` (constructors live in -// `eql_v3_internal`; the scalar type domains live in `public`). The `eql_v3` -// schema is the install-detection and grant target. -const EQL_V3_SCHEMA_NAME = 'eql_v3' + +// The grants SQL lives in `./grants.ts` (import-free) so the live proof in +// `@cipherstash/stack` can assert against the exact shipped strings without a +// package cycle. Re-exported here: this module stays the public entry point. +export { + EQL_SCHEMA_NAME, + EQL_V3_INTERNAL_SCHEMA_NAME, + EQL_V3_SCHEMA_NAME, + SUPABASE_PERMISSIONS_SQL, + SUPABASE_PERMISSIONS_SQL_V3, + supabaseInternalPermissionsSql, + supabasePermissionsSql, +} from './grants.js' /** * Which EQL generation to install / inspect. `2` is the composite @@ -25,35 +39,19 @@ function schemaNameFor(eqlVersion: EqlVersion): string { } /** - * Build the SQL block that grants an EQL schema, tables, routines, and - * sequences to Supabase's built-in roles (`anon`, `authenticated`, - * `service_role`). - * - * Supabase uses dedicated roles that don't own the schema, so explicit grants - * are required. Returned as a single multi-statement string so it can be - * executed in one `client.query()` (Postgres accepts multi-statement strings) - * AND embedded directly into a Supabase migration file. One source of truth - * for both the runtime install path and the generated migration file, shared - * by the v2 (`eql_v2`) and v3 (`eql_v3`) installs. + * The grants block for an EQL generation — the ONE source of truth for both the + * runtime install path ({@link EQLInstaller.grantSupabasePermissions}) and the + * generated Supabase migration file. Previously the installer rebuilt the block + * from `supabasePermissionsSql(schemaNameFor(...))`, so a v3-only addition (the + * `eql_v3_internal` grants) reached the migration file and NOT the database the + * CLI installs into. */ -export function supabasePermissionsSql(schemaName: string): string { - return `GRANT USAGE ON SCHEMA ${schemaName} TO anon, authenticated, service_role; -GRANT SELECT ON ALL TABLES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; -GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; -GRANT USAGE ON ALL SEQUENCES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT SELECT ON TABLES TO anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT EXECUTE ON ROUTINES TO anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT USAGE ON SEQUENCES TO anon, authenticated, service_role; -` +export function supabaseGrantsFor(eqlVersion: EqlVersion): string { + return eqlVersion === 3 + ? SUPABASE_PERMISSIONS_SQL_V3 + : SUPABASE_PERMISSIONS_SQL } -/** The v2 (`eql_v2`) Supabase grants block. See {@link supabasePermissionsSql}. */ -export const SUPABASE_PERMISSIONS_SQL = supabasePermissionsSql(EQL_SCHEMA_NAME) - -/** The v3 (`eql_v3`) Supabase grants block. See {@link supabasePermissionsSql}. */ -export const SUPABASE_PERMISSIONS_SQL_V3 = - supabasePermissionsSql(EQL_V3_SCHEMA_NAME) - /** * Get the directory of the current file, supporting both ESM and CJS. */ @@ -340,7 +338,7 @@ export class EQLInstaller { * * Supabase uses dedicated roles (anon, authenticated, service_role) that * don't own the schema, so explicit grants are required. Issues the - * {@link supabasePermissionsSql} block as a single multi-statement query — + * {@link supabaseGrantsFor} block as a single multi-statement query — * Postgres accepts that and it keeps the SQL identical to what we'd write * into a Supabase migration file. */ @@ -348,7 +346,7 @@ export class EQLInstaller { client: pg.Client, eqlVersion: EqlVersion, ): Promise { - await client.query(supabasePermissionsSql(schemaNameFor(eqlVersion))) + await client.query(supabaseGrantsFor(eqlVersion)) } /** diff --git a/packages/stack/__tests__/eql-v3-domain-registry.test.ts b/packages/stack/__tests__/eql-v3-domain-registry.test.ts new file mode 100644 index 000000000..8aee36350 --- /dev/null +++ b/packages/stack/__tests__/eql-v3-domain-registry.test.ts @@ -0,0 +1,82 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { types } from '@/eql/v3' +import { + DOMAIN_REGISTRY, + factoryForDomain, + stripDomainSchema, + type V3ColumnFactory, +} from '@/eql/v3/domain-registry' + +describe('DOMAIN_REGISTRY', () => { + it('strips the public. schema prefix', () => { + expect(stripDomainSchema('public.text_search')).toBe('text_search') + expect(stripDomainSchema('public.integer_ord')).toBe('integer_ord') + // idempotent for an already-unqualified name + expect(stripDomainSchema('boolean')).toBe('boolean') + }) + + it('has an entry for every types factory, keyed by the unqualified domain', () => { + const factories = Object.values(types) as V3ColumnFactory[] + for (const factory of factories) { + const eqlType = factory('probe').getEqlType() + const key = stripDomainSchema(eqlType) + expect( + DOMAIN_REGISTRY[key], + `missing registry entry for ${key}`, + ).toBeDefined() + expect(DOMAIN_REGISTRY[key]('c').getEqlType()).toBe(eqlType) + } + }) + + it('has no registry entry that does not round-trip to its own key', () => { + for (const [key, factory] of Object.entries(DOMAIN_REGISTRY)) { + expect(stripDomainSchema(factory('c').getEqlType())).toBe(key) + } + }) + + it('has exactly as many entries as there are types factories', () => { + expect(Object.keys(DOMAIN_REGISTRY)).toHaveLength(Object.keys(types).length) + }) + + it('returns undefined for an unknown domain', () => { + expect(factoryForDomain('not_a_domain')).toBeUndefined() + expect(factoryForDomain('text_search')).toBe(DOMAIN_REGISTRY.text_search) + }) + + it('PROPERTY: round-trips for any registry key and rejects any non-key', () => { + const keys = Object.keys(DOMAIN_REGISTRY) + // Any known key builds a column whose stripped eqlType is that key. + fc.assert( + fc.property(fc.constantFrom(...keys), (key) => { + expect(stripDomainSchema(DOMAIN_REGISTRY[key]('c').getEqlType())).toBe( + key, + ) + }), + ) + // Any arbitrary string that is not a registry key resolves to undefined. + const keySet = new Set(keys) + fc.assert( + fc.property(fc.string(), (s) => { + fc.pre(!keySet.has(s)) + expect(factoryForDomain(s)).toBeUndefined() + }), + ) + }) +}) + +describe('prototype keys are not domains', () => { + it.each([ + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty', + '__proto__', + ])('factoryForDomain(%s) is undefined', (key) => { + expect(factoryForDomain(key)).toBeUndefined() + }) + + it('the registry has a null prototype', () => { + expect(Object.getPrototypeOf(DOMAIN_REGISTRY)).toBeNull() + }) +}) diff --git a/packages/stack/__tests__/helpers/live-gate.ts b/packages/stack/__tests__/helpers/live-gate.ts index a674fbebb..854bfb4dc 100644 --- a/packages/stack/__tests__/helpers/live-gate.ts +++ b/packages/stack/__tests__/helpers/live-gate.ts @@ -37,3 +37,38 @@ export const LIVE_LOCK_CONTEXT_ENABLED = Boolean( export const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip export const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip + +/** + * True when only a Postgres `DATABASE_URL` is configured (no CipherStash creds + * needed — introspection reads the schema, it does not encrypt). + * + * Like the flags above, a false value turns its suites into `describe.skip`, + * which in CI would be a silent whole-suite skip on a green job. That hole is + * closed by `../live-coverage-guard.test.ts`, which asserts THIS flag in CI. + * Any new gate flag added here must be asserted there too. + */ +export const LIVE_PG_ENABLED = Boolean(process.env.DATABASE_URL) + +export const describeLivePgOnly = LIVE_PG_ENABLED ? describe : describe.skip + +/** + * True when a real PostgREST is reachable AND a `DATABASE_URL` is configured. + * + * The supabase v3 adapter talks to PostgREST, not to Postgres — the aliasing + * `prop:db_name::jsonb` casts, the `cs` containment mapping, the quoted + * envelopes inside `or=(…)`, and the full storage envelope every `public.*` + * domain CHECK must accept are all things only a real server can execute. + * Everything else in the repo asserts them as strings against a mock. + * + * NO CipherStash creds: the suite builds structurally-valid envelopes itself + * (the domain CHECKs are structural — `v`/`i`/`c` plus the domain's index + * terms), so it can run wherever the DB-only suites run. It proves the WIRE and + * the GRANTS. Cryptographic round-tripping is `drizzle-v3/operators-live-pg`'s + * job and needs the credentials. + */ +export const LIVE_SUPABASE_PGREST_ENABLED = + Boolean(process.env.PGRST_URL) && LIVE_PG_ENABLED + +export const describeLiveSupabasePgrest = LIVE_SUPABASE_PGREST_ENABLED + ? describe + : describe.skip diff --git a/packages/stack/__tests__/helpers/pgrest.ts b/packages/stack/__tests__/helpers/pgrest.ts new file mode 100644 index 000000000..bedcee766 --- /dev/null +++ b/packages/stack/__tests__/helpers/pgrest.ts @@ -0,0 +1,73 @@ +/** + * A real PostgREST in front of a real Postgres, for the live supabase v3 suite. + * + * `SupabaseClientLike` is just `{ from(table): … }` (`src/supabase/types.ts`), so + * a bare `PostgrestClient` satisfies the adapter with no anon key, no JWT, and + * none of the Supabase auth stack. What it does give us is the one thing the + * mock cannot: PostgREST actually parsing `prop:db_name::jsonb` aliasing selects + * against a DOMAIN column, mapping `cs` to `@>`, re-parsing a double-quoted JSON + * envelope inside `or=(…)`, and coercing a full storage envelope through the + * `public.*` domain CHECK on every filter operand. + */ + +import { PostgrestClient } from '@supabase/postgrest-js' +import type postgres from 'postgres' +import type { SupabaseClientLike } from '@/supabase/types' + +export function makePostgrestClient(): SupabaseClientLike { + const url = process.env.PGRST_URL + if (!url) { + throw new Error( + 'PGRST_URL is not set — makePostgrestClient() must only be called behind `describeLiveSupabasePgrest`', + ) + } + return new PostgrestClient(url) as unknown as SupabaseClientLike +} + +/** + * Ask PostgREST to reload its schema cache, then WAIT until it really has. + * + * PostgREST caches the schema at boot, and this suite creates its table in + * `beforeAll`, long after that. Getting this wrong is subtle, so: + * + * DO NOT poll with a GET against the new table. PostgREST 12 self-heals a + * cache miss by scheduling a reload and retrying THAT request, so the GET + * starts succeeding while the cache reload is still in flight. A `POST` issued + * in the window between then 404s — observed exactly this, cold: the probe GET + * returned 200, the very next insert returned 404, and a later GET returned + * 200 again. Polling reads therefore proves nothing about writes. + * + * Poll the ROOT endpoint instead. Its OpenAPI output is rendered FROM the + * loaded schema cache, so a table appearing under `paths` means the reload has + * actually landed and every verb will see it. + * + * `NOTIFY` needs `PGRST_DB_CHANNEL_ENABLED=true`; the self-heal covers us if it + * is off, but slowly and only for reads — hence both. + */ +export async function reloadSchemaCache( + sql: postgres.Sql, + tableName: string, + { timeoutMs = 30_000, intervalMs = 100 } = {}, +): Promise { + await sql.unsafe(`NOTIFY pgrst, 'reload schema'`) + + const url = process.env.PGRST_URL + const deadline = Date.now() + timeoutMs + let lastStatus = 0 + + while (Date.now() < deadline) { + const response = await fetch(`${url}/`) + lastStatus = response.status + if (response.ok) { + const spec = (await response.json()) as { + paths?: Record + } + if (spec.paths && Object.hasOwn(spec.paths, `/${tableName}`)) return + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + + throw new Error( + `PostgREST never loaded table "${tableName}" into its schema cache within ${timeoutMs}ms (last root status: ${lastStatus}). Is PGRST_DB_CHANNEL_ENABLED=true, and does the anon role have SELECT on the table?`, + ) +} diff --git a/packages/stack/__tests__/helpers/supabase-mock.ts b/packages/stack/__tests__/helpers/supabase-mock.ts new file mode 100644 index 000000000..9c8acb474 --- /dev/null +++ b/packages/stack/__tests__/helpers/supabase-mock.ts @@ -0,0 +1,196 @@ +/** + * Test doubles for the Supabase query-builder suites. + * + * The builders only touch a narrow slice of the encryption client and the + * supabase client, so both are simulated: the encryption mock produces + * deterministic fake envelopes (carrying the plaintext in `pt` so the fake + * decrypt can undo them), and the supabase mock records every builder call. + * This pins the WIRE ENCODING each dialect produces — the part of the adapter + * that CI can verify without a live Supabase project. + * + * Extracted verbatim from `supabase-v3-builder.test.ts` so the 39-domain wire + * sweep (`supabase-v3-matrix.test.ts`) drives the adapter through the exact + * same doubles, and a change to either mock moves both suites together. + */ + +import type { EncryptionClient } from '@/encryption' + +export type FakeEnvelope = { + v: 2 + i: { t: string; c: string } + c: string + hm: string + pt: unknown +} + +export function fakeEnvelope(value: unknown, column: string): FakeEnvelope { + // `pt` is carried through `JSON.stringify` by the v3 filter path + // (`encryptCollectedTerms`), so it must be JSON-serializable. A real + // envelope only ever holds strings; normalize the two plaintext types that + // are not — `Date` and `bigint` — rather than letting the mock throw where + // the product would not. The `bigint` domains' samples are the only reason + // this matters. + const pt = + value instanceof Date + ? value.toISOString() + : typeof value === 'bigint' + ? value.toString() + : value + return { + v: 2, + i: { t: 'tbl', c: column }, + c: `ct:${String(pt)}`, + hm: `hm:${String(pt)}`, + pt, + } +} + +export function isFakeEnvelope(value: unknown): value is FakeEnvelope { + return ( + typeof value === 'object' && + value !== null && + 'pt' in value && + 'c' in value && + 'hm' in value + ) +} + +/** A chainable operation resolving to `{ data }`, like the real ones. */ +export function operation(data: T) { + const op = { + withLockContext: () => op, + audit: () => op, + then: ( + onfulfilled?: ((value: { data: T }) => unknown) | null, + onrejected?: ((reason: unknown) => unknown) | null, + ) => Promise.resolve({ data }).then(onfulfilled, onrejected), + } + return op +} + +type SchemaLike = { + build(): { columns: Record } + buildColumnKeyMap?(): Record +} + +export function createMockEncryptionClient() { + const encryptedProps = (table: SchemaLike): string[] => + table.buildColumnKeyMap + ? Object.keys(table.buildColumnKeyMap()) + : Object.keys(table.build().columns) + + const client = { + encrypt: (value: unknown, opts: { column: { getName(): string } }) => + operation(fakeEnvelope(value, opts.column.getName())), + + // v2 filter path: batch query terms as composite literals + encryptQuery: (terms: Array<{ value: unknown }>) => + operation(terms.map((t) => `("${String(t.value)}")`)), + + encryptModel: (model: Record, table: SchemaLike) => { + const props = encryptedProps(table) + const out: Record = { ...model } + for (const prop of props) { + if (out[prop] != null) out[prop] = fakeEnvelope(out[prop], prop) + } + return operation(out) + }, + + bulkEncryptModels: ( + models: Record[], + table: SchemaLike, + ) => { + const props = encryptedProps(table) + return operation( + models.map((model) => { + const out: Record = { ...model } + for (const prop of props) { + if (out[prop] != null) out[prop] = fakeEnvelope(out[prop], prop) + } + return out + }), + ) + }, + + decryptModel: (model: Record) => { + const out: Record = {} + for (const [key, value] of Object.entries(model)) { + out[key] = isFakeEnvelope(value) ? value.pt : value + } + return operation(out) + }, + + bulkDecryptModels: (models: Record[]) => + operation( + models.map((model) => { + const out: Record = {} + for (const [key, value] of Object.entries(model)) { + out[key] = isFakeEnvelope(value) ? value.pt : value + } + return out + }), + ), + } + + return client as unknown as EncryptionClient +} + +export type RecordedCall = { method: string; args: unknown[] } + +export function createMockSupabase(resultData: unknown = []) { + const calls: RecordedCall[] = [] + // biome-ignore lint/suspicious/noExplicitAny: test double for the supabase query builder + const qb: any = {} + const methods = [ + 'select', + 'insert', + 'update', + 'upsert', + 'delete', + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'like', + 'ilike', + 'contains', + 'is', + 'in', + 'filter', + 'not', + 'or', + 'match', + 'order', + 'limit', + 'range', + 'single', + 'maybeSingle', + 'csv', + 'abortSignal', + 'throwOnError', + ] + for (const method of methods) { + qb[method] = (...args: unknown[]) => { + calls.push({ method, args }) + return qb + } + } + qb.then = ( + onfulfilled?: ((value: unknown) => unknown) | null, + onrejected?: ((reason: unknown) => unknown) | null, + ) => + Promise.resolve({ + data: resultData, + error: null, + count: null, + status: 200, + statusText: 'OK', + }).then(onfulfilled, onrejected) + + const client = { from: (_table: string) => qb } + const callsFor = (method: string) => calls.filter((c) => c.method === method) + + return { client, calls, callsFor } +} diff --git a/packages/stack/__tests__/helpers/v3-envelope.ts b/packages/stack/__tests__/helpers/v3-envelope.ts new file mode 100644 index 000000000..db8169070 --- /dev/null +++ b/packages/stack/__tests__/helpers/v3-envelope.ts @@ -0,0 +1,90 @@ +/** + * Structurally-valid EQL v3 storage envelopes, built without a CipherStash client. + * + * The `public.*` domain CHECKs are purely STRUCTURAL — e.g. `public.text_search` + * requires an object with `v`/`i`/`c`/`hm`/`ob`/`bf`, a non-empty `ob` array, + * and `v = '3'`. Nothing in the CHECK is cryptographic. That lets the live + * PostgREST suite drive the real adapter against real domains and real + * `eql_v3` operators with no ZeroKMS round-trip, so it runs wherever + * `DATABASE_URL` runs. + * + * What this CAN prove: the wire encoding PostgREST accepts, that a full storage + * envelope clears the domain CHECK on every filter operand while a narrowed + * `encryptQuery` term does not, that `cs` resolves to bloom containment, and + * that the Supabase roles hold the grants those operators need. + * + * What it CANNOT prove: that ciphertext decrypts, or that ORE ordering is + * semantically correct. `drizzle-v3/operators-live-pg.test.ts` covers those with + * real credentials. + */ + +/** `hm` — the HMAC equality term. Any stable string; equality is by identity. */ +function hmacFor(value: string): string { + return `hm-${value}` +} + +/** + * `ob` — one ORE block-256 term. + * + * `eql_v3_internal.compare_ore_block_256_term` derives the block count from the + * ciphertext length and rejects anything else: the layout is + * `[N PRP bytes][N*16B left][16B hash key][N*32B right]`, so + * `octet_length = 49*N + 16`. N=1 → 65 bytes. A shorter term raises + * "Malformed ORE term: bytes" from inside the operator, which is a far more + * confusing failure than a CHECK violation. + * + * Comparisons between two such terms EXECUTE but carry no order semantics — a + * term only reliably compares equal to itself. Suites must not assert `<`/`>` + * outcomes on synthetic terms. + */ +const ORE_TERM_BYTES = 49 * 1 + 16 + +function oreTermFor(seed: number): string { + const byte = (seed % 251).toString(16).padStart(2, '0') + return byte.repeat(ORE_TERM_BYTES) +} + +/** `bf` — the bloom filter for match/`cs`. A set of token positions. */ +function bloomFor(tokens: number[]): number[] { + return [...new Set(tokens)].sort((a, b) => a - b) +} + +export type EnvelopeParts = { + table: string + column: string + /** Distinguishes ciphertexts; also seeds the ORE term. */ + seed: number + /** Present on equality-capable domains (`hm`). */ + hmac?: string + /** Present on order-capable domains (`ob`). */ + ore?: boolean + /** Present on match-capable domains (`bf`). */ + bloom?: number[] +} + +/** A full STORAGE envelope — what `encrypt()` returns and every filter operand + * must be. Includes `c`, which a narrowed `encryptQuery` term omits. */ +export function storageEnvelope(parts: EnvelopeParts): Record { + const envelope: Record = { + v: '3', + i: { t: parts.table, c: parts.column }, + c: `ct-${parts.column}-${parts.seed}`, + } + if (parts.hmac !== undefined) envelope.hm = hmacFor(parts.hmac) + if (parts.ore) envelope.ob = [oreTermFor(parts.seed)] + if (parts.bloom) envelope.bf = bloomFor(parts.bloom) + return envelope +} + +/** + * The narrowed query term the v2 dialect emits (`encryptQuery`): index terms + * but NO `c`. The v3 domain CHECK requires `c`, so Postgres rejects this with + * 23514 for EVERY domain — the reason `query-builder-v3.ts` sends full + * envelopes as filter operands. The live suite asserts exactly that. + */ +export function narrowedQueryTerm( + parts: EnvelopeParts, +): Record { + const { c: _dropped, ...rest } = storageEnvelope(parts) + return rest +} diff --git a/packages/stack/__tests__/live-coverage-guard.test.ts b/packages/stack/__tests__/live-coverage-guard.test.ts index 6b6005ba6..1d44370dd 100644 --- a/packages/stack/__tests__/live-coverage-guard.test.ts +++ b/packages/stack/__tests__/live-coverage-guard.test.ts @@ -56,6 +56,8 @@ import { LIVE_CIPHERSTASH_ENABLED, LIVE_EQL_V3_PG_ENABLED, LIVE_LOCK_CONTEXT_ENABLED, + LIVE_PG_ENABLED, + LIVE_SUPABASE_PGREST_ENABLED, } from './helpers/live-gate' // GitHub Actions always sets CI=true; treat any truthy CI as "must run live". @@ -94,18 +96,51 @@ describe('live-coverage guard', () => { // the identity / lock-context live suites soft-skip on a missing USER_JWT, so // once the secret lands this guard turns a silent whole-suite skip into a // loud failure (as the CS_*/DATABASE_URL guards already do). - it.skip( - 'CI must have USER_JWT so the lock-context live suites do not silently skip', + it.skip('CI must have USER_JWT so the lock-context live suites do not silently skip', () => { + expect( + LIVE_LOCK_CONTEXT_ENABLED, + 'CI must run the live lock-context / identity suites — ' + + '`LIVE_LOCK_CONTEXT_ENABLED` is false. This needs the CS_* creds AND ' + + 'a `USER_JWT`; the identity/lock-context suites (e.g. ' + + 'lock-context.test.ts, protect-ops.test.ts, ' + + 'operators-lock-context-live-pg.test.ts) SOFT-SKIP when USER_JWT is ' + + 'absent, so a missing/rotated USER_JWT lets them skip green with no ' + + 'signal — the exact failure mode this guard exists to prevent.', + ).toBe(true) + }) + + it.runIf(IN_CI)( + 'CI must have DATABASE_URL so the pg-only suites do not silently skip', + () => { + expect( + LIVE_PG_ENABLED, + 'CI must run the DB-only suites — `LIVE_PG_ENABLED` is false. This ' + + 'needs only `DATABASE_URL` (no CS_* creds: introspection reads the ' + + 'schema, it does not encrypt), and `.github/workflows/tests.yml` ' + + 'writes it as a literal into packages/stack/.env alongside a Postgres ' + + 'service — so a false value here is a broken workflow, never a valid ' + + 'configuration. It makes every `describeLivePgOnly` suite (e.g. ' + + 'supabase-v3-introspect-pg) silently skip while CI stays green.', + ).toBe(true) + }, + ) + + it.runIf(IN_CI)( + 'CI must have PGRST_URL so the live supabase PostgREST suite does not silently skip', () => { expect( - LIVE_LOCK_CONTEXT_ENABLED, - 'CI must run the live lock-context / identity suites — ' + - '`LIVE_LOCK_CONTEXT_ENABLED` is false. This needs the CS_* creds AND ' + - 'a `USER_JWT`; the identity/lock-context suites (e.g. ' + - 'lock-context.test.ts, protect-ops.test.ts, ' + - 'operators-lock-context-live-pg.test.ts) SOFT-SKIP when USER_JWT is ' + - 'absent, so a missing/rotated USER_JWT lets them skip green with no ' + - 'signal — the exact failure mode this guard exists to prevent.', + LIVE_SUPABASE_PGREST_ENABLED, + 'CI must run the live supabase PostgREST suite — ' + + '`LIVE_SUPABASE_PGREST_ENABLED` is false. This needs a ' + + '`DATABASE_URL` AND a `PGRST_URL` pointing at the pinned ' + + '`postgrest/postgrest` service in .github/workflows/tests.yml (no ' + + 'CS_* creds — the domain CHECKs are structural). It is ' + + 'the ONLY suite that executes the adapter against a real PostgREST — ' + + 'the `prop:db_name::jsonb` aliasing selects, the `cs` containment ' + + 'mapping, and the full-envelope filter operands that every `public.*` ' + + 'domain CHECK must accept. Everything else asserts those as strings ' + + 'against a mock, so a false value here means the wire encoding is ' + + 'unproven while CI stays green.', ).toBe(true) }, ) diff --git a/packages/stack/__tests__/schema-v3.test.ts b/packages/stack/__tests__/schema-v3.test.ts index c5620407e..e6e4dd91b 100644 --- a/packages/stack/__tests__/schema-v3.test.ts +++ b/packages/stack/__tests__/schema-v3.test.ts @@ -160,7 +160,11 @@ describe('eql_v3 encryptedTable', () => { }) const built = users.build() expect(built.tableName).toBe('users') - expect(built.columns).toStrictEqual({ + // Spread to a plain object: `columns` is null-prototype (a DB column named + // `__proto__` must land as an own key), and `toStrictEqual` compares + // prototypes. The contract under test is the column configs, not the + // prototype — which `supabase-schema-builder.test.ts` pins directly. + expect({ ...built.columns }).toStrictEqual({ email: { cast_as: 'string', indexes: { diff --git a/packages/stack/__tests__/supabase-helpers.test.ts b/packages/stack/__tests__/supabase-helpers.test.ts new file mode 100644 index 000000000..e7ce2604b --- /dev/null +++ b/packages/stack/__tests__/supabase-helpers.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest' +import { + addJsonbCastsV3, + parseOrString, + rebuildOrString, +} from '@/supabase/helpers' +import type { DbPendingOrCondition } from '@/supabase/types' + +// `createdAt` is a renamed property (DB column `created_at`); `email` is a +// property whose name already equals its DB column. +const propToDb = { createdAt: 'created_at', email: 'email' } + +describe('addJsonbCastsV3', () => { + it('aliases a renamed property to its DB name', () => { + expect(addJsonbCastsV3('createdAt', propToDb)).toBe( + 'createdAt:created_at::jsonb', + ) + }) + + it('casts a property whose name equals its DB name in place', () => { + expect(addJsonbCastsV3('email', propToDb)).toBe('email::jsonb') + }) + + it('casts a raw DB name in place, without aliasing', () => { + expect(addJsonbCastsV3('created_at', propToDb)).toBe('created_at::jsonb') + }) + + it('resolves an already-aliased token whose name is a property', () => { + expect(addJsonbCastsV3('e:createdAt', propToDb)).toBe('e:created_at::jsonb') + }) + + it('resolves an already-aliased token whose name is a raw DB name', () => { + expect(addJsonbCastsV3('e:created_at', propToDb)).toBe( + 'e:created_at::jsonb', + ) + }) + + it('leaves an aliased token naming an unknown column untouched', () => { + expect(addJsonbCastsV3('e:other', propToDb)).toBe('e:other') + }) + + it('leaves already-cast tokens untouched', () => { + expect(addJsonbCastsV3('email::text', propToDb)).toBe('email::text') + }) + + it('leaves function calls untouched', () => { + expect(addJsonbCastsV3('count(email)', propToDb)).toBe('count(email)') + }) + + it('leaves foreign-table (dotted) tokens untouched', () => { + expect(addJsonbCastsV3('t.email', propToDb)).toBe('t.email') + }) + + // `lookupDbName`'s `Object.hasOwn` guard. Without it an inherited + // `Object.prototype` member resolves truthy and gets interpolated into the + // emitted select string (e.g. `function Object() { … }::jsonb`). + it('does not resolve a bare Object.prototype key as a property', () => { + expect(addJsonbCastsV3('constructor', propToDb)).toBe('constructor') + }) + + it('does not resolve an Object.prototype key inside an alias', () => { + expect(addJsonbCastsV3('a:toString', propToDb)).toBe('a:toString') + }) + + // Pins the leading-whitespace capture: drop it and ` email` loses its space. + it('maps each token of a multi-column select independently', () => { + expect(addJsonbCastsV3('id, email, createdAt', propToDb)).toBe( + 'id, email::jsonb, createdAt:created_at::jsonb', + ) + }) +}) + +// --------------------------------------------------------------------------- +// .or() operand quoting +// +// Every v3 encrypted operand is `JSON.stringify(envelope)` — dense with double +// quotes and commas. `formatOrValue` wraps a comma-bearing value in quotes but +// never escapes the quotes already inside it, so PostgREST terminates the value +// at the first inner `"`. Pre-existing in v2 (its composite literal also +// carries quotes); v3 makes it certain to fire. +// --------------------------------------------------------------------------- + +const ENVELOPE = '{"v":1,"i":{"t":"users","c":"email"},"c":"ct:abc"}' + +/** `rebuildOrString` takes DB-space conditions; `column` is a branded `DbName`. */ +function cond(column: string, op: string, value: unknown, negate?: boolean) { + return { column, op, value, negate } as unknown as DbPendingOrCondition +} + +describe('rebuildOrString quoting', () => { + it('escapes the double quotes inside a quoted operand', () => { + const out = rebuildOrString([cond('email', 'eq', ENVELOPE)]) + // The operand must be one quoted token whose inner quotes are escaped. + expect(out).toBe( + `email.eq."{\\"v\\":1,\\"i\\":{\\"t\\":\\"users\\",\\"c\\":\\"email\\"},\\"c\\":\\"ct:abc\\"}"`, + ) + }) + + it('escapes a backslash before escaping quotes', () => { + expect(rebuildOrString([cond('a', 'eq', 'x\\y,z')])).toBe('a.eq."x\\\\y,z"') + }) + + it('quotes a value containing a bare double quote even without a comma', () => { + expect(rebuildOrString([cond('a', 'eq', 'he"llo')])).toBe('a.eq."he\\"llo"') + }) + + it('leaves a value with no reserved characters unquoted', () => { + expect(rebuildOrString([cond('a', 'eq', 'plain')])).toBe('a.eq.plain') + }) +}) + +describe('parseOrString / rebuildOrString round-trip', () => { + it('round-trips an encrypted JSON envelope operand', () => { + const conditions = [ + { column: 'email', op: 'eq', negate: false, value: ENVELOPE }, + ] + expect( + parseOrString( + rebuildOrString(conditions.map((c) => cond(c.column, c.op, c.value))), + ), + ).toEqual(conditions) + }) + + it('round-trips a value carrying backslashes and quotes', () => { + const conditions = [ + { column: 'a', op: 'eq', negate: false, value: 'x\\"y,z' }, + ] + expect( + parseOrString( + rebuildOrString(conditions.map((c) => cond(c.column, c.op, c.value))), + ), + ).toEqual(conditions) + }) + + it('does not split on a comma inside a quoted operand', () => { + const s = rebuildOrString([ + cond('email', 'eq', ENVELOPE), + cond('id', 'eq', '7'), + ]) + const parsed = parseOrString(s) + expect(parsed).toHaveLength(2) + expect(parsed[0].value).toBe(ENVELOPE) + expect(parsed[1].value).toBe('7') + }) +}) + +// --------------------------------------------------------------------------- +// PostgREST negation inside .or() +// +// The parser split on the first two dots, so `col.not.in.(a,b)` yielded +// `{ op: 'not', value: 'in.(a,b)' }`. On a plaintext column that round-tripped +// by accident (rebuild re-joins the pieces verbatim). On an ENCRYPTED column the +// literal string `in.(a,b)` was encrypted as one plaintext, producing a filter +// that silently matched nothing. +// --------------------------------------------------------------------------- + +describe('parseOrString negation', () => { + it('lifts a not. prefix off the operator', () => { + expect(parseOrString('nickname.not.eq.ada')).toEqual([ + { column: 'nickname', op: 'eq', negate: true, value: 'ada' }, + ]) + }) + + it('parses a negated in-list as a real list, not a literal string', () => { + expect(parseOrString('nickname.not.in.(ada,grace)')).toEqual([ + { column: 'nickname', op: 'in', negate: true, value: ['ada', 'grace'] }, + ]) + }) + + it('parses not.is.null', () => { + expect(parseOrString('email.not.is.null')).toEqual([ + { column: 'email', op: 'is', negate: true, value: null }, + ]) + }) + + it('leaves a non-negated condition unnegated', () => { + expect(parseOrString('nickname.in.(ada,grace)')).toEqual([ + { column: 'nickname', op: 'in', negate: false, value: ['ada', 'grace'] }, + ]) + }) + + it('does not mistake a column or value named "not" for the prefix', () => { + expect(parseOrString('not.eq.ada')).toEqual([ + { column: 'not', op: 'eq', negate: false, value: 'ada' }, + ]) + expect(parseOrString('nickname.eq.not')).toEqual([ + { column: 'nickname', op: 'eq', negate: false, value: 'not' }, + ]) + }) + + it('does not swallow a condition whose not. prefix has no operator', () => { + // `col.not.` is malformed PostgREST. Consuming the prefix would leave + // no operator, and the condition would be silently DROPPED from the or-string + // — quietly widening the result set. Pass it through so PostgREST rejects it. + expect(parseOrString('nickname.not.ada')).toEqual([ + { column: 'nickname', op: 'not', negate: false, value: 'ada' }, + ]) + expect( + rebuildOrString( + parseOrString('nickname.not.ada') as DbPendingOrCondition[], + ), + ).toBe('nickname.not.ada') + }) +}) + +describe('rebuildOrString negation', () => { + it('re-emits the not. prefix', () => { + expect(rebuildOrString([cond('nickname', 'eq', 'ada', true)])).toBe( + 'nickname.not.eq.ada', + ) + }) + + it('round-trips a negated in-list through parse → rebuild', () => { + const parsed = parseOrString('nickname.not.in.(ada,grace)') + expect(rebuildOrString(parsed as DbPendingOrCondition[])).toBe( + 'nickname.not.in.(ada,grace)', + ) + }) + + it('omits the prefix when negate is false or absent', () => { + expect(rebuildOrString([cond('nickname', 'eq', 'ada', false)])).toBe( + 'nickname.eq.ada', + ) + expect(rebuildOrString([cond('nickname', 'eq', 'ada')])).toBe( + 'nickname.eq.ada', + ) + }) +}) diff --git a/packages/stack/__tests__/supabase-introspect.test.ts b/packages/stack/__tests__/supabase-introspect.test.ts new file mode 100644 index 000000000..a39bb60ac --- /dev/null +++ b/packages/stack/__tests__/supabase-introspect.test.ts @@ -0,0 +1,216 @@ +import fc from 'fast-check' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { groupIntrospectionRows, loadPg } from '@/supabase/introspect' + +describe('groupIntrospectionRows', () => { + it('groups rows by table, preserving row order as column order', () => { + const result = groupIntrospectionRows([ + { table_name: 'users', column_name: 'id', domain_name: null }, + { table_name: 'users', column_name: 'email', domain_name: 'text_search' }, + { table_name: 'users', column_name: 'note', domain_name: null }, + { table_name: 'orders', column_name: 'id', domain_name: null }, + { + table_name: 'orders', + column_name: 'total', + domain_name: 'integer_ord', + }, + ]) + + expect(result).toEqual([ + { + tableName: 'users', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'email', domainName: 'text_search' }, + { columnName: 'note', domainName: null }, + ], + }, + { + tableName: 'orders', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'total', domainName: 'integer_ord' }, + ], + }, + ]) + }) + + it('returns an empty array for no rows', () => { + expect(groupIntrospectionRows([])).toEqual([]) + }) + + it('PROPERTY: preserves total column count, first-seen table order, and domains', () => { + const rowArb = fc.record({ + table_name: fc.constantFrom('a', 'b', 'c'), + column_name: fc.string(), + domain_name: fc.option(fc.string(), { nil: null }), + }) + fc.assert( + fc.property(fc.array(rowArb), (rows) => { + const grouped = groupIntrospectionRows(rows) + // (1) Column count is preserved. + const total = grouped.reduce((n, t) => n + t.columns.length, 0) + expect(total).toBe(rows.length) + // (2) Table order is first-seen order of the input. + const firstSeen: string[] = [] + for (const r of rows) { + if (!firstSeen.includes(r.table_name)) firstSeen.push(r.table_name) + } + expect(grouped.map((t) => t.tableName)).toEqual(firstSeen) + // (3) Per-table column names+domains match the input rows in order. + for (const t of grouped) { + const expected = rows + .filter((r) => r.table_name === t.tableName) + .map((r) => ({ + columnName: r.column_name, + domainName: r.domain_name, + })) + expect(t.columns).toEqual(expected) + } + }), + ) + }) +}) + +describe('introspect happy path', () => { + afterEach(() => vi.resetModules()) + + it('issues both queries, builds the domains, and closes the connection', async () => { + const end = vi.fn(() => Promise.resolve()) + const queries: Array<{ sql: string; params?: unknown[] }> = [] + + vi.doMock('pg', () => { + class Client { + connect() { + return Promise.resolve() + } + end = end + query(sql: string, params?: unknown[]) { + queries.push({ sql, params }) + // The unmodelled query is the parameterised one. + return Promise.resolve({ + rows: params + ? [ + { + table_name: 'users', + column_name: 'legacy', + domain_name: 'unsupported_domain', + }, + ] + : [ + { table_name: 'users', column_name: 'id', domain_name: null }, + { + table_name: 'users', + column_name: 'email', + domain_name: 'text_search', + }, + ], + }) + } + } + return { default: { Client } } + }) + + const { introspect } = await import('@/supabase/introspect') + const { tables, unmodelled } = await introspect('postgres://ok') + + expect(queries).toHaveLength(2) + // The registry IS the query parameter — it must be pushed into Postgres, + // not re-derived client-side. + const parameterised = queries.find((q) => q.params)! + expect(parameterised.params?.[0]).toContain('text_search') + + expect(tables).toEqual([ + { + tableName: 'users', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'email', domainName: 'text_search' }, + ], + }, + ]) + expect(unmodelled.get('users')).toEqual([ + { columnName: 'legacy', domainName: 'unsupported_domain' }, + ]) + // A leaked connection is invisible to every other assertion here. + expect(end).toHaveBeenCalledTimes(1) + + vi.doUnmock('pg') + }) + + it('closes the connection when a query throws', async () => { + const end = vi.fn(() => Promise.resolve()) + + vi.doMock('pg', () => { + class Client { + connect() { + return Promise.resolve() + } + end = end + query() { + return Promise.reject(new Error('relation does not exist')) + } + } + return { default: { Client } } + }) + + const { introspect } = await import('@/supabase/introspect') + await expect(introspect('postgres://ok')).rejects.toThrow( + 'relation does not exist', + ) + expect(end).toHaveBeenCalledTimes(1) + + vi.doUnmock('pg') + }) +}) + +describe('introspect connection error handling', () => { + afterEach(() => vi.resetModules()) + + it('surfaces the connect error, not a failing end()', async () => { + vi.doMock('pg', () => { + class Client { + connect() { + return Promise.reject(new Error('ECONNREFUSED')) + } + end() { + // A throwing end() must NOT replace the connect error. + return Promise.reject(new Error('end failed')) + } + query() { + return Promise.resolve({ rows: [] }) + } + } + return { default: { Client } } + }) + + const { introspect } = await import('@/supabase/introspect') + await expect(introspect('postgres://unreachable')).rejects.toThrow( + 'ECONNREFUSED', + ) + vi.doUnmock('pg') + }) +}) + +describe('loadPg', () => { + const failingImport = (err: unknown) => () => Promise.reject(err) + + for (const code of ['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND']) { + it(`remaps a missing optional \`pg\` peer (${code}) to an install message`, async () => { + const err = Object.assign(new Error("Cannot find package 'pg'"), { code }) + + await expect(loadPg(failingImport(err))).rejects.toThrow( + /'pg' is not installed/, + ) + await expect(loadPg(failingImport(err))).rejects.toHaveProperty( + 'cause', + err, + ) + }) + } + + it('does not swallow an unrelated module-load failure', async () => { + const err = new Error('boom: pg self-check failed') + await expect(loadPg(failingImport(err))).rejects.toBe(err) + }) +}) diff --git a/packages/stack/__tests__/supabase-schema-builder.test.ts b/packages/stack/__tests__/supabase-schema-builder.test.ts new file mode 100644 index 000000000..f9d336b53 --- /dev/null +++ b/packages/stack/__tests__/supabase-schema-builder.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import type { IntrospectionResult } from '@/supabase/introspect' +import { groupUnmodelledRows } from '@/supabase/introspect' +import { + mergeDeclaredTables, + synthesizeTables, +} from '@/supabase/schema-builder' + +const introspection: IntrospectionResult = [ + { + tableName: 'users', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'email', domainName: 'text_search' }, + { columnName: 'amount', domainName: 'integer_ord' }, + { columnName: 'note', domainName: null }, + { columnName: 'weird', domainName: 'not_a_domain' }, // unknown → plaintext + ], + }, +] + +describe('synthesizeTables', () => { + it('builds an EncryptedTable with only the recognised domain columns', () => { + const { tables } = synthesizeTables(introspection) + const users = tables.get('users') + expect(users).toBeDefined() + expect(Object.keys(users!.columnBuilders).sort()).toEqual([ + 'amount', + 'email', + ]) + }) + + it('records the FULL column list (encrypted + plaintext) for select(*)', () => { + const { allColumns } = synthesizeTables(introspection) + expect(allColumns.get('users')).toEqual([ + 'id', + 'email', + 'amount', + 'note', + 'weird', + ]) + }) + + it('synthesizes a domain column byte-identically to a declared column', () => { + const { tables } = synthesizeTables(introspection) + const synthesized = tables.get('users')!.build() + const declared = encryptedTable('users', { + email: types.TextSearch('email'), + amount: types.IntegerOrd('amount'), + }).build() + expect(synthesized.columns.email).toEqual(declared.columns.email) + expect(synthesized.columns.amount).toEqual(declared.columns.amount) + }) + + it('treats an Object.prototype-named domain as plaintext, not a domain', () => { + // `DOMAIN_REGISTRY['constructor']` would be truthy on a plain object; the + // null prototype + Object.hasOwn guard keep this column plaintext. + const prototypeNamed: IntrospectionResult = [ + { + tableName: 'weird', + columns: [ + { columnName: 'a', domainName: 'constructor' }, + { columnName: 'b', domainName: '__proto__' }, + { columnName: 'c', domainName: 'toString' }, + ], + }, + ] + const { tables, allColumns } = synthesizeTables(prototypeNamed) + expect(Object.keys(tables.get('weird')!.columnBuilders)).toEqual([]) + expect(allColumns.get('weird')).toEqual(['a', 'b', 'c']) + }) + + // A DB column may legally be named `__proto__`; `encryptedTable()` rejects + // such names as JS properties, but nothing constrains the database. On a plain + // object literal, `builders['__proto__'] = builder` reparents the object + // instead of adding an own key, so the column disappears — it would then be + // treated as a plaintext passthrough and its ciphertext returned undecrypted + // (`decryptModel` skips columns absent from the encrypt config). + const protoColumn: IntrospectionResult = [ + { + tableName: 'users', + columns: [ + { columnName: '__proto__', domainName: 'text_search' }, + { columnName: 'email', domainName: 'text_search' }, + ], + }, + ] + + it('keeps a column literally named __proto__ as an own builder key', () => { + const { tables } = synthesizeTables(protoColumn) + const builders = tables.get('users')!.columnBuilders + + expect(Object.hasOwn(builders, '__proto__')).toBe(true) + expect(Object.keys(builders).sort()).toEqual(['__proto__', 'email']) + }) + + it('registers a __proto__ column in the encrypt config, not on the prototype', () => { + const { columns } = synthesizeTables(protoColumn) + .tables.get('users')! + .build() + + // The load-bearing assertion: absent from the config, the column is a + // plaintext passthrough and reads return raw ciphertext. + expect(Object.hasOwn(columns, '__proto__')).toBe(true) + expect(Object.keys(columns).sort()).toEqual(['__proto__', 'email']) + }) +}) + +describe('mergeDeclaredTables', () => { + it('keeps the declared builder instance over the synthesized one', () => { + const synth = synthesizeTables(introspection) + const declaredTable = encryptedTable('users', { + email: types.TextSearch('email'), + }) + const merged = mergeDeclaredTables(synth, { users: declaredTable }) + const mergedTable = merged.tables.get('users')! + + // A declared column and its synthesized twin build byte-identically (see + // above), so instance identity is the only observable proof that the + // declared builder is the one that survived the merge. + expect(mergedTable.columnBuilders.email).toBe( + declaredTable.columnBuilders.email, + ) + expect(mergedTable.build().columns.amount).toBeDefined() + expect(merged.allColumns.get('users')).toEqual( + synth.allColumns.get('users'), + ) + }) + + it('preserves a declared property→DB-name rename', () => { + const renamed: IntrospectionResult = [ + { + tableName: 'events', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'created_on', domainName: 'timestamp_ord' }, + ], + }, + ] + const synth = synthesizeTables(renamed) + const declaredTable = encryptedTable('events', { + createdAt: types.TimestampOrd('created_on'), + }) + const merged = mergeDeclaredTables(synth, { events: declaredTable }) + const table = merged.tables.get('events')! + expect(table.buildColumnKeyMap()).toEqual({ createdAt: 'created_on' }) + expect(Object.keys(table.columnBuilders)).toEqual(['createdAt']) + }) + + it('merges a declared table absent from introspection as declared-only', () => { + // Unreachable through `encryptedSupabaseV3` — `verifyDeclaredSchemas` throws + // on an absent table before the merge runs — but `mergeDeclaredTables` is + // exported, so the `if (synthesized)` false arm is reachable by any other + // caller and must not read through to a stale table or throw. + const synth = synthesizeTables(introspection) + const declaredTable = encryptedTable('orders', { + total: types.IntegerOrd('total'), + }) + const merged = mergeDeclaredTables(synth, { orders: declaredTable }) + + expect(Object.keys(merged.tables.get('orders')!.columnBuilders)).toEqual([ + 'total', + ]) + // The absent table contributes no `allColumns`, so `select('*')` on it + // still throws rather than silently selecting nothing. + expect(merged.allColumns.get('orders')).toBeUndefined() + // The introspected table is untouched. + expect( + Object.keys(merged.tables.get('users')!.columnBuilders).sort(), + ).toEqual(['amount', 'email']) + }) +}) + +// The three-way classification (plaintext / modelled / unmodelled) moved into +// `UNMODELLED_COLUMNS_QUERY`'s predicate, so it is proven against live Postgres +// in `supabase-v3-introspect-pg.test.ts`, not here. What remains client-side is +// the grouping — and, load-bearing, the fact that `synthesizeTables` treats an +// unmodelled column as plaintext (covered above): that is exactly why the +// `from()` guard must be unconditional. +describe('groupUnmodelledRows', () => { + it('groups rows by table name, preserving row order', () => { + expect( + groupUnmodelledRows([ + { + table_name: 'metrics', + column_name: 'score', + domain_name: 'integer_ord_ope', + }, + { table_name: 'audit', column_name: 'payload', domain_name: 'json' }, + { + table_name: 'metrics', + column_name: 'bucket', + domain_name: 'text_ord_ope', + }, + ]), + ).toEqual( + new Map([ + [ + 'metrics', + [ + { columnName: 'score', domainName: 'integer_ord_ope' }, + { columnName: 'bucket', domainName: 'text_ord_ope' }, + ], + ], + ['audit', [{ columnName: 'payload', domainName: 'json' }]], + ]), + ) + }) + + it('returns an empty map when nothing is unmodelled', () => { + expect(groupUnmodelledRows([]).size).toBe(0) + }) +}) diff --git a/packages/stack/__tests__/supabase-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts new file mode 100644 index 000000000..dc784bace --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -0,0 +1,1467 @@ +import { describe, expect, it, vi } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' +import { encryptedSupabase } from '@/supabase' +import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3' +import { + createMockEncryptionClient, + createMockSupabase, + fakeEnvelope, + isFakeEnvelope, +} from './helpers/supabase-mock' + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + nickname: types.TextEq('nickname'), + amount: types.IntegerOrd('amount'), + createdAt: types.TimestampOrd('created_at'), + active: types.Boolean('active'), + // MATCH_ONLY: freeTextSearch WITHOUT equality. The only fixture column that + // can distinguish a correct op→queryType mapping from the `equality` default. + bio: types.TextMatch('bio'), +}) + +const usersV2 = encryptedTableV2('users', { + email: encryptedColumn('email').freeTextSearch().equality(), + age: encryptedColumn('age').dataType('number').equality().orderAndRange(), +}) + +// DB column names as introspection would report them (id/note are plaintext). +const USERS_ALL_COLUMNS = [ + 'id', + 'email', + 'nickname', + 'amount', + 'created_at', + 'active', + 'bio', + 'note', +] + +// `encryptedSupabaseV3` is now an async, DB-introspecting factory, so the wire +// tests construct the v3 builder directly. The declared `users` table is kept +// (not a synthesized one) because the `createdAt:created_at::jsonb` assertions +// are inherently about a property→DB rename that a synthesized table — where +// property == DB name — cannot express. `supabase-schema-builder.test.ts` +// proves synthesized ≡ declared byte-for-byte. +function v3Instance(resultData: unknown = []) { + const supabase = createMockSupabase(resultData) + const encryptionClient = createMockEncryptionClient() + const es = { + from(tableName: string, table: typeof users) { + return new EncryptedQueryBuilderV3Impl( + tableName, + table, + encryptionClient, + supabase.client, + USERS_ALL_COLUMNS, + ) + }, + } + return { es, supabase } +} + +/** + * Extract the operand from an emitted `.or()` token and undo PostgREST's + * quoting, exactly as PostgREST does server-side. + * + * Asserts the inner quotes ARE escaped before unescaping: an unescaped operand + * would be truncated at its first `"` by PostgREST, so a test that merely + * `JSON.parse`d the raw slice would pass on a filter the database rejects. + */ +function orOperand(emitted: string, prefix: string): string { + const quoted = emitted.slice(prefix.length) + expect(quoted.startsWith('"') && quoted.endsWith('"')).toBe(true) + const inner = quoted.slice(1, -1) + expect(inner).toContain('\\"') + return inner.replace(/\\(.)/g, '$1') +} + +// --------------------------------------------------------------------------- +// v3 dialect +// --------------------------------------------------------------------------- + +describe('encryptedSupabaseV3 wire encoding', () => { + it('inserts the raw encrypted payload keyed by DB column name (no composite wrap)', async () => { + const { es, supabase } = v3Instance() + + const createdAt = new Date('2026-01-02T03:04:05.000Z') + await es + .from('users', users) + .insert({ email: 'a@b.com', createdAt, note: 'plain' }) + + const [insert] = supabase.callsFor('insert') + expect(insert).toBeDefined() + const body = insert.args[0] as Record + + // Property createdAt lands in DB column created_at + expect(Object.keys(body).sort()).toEqual(['created_at', 'email', 'note']) + // Raw envelope — NOT v2's `{ data: ... }` composite wrap + expect(isFakeEnvelope(body.email)).toBe(true) + expect((body.email as Record).data).toBeUndefined() + expect(isFakeEnvelope(body.created_at)).toBe(true) + expect(body.note).toBe('plain') + }) + + it('bulk-inserts raw encrypted payloads keyed by DB column name', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .insert([{ email: 'a@b.com' }, { email: 'b@c.com' }]) + + const [insert] = supabase.callsFor('insert') + const body = insert.args[0] as Record[] + expect(body).toHaveLength(2) + expect(isFakeEnvelope(body[0].email)).toBe(true) + expect(isFakeEnvelope(body[1].email)).toBe(true) + }) + + it('adds ::jsonb casts and aliases property names to DB names in select', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id, email, createdAt') + + const [select] = supabase.callsFor('select') + expect(select.args[0]).toBe('id, email::jsonb, createdAt:created_at::jsonb') + }) + + it('encrypts equality operands as full-envelope jsonb text', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id, email').eq('email', 'a@b.com') + + const eqCalls = supabase.callsFor('eq') + expect(eqCalls).toHaveLength(1) + const [column, term] = eqCalls[0].args + expect(column).toBe('email') + // The operand must be the FULL storage envelope (v/i/c + index terms) so + // it satisfies the public.* domain CHECK when Postgres coerces it. + const parsed = JSON.parse(term as string) + expect(parsed.c).toBeDefined() + expect(parsed.i).toBeDefined() + expect(parsed.hm).toBeDefined() + }) + + it('passes non-encrypted filters through untouched', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id, email').eq('id', 42) + + const eqCalls = supabase.callsFor('eq') + expect(eqCalls[0].args).toEqual(['id', 42]) + }) + + it('maps property names to DB names in range filters', async () => { + const { es, supabase } = v3Instance() + + const from = new Date('2026-01-01T00:00:00.000Z') + await es.from('users', users).select('id, createdAt').gte('createdAt', from) + + const [gte] = supabase.callsFor('gte') + expect(gte.args[0]).toBe('created_at') + expect(JSON.parse(gte.args[1] as string).c).toBeDefined() + }) + + it('emits encrypted contains as PostgREST cs (bloom-filter containment)', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id, email').contains('email', 'a@b') + + const filterCalls = supabase.callsFor('filter') + expect(filterCalls).toHaveLength(1) + expect(filterCalls[0].args[0]).toBe('email') + expect(filterCalls[0].args[1]).toBe('cs') + // Full storage envelope: eql_v3.contains coerces the operand to the storage + // domain, whose CHECK requires `c`. + expect(JSON.parse(filterCalls[0].args[2] as string).c).toBeDefined() + // postgrest-js's native contains() would re-serialize the operand. + expect(supabase.callsFor('contains')).toHaveLength(0) + }) + + it('refuses like/ilike on an encrypted column, naming contains', async () => { + const { es } = v3Instance() + + // The typed builder omits like/ilike, but an untyped JS caller reaches them. + expect(() => + es.from('users', users).select('id').like('email', '%a@b%'), + ).toThrow(/token containment.*Use contains\(\)/s) + expect(() => + es.from('users', users).select('id').ilike('email', '%a@b%'), + ).toThrow(/token containment.*Use contains\(\)/s) + }) + + it('passes contains through to native cs on a plaintext column', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').contains('note', 'plain') + + expect(supabase.callsFor('contains')[0].args).toEqual(['note', 'plain']) + expect(supabase.callsFor('filter')).toHaveLength(0) + }) + + it('keeps like on plain columns as like', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id, email').like('note', '%x%') + + expect(supabase.callsFor('like')).toHaveLength(1) + expect(supabase.callsFor('filter')).toHaveLength(0) + }) + + it('maps not(contains) on encrypted columns to not(cs)', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id, email') + .not('email', 'contains', 'a@b') + + const [not] = supabase.callsFor('not') + expect(not.args[0]).toBe('email') + expect(not.args[1]).toBe('cs') + }) + + it('encrypts each element of an in() filter', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id, nickname') + .in('nickname', ['ada', 'grace']) + + const [inCall] = supabase.callsFor('in') + expect(inCall.args[0]).toBe('nickname') + const values = inCall.args[1] as string[] + expect(values).toHaveLength(2) + expect(JSON.parse(values[0]).pt).toBe('ada') + expect(JSON.parse(values[1]).pt).toBe('grace') + }) + + it('maps match() keys to DB names and encrypts values', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id, nickname') + .match({ nickname: 'ada', id: 7 }) + + const [match] = supabase.callsFor('match') + const query = match.args[0] as Record + expect(JSON.parse(query.nickname as string).pt).toBe('ada') + expect(query.id).toBe(7) + }) + + it('rejects a query type the column does not support', async () => { + const { es } = v3Instance() + + // nickname is public.text_eq — equality only, no order/range + const { error, status } = await es + .from('users', users) + .select('id, nickname') + .gte('nickname', 'a') + + expect(status).toBe(500) + expect(error?.message).toContain('does not support orderAndRange') + }) + + it('rejects filters on storage-only columns', async () => { + const { es } = v3Instance() + + // active is public.boolean — storage only + const { error, status } = await es + .from('users', users) + .select('id') + // biome-ignore lint/suspicious/noExplicitAny: intentionally bypassing the type guard to prove the runtime guard + .eq('active' as any, true as any) + + expect(status).toBe(500) + expect(error?.message).toContain('does not support equality') + }) + + // Ordering ANY encrypted v3 column is rejected. The `*_ord` domains are + // `DOMAIN … AS jsonb` and the bundle declares no btree OPERATOR CLASS on them + // (it lints against one: `domain_opclass`), so `ORDER BY col` resolves through + // jsonb's default opclass — `jsonb_cmp` — and sorts by the envelope's byte + // structure, first key `bf`. Correct ordering needs `ORDER BY + // eql_v3.ord_term(col)`, which PostgREST's `order=` cannot express. + // + // The old guard only rejected columns LACKING orderAndRange, i.e. exactly the + // columns where the wrongness was obvious, and admitted the ORE-capable ones + // where it is silent. + it('rejects order() on an ORE-capable encrypted column', async () => { + const { es } = v3Instance() + + const { error, status } = await es + .from('users', users) + .select('id, createdAt') + .order('createdAt') + + expect(status).toBe(500) + expect(error?.message).toContain('cannot order by encrypted column') + expect(error?.message).toContain('created_at') + expect(error?.message).toContain('timestamp_ord') + expect(error?.message).toContain('ord_term') + }) + + it('rejects order() on an equality-only encrypted column', async () => { + const { es } = v3Instance() + + const { error, status } = await es + .from('users', users) + .select('id') + .order('amount') + + expect(status).toBe(500) + expect(error?.message).toContain('cannot order by encrypted column') + }) + + it('leaves plaintext columns untouched in order()', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id, note').order('note') + + const [order] = supabase.callsFor('order') + expect(order.args[0]).toBe('note') + }) + + it('rejects order() on a storage-only encrypted column', async () => { + const { es } = v3Instance() + + // active is public.boolean — storage only, so ordering it would sort ciphertext + const { error, status } = await es + .from('users', users) + .select('id') + .order('active') + + expect(status).toBe(500) + expect(error?.message).toContain('cannot order by encrypted column') + }) + + it('maps property names to DB names in the onConflict option', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .upsert({ email: 'a@b.com' }, { onConflict: 'createdAt' }) + + const [upsert] = supabase.callsFor('upsert') + expect((upsert.args[1] as { onConflict: string }).onConflict).toBe( + 'created_at', + ) + }) + + it('maps every column of a multi-column onConflict list', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .upsert({ email: 'a@b.com' }, { onConflict: 'createdAt,amount' }) + + const [upsert] = supabase.callsFor('upsert') + expect((upsert.args[1] as { onConflict: string }).onConflict).toBe( + 'created_at,amount', + ) + }) + + // `or()` had no v3 coverage at all. Any condition naming an encrypted column + // — under either its property or DB name — routes through + // `transformOrConditions`, which maps names; the verbatim branch is reached + // only when every condition names a plaintext column, which needs no mapping. + it('maps property names to DB names in an or() string', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').or('createdAt.gte.2026-01-01') + + const [or] = supabase.callsFor('or') + expect(or.args[0] as string).toMatch(/^created_at\.gte\./) + }) + + it('passes an all-plaintext or() string through verbatim', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').or('note.eq.x,id.eq.1') + + const [or] = supabase.callsFor('or') + expect(or.args[0]).toBe('note.eq.x,id.eq.1') + }) + + it('rebuilds a mixed encrypted/plaintext or() string, mapping only the encrypted column', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or('createdAt.gte.2026-01-01,note.eq.x') + + // The encrypted operand is a JSON envelope containing commas, so the + // conditions cannot be split on ','. Assert on the boundaries instead. + const [or] = supabase.callsFor('or') + const emitted = or.args[0] as string + expect(emitted).toMatch(/^created_at\.gte\./) + expect(emitted.endsWith(',note.eq.x')).toBe(true) + }) + + it('keeps every filter array correlated in a combined query', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id, email, createdAt') + .eq('email', 'a@b.com') + .not('nickname', 'eq', 'ada') + .or('createdAt.gte.2026-01-01,note.eq.x') + .match({ nickname: 'grace' }) + .order('note') + .limit(5) + + expect(supabase.callsFor('eq')[0].args[0]).toBe('email') + expect(supabase.callsFor('not')[0].args[0]).toBe('nickname') + expect(supabase.callsFor('or')[0].args[0] as string).toMatch( + /^created_at\./, + ) + expect( + (supabase.callsFor('match')[0].args[0] as Record) + .nickname, + ).toBeDefined() + // `order` can no longer carry an encrypted column, so the rename it used to + // exercise here is covered by the filter paths above. + expect(supabase.callsFor('order')[0].args[0]).toBe('note') + expect(supabase.callsFor('limit')[0].args[0]).toBe(5) + }) + + // Filter-capability errors are raised in `encryptFilterValues` (execute step + // 3); order-capability errors in `validateTransforms`, inside + // `buildAndExecuteQuery` (step 4). The filter must therefore win. Pins that + // precedence against a refactor that moves validation earlier. + it('reports the filter-capability error ahead of the order-capability error', async () => { + const { es } = v3Instance() + + const { error, status } = await es + .from('users', users) + .select('id') + .gte('nickname', 'a') // text_eq: no orderAndRange + .order('active') // boolean: storage only + + expect(status).toBe(500) + expect(error?.message).toContain('does not support orderAndRange') + expect(error?.message).not.toContain('does not support ordering') + }) + + // `is` is a SQL predicate (PostgREST accepts only null/true/false), never a + // data operand — and a null operand is SQL NULL, not a value to search for. + // Only the regular `.is()` filter skipped encryption; every other collector + // encrypted whatever it was handed. See the v2 block for the released-side + // regressions. + describe('is / null operands are never encrypted', () => { + it('does not encrypt a regular is() operand', async () => { + const { es, supabase } = v3Instance() + await es.from('users', users).select('id').is('createdAt', null) + expect(supabase.callsFor('is')[0].args).toEqual(['created_at', null]) + }) + + it('does not encrypt not(col, is, null)', async () => { + const { es, supabase } = v3Instance() + await es.from('users', users).select('id').not('createdAt', 'is', null) + expect(supabase.callsFor('not')[0].args).toEqual([ + 'created_at', + 'is', + null, + ]) + }) + + it('does not encrypt a raw filter() is operand', async () => { + const { es, supabase } = v3Instance() + await es.from('users', users).select('id').filter('createdAt', 'is', null) + expect(supabase.callsFor('filter')[0].args).toEqual([ + 'created_at', + 'is', + null, + ]) + }) + + it('does not encrypt a null match() value', async () => { + const { es, supabase } = v3Instance() + await es.from('users', users).select('id').match({ nickname: null }) + expect(supabase.callsFor('match')[0].args[0]).toEqual({ nickname: null }) + }) + + // The or-string verbatim branch keys on "was any VALUE encrypted". An `is` + // on an encrypted column encrypts nothing, so it would fall through to + // verbatim and forward the unmapped property name. It must rebuild whenever + // a condition REFERENCES an encrypted column. + it('maps the column name in an or() string whose only condition is an is', async () => { + const { es, supabase } = v3Instance() + await es.from('users', users).select('id').or('createdAt.is.null') + expect(supabase.callsFor('or')[0].args[0]).toBe('created_at.is.null') + }) + + it('maps names in a structured or() carrying an is', async () => { + const { es, supabase } = v3Instance() + await es + .from('users', users) + .select('id') + .or([{ column: 'createdAt', op: 'is', value: null }]) + expect(supabase.callsFor('or')[0].args[0]).toBe('created_at.is.null') + }) + + // `is` maps to the `equality` query type, so before the fix an `is` term + // reached the v3 capability gate and threw on a storage-only column. + it('does not raise a capability error for an is on a storage-only column', async () => { + const { es, supabase } = v3Instance() + const { status, error } = await es + .from('users', users) + .select('id') + .or('active.is.null') + + expect(error).toBeNull() + expect(status).toBe(200) + expect(supabase.callsFor('or')[0].args[0]).toBe('active.is.null') + }) + + it('encrypts the encrypted condition of a mixed or() but leaves the is alone', async () => { + const { es, supabase } = v3Instance() + await es + .from('users', users) + .select('id') + .or('nickname.eq.ada,createdAt.is.null') + + const emitted = supabase.callsFor('or')[0].args[0] as string + expect(emitted).toMatch(/^nickname\.eq\./) + expect(emitted.endsWith(',created_at.is.null')).toBe(true) + }) + }) + + it('reconstructs Date values from cast_as on decrypted rows', async () => { + const rows = [ + { + id: 1, + email: fakeEnvelope('a@b.com', 'email'), + createdAt: fakeEnvelope( + new Date('2026-01-02T03:04:05.000Z'), + 'created_at', + ), + }, + ] + const { es } = v3Instance(rows) + + const { data, error } = await es + .from('users', users) + .select('id, email, createdAt') + + expect(error).toBeNull() + expect(data).toHaveLength(1) + expect(data![0].email).toBe('a@b.com') + expect(data![0].createdAt).toBeInstanceOf(Date) + expect((data![0].createdAt as Date).toISOString()).toBe( + '2026-01-02T03:04:05.000Z', + ) + }) + + // `.or()` string conditions carry raw PostgREST operators, so free-text search + // arrives as `cs`. The three or() string tests above use `gte`/`eq`/`is`, so + // the freeTextSearch capability branch never ran. + describe('or() with encrypted pattern and structured conditions', () => { + it('encrypts an encrypted cs inside an or() string and keeps the operator', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').or('email.cs.ada') + + const emitted = supabase.callsFor('or')[0].args[0] as string + // The envelope is JSON (commas, braces), so `formatOrValue` quotes it. + expect(emitted).toMatch(/^email\.cs\."/) + expect(JSON.parse(orOperand(emitted, 'email.cs.')).c).toBeDefined() + }) + + it('gates an or() cs condition on the freeTextSearch capability', async () => { + const { es } = v3Instance() + + // amount is public.integer_ord — no match index. Before the query-type + // seam this was encrypted as an equality term and silently accepted. + const { error, status } = await es + .from('users', users) + .select('id') + .or('amount.cs.5') + + expect(status).toBe(500) + expect(error?.message).toContain('does not support freeTextSearch') + }) + + it('rejects an encrypted like inside an or() string', async () => { + const { es } = v3Instance() + + const { error, status } = await es + .from('users', users) + .select('id') + .or('email.like.ada') + + expect(status).toBe(500) + expect(error?.message).toContain('unsupported raw filter operator "like"') + }) + + // PostgREST spells negation `column.not..`. The parser split on + // the first two dots, so this parsed as `{ op: 'not', value: 'in.(ada,grace)' }` + // — the `in`-split never fired and the literal string `in.(ada,grace)` was + // encrypted as ONE plaintext. Silent: the filter matched nothing. + it('encrypts each element of a NEGATED in-list inside or()', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or('nickname.not.in.(ada,grace)') + + const emitted = supabase.callsFor('or')[0].args[0] as string + expect(emitted).toMatch(/^nickname\.not\.in\.\(/) + // Never the literal operand, and never one ciphertext for the whole list. + expect(emitted).not.toContain('in.(ada,grace)') + expect(emitted).not.toContain('"pt":["ada","grace"]') + + const operands = emitted + .slice('nickname.not.in.('.length, -1) + .split('","') + expect(operands).toHaveLength(2) + const pts = operands.map( + (o) => JSON.parse(o.replace(/^"|"$/g, '').replace(/\\(.)/g, '$1')).pt, + ) + expect(pts).toEqual(['ada', 'grace']) + }) + + it('preserves negation on a scalar encrypted or() condition', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').or('nickname.not.eq.ada') + + const emitted = supabase.callsFor('or')[0].args[0] as string + expect(emitted).toMatch(/^nickname\.not\.eq\."/) + expect(JSON.parse(orOperand(emitted, 'nickname.not.eq.')).pt).toBe('ada') + }) + + it('leaves a negated plaintext or() condition verbatim', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').or('note.not.in.(a,b)') + + expect(supabase.callsFor('or')[0].args[0]).toBe('note.not.in.(a,b)') + }) + + // The structured form's encrypted path (`query-builder.ts:1065`). The + // existing structured test uses `is`, which after `fd33aadf` encrypts + // nothing and so never populates `orStructuredConditionMap`. + it('encrypts the operand of a structured or() condition', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or([ + { + column: 'createdAt', + op: 'gte', + value: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + + const emitted = supabase.callsFor('or')[0].args[0] as string + expect(emitted).toMatch(/^created_at\.gte\."/) + expect(JSON.parse(orOperand(emitted, 'created_at.gte.')).c).toBeDefined() + }) + + // The regular filter path splits an `in` array and encrypts each element + // (query-builder.ts:533). The or() path had no such case: it pushed ONE + // term whose value was the whole array, so the `(a,b)` list form was lost + // and the filter could never match. Fails closed, silently. + it('encrypts each element of an in() list inside an or() string', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').or('nickname.in.(ada,grace)') + + const emitted = supabase.callsFor('or')[0].args[0] as string + expect(emitted).toMatch(/^nickname\.in\.\(/) + + // Two distinct encrypted operands, not one ciphertext of the array. + const plain = emitted.replace(/\\(.)/g, '$1') + expect(plain).toContain('"pt":"ada"') + expect(plain).toContain('"pt":"grace"') + expect(plain).not.toContain('"pt":["ada","grace"]') + }) + + it('encrypts each element of an in() list in a structured or()', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or([{ column: 'nickname', op: 'in', value: ['ada', 'grace'] }]) + + const emitted = supabase.callsFor('or')[0].args[0] as string + expect(emitted).toMatch(/^nickname\.in\.\(/) + const plain = emitted.replace(/\\(.)/g, '$1') + expect(plain).toContain('"pt":"ada"') + expect(plain).toContain('"pt":"grace"') + expect(plain).not.toContain('"pt":["ada","grace"]') + }) + + it('rewrites an encrypted contains in a structured or() to cs', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or([{ column: 'email', op: 'contains', value: 'ada' }]) + + expect(supabase.callsFor('or')[0].args[0] as string).toMatch( + /^email\.cs\."/, + ) + }) + }) + + describe('update / delete / single / maybeSingle', () => { + it('updates with raw envelopes keyed by DB column name', async () => { + const { es, supabase } = v3Instance() + + const createdAt = new Date('2026-01-02T03:04:05.000Z') + await es + .from('users', users) + .update({ email: 'a@b.com', createdAt }) + .eq('id', 1) + + const [update] = supabase.callsFor('update') + const body = update.args[0] as Record + expect(Object.keys(body).sort()).toEqual(['created_at', 'email']) + expect(isFakeEnvelope(body.email)).toBe(true) + expect((body.email as Record).data).toBeUndefined() + expect(isFakeEnvelope(body.created_at)).toBe(true) + }) + + // `delete()` carries no body at all — `buildAndExecuteQuery` calls + // `query.delete(options)`. The WHERE operand still has to be encrypted. + it('sends no body on delete but still encrypts the WHERE operand', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).delete().eq('nickname', 'ada') + + const [del] = supabase.callsFor('delete') + expect(del).toBeDefined() + expect(del.args[0]).toBeUndefined() + + const [eq] = supabase.callsFor('eq') + expect(eq.args[0]).toBe('nickname') + expect(JSON.parse(eq.args[1] as string).pt).toBe('ada') + }) + + it('single() returns one decrypted object, not an array', async () => { + const row = { + id: 1, + email: fakeEnvelope('a@b.com', 'email'), + createdAt: fakeEnvelope( + new Date('2026-01-02T03:04:05.000Z'), + 'created_at', + ), + } + const { es, supabase } = v3Instance(row) + + const { data, error } = await es + .from('users', users) + .select('id, email, createdAt') + .single() + + expect(error).toBeNull() + expect(supabase.callsFor('single')).toHaveLength(1) + expect(Array.isArray(data)).toBe(false) + // `data` is declared `T[] | null` on the shared builder surface; single() + // narrows it to one row at runtime only. + const single = data as unknown as { email: string; createdAt: Date } + expect(single.email).toBe('a@b.com') + expect(single.createdAt).toBeInstanceOf(Date) + }) + + it('maybeSingle() returns null for null result data without throwing', async () => { + const { es } = v3Instance(null) + + const { data, error } = await es + .from('users', users) + .select('id, email') + .maybeSingle() + + expect(error).toBeNull() + expect(data).toBeNull() + }) + }) + + // `postprocessDecryptedRow` reconstructs `Date` from `cast_as`. Only the + // string-via-property-key arm was covered. + describe('postprocessDecryptedRow branches', () => { + it('leaves a null date-like value as null', async () => { + const { es } = v3Instance([{ id: 1, createdAt: null }]) + + const { data } = await es.from('users', users).select('id, createdAt') + + expect(data![0].createdAt).toBeNull() + }) + + it('leaves an already-Date value untouched', async () => { + const existing = new Date('2026-01-02T03:04:05.000Z') + const { es } = v3Instance([{ id: 1, createdAt: existing }]) + + const { data } = await es.from('users', users).select('id, createdAt') + + expect(data![0].createdAt).toBe(existing) + }) + + it('reconstructs a Date from a numeric epoch', async () => { + const epoch = Date.parse('2026-01-02T03:04:05.000Z') + const { es } = v3Instance([{ id: 1, createdAt: epoch }]) + + const { data } = await es.from('users', users).select('id, createdAt') + + expect(data![0].createdAt).toBeInstanceOf(Date) + expect((data![0].createdAt as Date).toISOString()).toBe( + '2026-01-02T03:04:05.000Z', + ) + }) + + // Selecting by raw DB name means the row comes back keyed `created_at`, + // the only way to reach the `dbName` half of the two-key branch. It also + // exercises the `value == null` skip on the absent `createdAt` key. + it('reconstructs a Date on a row keyed by the raw DB column name', async () => { + const rows = [ + { + id: 1, + created_at: fakeEnvelope( + new Date('2026-01-02T03:04:05.000Z'), + 'created_at', + ), + }, + ] + const { es } = v3Instance(rows) + + const { data } = await es.from('users', users).select('id, created_at') + + const row = data![0] as unknown as Record + expect(row.created_at).toBeInstanceOf(Date) + expect((row.created_at as Date).toISOString()).toBe( + '2026-01-02T03:04:05.000Z', + ) + }) + }) + + // `notFilterOperator` was asserted only on the operator, never on the + // operand — a regression dropping envelope encoding on the not() path would + // have passed. + describe('notFilterOperator', () => { + it('sends a full envelope as the not(cs) operand', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').not('email', 'contains', 'a@b') + + const [not] = supabase.callsFor('not') + expect(not.args[0]).toBe('email') + expect(not.args[1]).toBe('cs') + expect(JSON.parse(not.args[2] as string).c).toBeDefined() + }) + + it('leaves not(like) on an encrypted column as like — no silent cs rewrite', async () => { + const { es, supabase } = v3Instance() + + // `not()` takes a raw operator string, so it bypasses the like() guard. + // It must NOT be rewritten to cs: `~~` does not exist on the domain, so + // PostgREST errors loudly instead of returning a wrong row set. + await es.from('users', users).select('id').not('email', 'like', 'a@b') + + expect(supabase.callsFor('not')[0].args[1]).toBe('like') + }) + + it('leaves not(like) on a plaintext column as like with a plain operand', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').not('note', 'like', '%x%') + + expect(supabase.callsFor('not')[0].args).toEqual(['note', 'like', '%x%']) + }) + + it('keeps a non-pattern operator on an encrypted column, still enveloped', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').not('nickname', 'eq', 'ada') + + const [not] = supabase.callsFor('not') + expect(not.args[0]).toBe('nickname') + expect(not.args[1]).toBe('eq') + expect(JSON.parse(not.args[2] as string).pt).toBe('ada') + }) + }) + + // `v3Columns` / `dbToProp` are null-prototype and `dbNameFor` uses + // `Object.hasOwn` precisely so a plaintext DB column named `constructor` + // cannot resolve to the inherited `Object.prototype.constructor`. Nothing + // exercised those guards through the builder. + describe('a plaintext column named `constructor`', () => { + // Only encrypted columns are declared; `constructor` is a plaintext DB + // column, as introspection would report it. + const protoTable = encryptedTable('proto', { + email: types.TextSearch('email'), + createdAt: types.TimestampOrd('created_at'), + }) + const PROTO_ALL_COLUMNS = ['id', 'email', 'created_at', 'constructor'] + + function protoInstance() { + const supabase = createMockSupabase() + const q = new EncryptedQueryBuilderV3Impl( + 'proto', + protoTable, + createMockEncryptionClient(), + supabase.client, + PROTO_ALL_COLUMNS, + // biome-ignore lint/suspicious/noExplicitAny: addressing a column outside the declared row type + ) as any + return { q, supabase } + } + + it('expands it as a real column in select(*)', async () => { + const { q, supabase } = protoInstance() + + await q.select('*') + + const emitted = supabase.callsFor('select')[0].args[0] as string + expect(emitted.split(', ')).toEqual([ + 'id', + 'email::jsonb', + 'createdAt:created_at::jsonb', + 'constructor', + ]) + }) + + it('resolves it as a plaintext filter column', async () => { + const { q, supabase } = protoInstance() + + await q.select('id').eq('constructor', 'x') + + expect(supabase.callsFor('eq')[0].args).toEqual(['constructor', 'x']) + }) + + // The sharpest of the three: `validateTransforms` indexes `v3Columns` + // without an own-key guard, so an inherited `constructor` would resolve to + // a Function and blow up on `.getQueryCapabilities()`. + it('orders by it without consulting the capability guard', async () => { + const { q, supabase } = protoInstance() + + const { error } = await q.select('id').order('constructor') + + expect(error).toBeNull() + expect(supabase.callsFor('order')[0].args[0]).toBe('constructor') + }) + }) +}) + +// --------------------------------------------------------------------------- +// v2 regression — the dialect seams must leave the v2 wire encoding untouched +// --------------------------------------------------------------------------- + +describe('encryptedSupabase (v2) wire encoding is unchanged by the dialect seams', () => { + function v2Instance(resultData: unknown = []) { + const supabase = createMockSupabase(resultData) + const es = encryptedSupabase({ + encryptionClient: createMockEncryptionClient(), + supabaseClient: supabase.client, + }) + return { es, supabase } + } + + it('wraps encrypted mutation values in the { data } composite shape', async () => { + const { es, supabase } = v2Instance() + + await es.from('users', usersV2).insert({ email: 'a@b.com', note: 'x' }) + + const [insert] = supabase.callsFor('insert') + const body = insert.args[0] as Record + expect(body.email).toHaveProperty('data') + expect(isFakeEnvelope((body.email as Record).data)).toBe( + true, + ) + expect(body.note).toBe('x') + }) + + it('encodes filter terms as composite literals via encryptQuery', async () => { + const { es, supabase } = v2Instance() + + await es.from('users', usersV2).select('id, email').eq('email', 'a@b.com') + + const [eq] = supabase.callsFor('eq') + expect(eq.args).toEqual(['email', '("a@b.com")']) + }) + + it('keeps like on encrypted columns as like', async () => { + const { es, supabase } = v2Instance() + + await es.from('users', usersV2).select('id, email').like('email', 'a@b') + + expect(supabase.callsFor('like')).toHaveLength(1) + expect(supabase.callsFor('filter')).toHaveLength(0) + }) + + it('adds plain ::jsonb casts without aliasing', async () => { + const { es, supabase } = v2Instance() + + await es.from('users', usersV2).select('id, email, age') + + const [select] = supabase.callsFor('select') + expect(select.args[0]).toBe('id, email::jsonb, age::jsonb') + }) + + it('passes order() column names through unchanged', async () => { + const { es, supabase } = v2Instance() + + await es.from('users', usersV2).select('id, age').order('age') + + const [order] = supabase.callsFor('order') + expect(order.args[0]).toBe('age') + }) + + it('passes the onConflict option through by reference', async () => { + const { es, supabase } = v2Instance() + + const options = { onConflict: 'email' } + await es.from('users', usersV2).upsert({ email: 'a@b.com' }, options) + + const [upsert] = supabase.callsFor('upsert') + expect(upsert.args[1]).toBe(options) + }) + + it('passes an all-plaintext or() string through verbatim', async () => { + const { es, supabase } = v2Instance() + + await es.from('users', usersV2).select('id').or('id.eq.1,note.eq.x') + + const [or] = supabase.callsFor('or') + expect(or.args[0]).toBe('id.eq.1,note.eq.x') + }) + + // ------------------------------------------------------------------------- + // Characterization tests for the paths `toDbSpace()` will rewrite. Each pins + // the correlation between the term collector (`encryptFilterValues`) and the + // applier (`applyFilters`), which agree only by array index / column name. + // ------------------------------------------------------------------------- + + it('match() encrypts encrypted keys and passes plaintext through', async () => { + const { es, supabase } = v2Instance() + + await es + .from('users', usersV2) + .select('id') + .match({ email: 'a@b.com', note: 'plain' }) + + const [match] = supabase.callsFor('match') + const query = match.args[0] as Record + expect(query.email).toBe('("a@b.com")') + expect(query.note).toBe('plain') + // Key order survives the Record -> entries -> Record round-trip + expect(Object.keys(query)).toEqual(['email', 'note']) + }) + + it('not() encrypts on encrypted columns and passes plaintext through', async () => { + const { es, supabase } = v2Instance() + + await es.from('users', usersV2).select('id').not('email', 'eq', 'a@b.com') + await es.from('users', usersV2).select('id').not('note', 'eq', 'plain') + + const [encrypted, plain] = supabase.callsFor('not') + expect(encrypted.args).toEqual(['email', 'eq', '("a@b.com")']) + expect(plain.args).toEqual(['note', 'eq', 'plain']) + }) + + it('in() encrypts each element and leaves plaintext arrays alone', async () => { + const { es, supabase } = v2Instance() + + await es.from('users', usersV2).select('id').in('email', ['a@b.com', 'c@d']) + await es.from('users', usersV2).select('id').in('note', ['x', 'y']) + + const [encrypted, plain] = supabase.callsFor('in') + expect(encrypted.args[1]).toEqual(['("a@b.com")', '("c@d")']) + expect(plain.args[1]).toEqual(['x', 'y']) + }) + + it('is() leaves the value untouched on an encrypted column', async () => { + const { es, supabase } = v2Instance() + + await es.from('users', usersV2).select('id').is('email', null) + + const [is] = supabase.callsFor('is') + expect(is.args).toEqual(['email', null]) + }) + + it('filter() encrypts the operand on an encrypted column', async () => { + const { es, supabase } = v2Instance() + + await es + .from('users', usersV2) + .select('id') + .filter('email', 'eq', 'a@b.com') + await es.from('users', usersV2).select('id').filter('note', 'eq', 'plain') + + const [encrypted, plain] = supabase.callsFor('filter') + expect(encrypted.args).toEqual(['email', 'eq', '("a@b.com")']) + expect(plain.args).toEqual(['note', 'eq', 'plain']) + }) + + // The single most important characterization test: a strict nonempty SUBSET + // of the or-string's conditions is encrypted, so the condition index `j` must + // agree between the two `parseOrString` calls that `toDbSpace()` collapses + // into one. + it('or() rebuilds a mixed encrypted/plaintext string, keeping each condition on its own column', async () => { + const { es, supabase } = v2Instance() + + await es + .from('users', usersV2) + .select('id') + .or('email.eq.a@b.com,note.eq.x') + + const [or] = supabase.callsFor('or') + const emitted = or.args[0] as string + const [emailCond, noteCond] = emitted.split(',') + expect(emailCond).toContain('email.eq.') + expect(emailCond).toContain('a@b.com') + expect(noteCond).toBe('note.eq.x') + }) + + it('keeps every filter array correlated in a combined query', async () => { + const { es, supabase } = v2Instance() + + await es + .from('users', usersV2) + .select('id, email, age') + .eq('email', 'a@b.com') + .not('age', 'eq', 30) + .or('email.eq.c@d,note.eq.x') + .match({ email: 'e@f.com' }) + .filter('age', 'gte', 18) + .order('age') + .limit(10) + + expect(supabase.callsFor('eq')[0].args).toEqual(['email', '("a@b.com")']) + expect(supabase.callsFor('not')[0].args).toEqual(['age', 'eq', '("30")']) + expect(supabase.callsFor('or')[0].args[0]).toContain('note.eq.x') + expect( + (supabase.callsFor('match')[0].args[0] as Record).email, + ).toBe('("e@f.com")') + expect(supabase.callsFor('filter')[0].args).toEqual([ + 'age', + 'gte', + '("18")', + ]) + expect(supabase.callsFor('order')[0].args[0]).toBe('age') + expect(supabase.callsFor('limit')[0].args[0]).toBe(10) + }) + + // Released-side regressions. `is` is a SQL predicate — PostgREST accepts only + // null/true/false — and a null operand is SQL NULL, never a value to search + // for. Only the regular `.is()` filter skipped encryption; every other + // collector encrypted whatever it was handed, emitting operands PostgREST + // rejects. + describe('is / null operands are never encrypted', () => { + it('does not encrypt not(col, is, null)', async () => { + const { es, supabase } = v2Instance() + await es.from('users', usersV2).select('id').not('age', 'is', null) + expect(supabase.callsFor('not')[0].args).toEqual(['age', 'is', null]) + }) + + it('does not encrypt a raw filter() is operand', async () => { + const { es, supabase } = v2Instance() + await es.from('users', usersV2).select('id').filter('age', 'is', null) + expect(supabase.callsFor('filter')[0].args).toEqual(['age', 'is', null]) + }) + + it('forwards an or() is condition unencrypted', async () => { + const { es, supabase } = v2Instance() + await es.from('users', usersV2).select('id').or('age.is.null') + expect(supabase.callsFor('or')[0].args[0]).toBe('age.is.null') + }) + + it('does not encrypt a null eq() operand', async () => { + const { es, supabase } = v2Instance() + await es.from('users', usersV2).select('id').eq('email', null) + expect(supabase.callsFor('eq')[0].args).toEqual(['email', null]) + }) + + it('does not encrypt a null match() value', async () => { + const { es, supabase } = v2Instance() + await es.from('users', usersV2).select('id').match({ email: null }) + expect(supabase.callsFor('match')[0].args[0]).toEqual({ email: null }) + }) + + it('does not encrypt null elements of an in() list', async () => { + const { es, supabase } = v2Instance() + await es + .from('users', usersV2) + .select('id') + .in('email', ['a@b.com', null]) + expect(supabase.callsFor('in')[0].args[1]).toEqual(['("a@b.com")', null]) + }) + + it('treats is() as a predicate even with a non-null operand', async () => { + const { es, supabase } = v2Instance() + await es.from('users', usersV2).select('id').is('email', false) + expect(supabase.callsFor('is')[0].args).toEqual(['email', false]) + }) + }) +}) + +// --------------------------------------------------------------------------- +// Property → DB name collision +// +// `createdAt` is declared as the property for DB column `created_at`. If the +// database ALSO has a distinct plaintext column literally named `createdAt`, +// `expandAllColumns` maps `created_at → createdAt` and passes the plaintext +// `createdAt` through unchanged, so `select('*')` emits the same aliased token +// twice and PostgREST returns the encrypted column under `createdAt`. The real +// plaintext column is never selected. Nothing downstream can disambiguate, so +// the builder must refuse to construct. +// --------------------------------------------------------------------------- + +describe('v3 property/DB name collision', () => { + const collidingColumns = [ + 'id', + 'email', + 'nickname', + 'amount', + 'created_at', + 'createdAt', // distinct plaintext column colliding with the property name + 'active', + 'note', + ] + + it('throws when a property name collides with a different DB column', () => { + const supabase = createMockSupabase() + expect( + () => + new EncryptedQueryBuilderV3Impl( + 'users', + users, + createMockEncryptionClient(), + supabase.client, + collidingColumns, + ), + ).toThrow(/createdAt.*created_at|collide/) + }) + + it('constructs normally when no property name collides', () => { + const supabase = createMockSupabase() + expect( + () => + new EncryptedQueryBuilderV3Impl( + 'users', + users, + createMockEncryptionClient(), + supabase.client, + USERS_ALL_COLUMNS, + ), + ).not.toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// encryptCollectedTerms: failure arm + lockContext/audit threading +// +// The existing 500-status tests all reach 500 via the CAPABILITY GUARD, which +// throws before `encryptionClient.encrypt()` is ever called — so they pass even +// if the failure/threading block below is deleted wholesale. These do not: they +// need a client whose encrypt() actually fails, and one that records what was +// threaded onto the operation. +// --------------------------------------------------------------------------- + +/** An encrypt operation that resolves to a failure result. */ +function failingEncryptionClient(message: string) { + const op = { + withLockContext: () => op, + audit: () => op, + then: ( + onfulfilled?: ((v: unknown) => unknown) | null, + onrejected?: ((r: unknown) => unknown) | null, + ) => + Promise.resolve({ failure: { message } }).then(onfulfilled, onrejected), + } + return { encrypt: () => op } +} + +/** An encrypt operation that records `withLockContext` / `audit` calls. */ +function recordingEncryptionClient() { + const withLockContext = vi.fn() + const audit = vi.fn() + const op: Record = { + withLockContext: (...a: unknown[]) => { + withLockContext(...a) + return op + }, + audit: (...a: unknown[]) => { + audit(...a) + return op + }, + then: ( + onfulfilled?: ((v: unknown) => unknown) | null, + onrejected?: ((r: unknown) => unknown) | null, + ) => + Promise.resolve({ data: fakeEnvelope('a@b.com', 'email') }).then( + onfulfilled, + onrejected, + ), + } + return { client: { encrypt: () => op }, withLockContext, audit } +} + +function builderWith(encryptionClient: unknown) { + const supabase = createMockSupabase() + return new EncryptedQueryBuilderV3Impl( + 'users', + users, + encryptionClient as never, + supabase.client, + USERS_ALL_COLUMNS, + ) +} + +describe('v3 encryptCollectedTerms', () => { + it('surfaces a filter-term encryption failure as a 500 response', async () => { + const builder = builderWith(failingEncryptionClient('boom')) + + const { error, status } = await builder + .select('id, email') + .eq('email', 'a@b.com') + + expect(status).toBe(500) + expect(error?.message).toContain('Failed to encrypt query terms') + expect(error?.message).toContain('boom') + }) + + it('threads lockContext and audit onto the filter-term encryption', async () => { + const { client, withLockContext, audit } = recordingEncryptionClient() + const lockContext = { identify: () => {} } as never + const auditConfig = { metadata: { a: 1 } } + + await builderWith(client) + .withLockContext(lockContext) + .audit(auditConfig) + .select('id, email') + .eq('email', 'a@b.com') + + // Dropping either call would encrypt query terms under the wrong key, or + // silently lose the audit trail, with no test failing today. + expect(withLockContext).toHaveBeenCalledWith(lockContext) + expect(audit).toHaveBeenCalledWith(auditConfig) + }) +}) + +// --------------------------------------------------------------------------- +// Date reconstruction on the single-row decrypt path +// +// The `postprocessDecryptedRow` branches themselves are covered above; what is +// not, is that the SINGLE-row call site invokes it at all. Only the array path +// was exercised, so a missed reconstruction here hands the caller a string +// where the row type promises a Date. +// --------------------------------------------------------------------------- + +describe('v3 single() decrypt path', () => { + // `postprocessDecryptedRow` is called from both the array path and the + // single-row path; only the array path was covered. A missed reconstruction + // here hands the caller a string where the row type promises a Date. + it('reconstructs Date on the single() path', async () => { + const iso = '2026-01-02T03:04:05.000Z' + const { es } = v3Instance({ + id: 1, + createdAt: fakeEnvelope(new Date(iso), 'created_at'), + }) + + const { data, error } = await es + .from('users', users) + .select('id, createdAt') + .single() + + expect(error).toBeNull() + const row = data as unknown as { createdAt: Date } + expect(row.createdAt).toBeInstanceOf(Date) + expect(row.createdAt.toISOString()).toBe(iso) + }) +}) + +// --------------------------------------------------------------------------- +// Raw .filter() query-type resolution (Workstream D) +// +// Every other term collector derives `queryType` from the operator; the raw +// filter loop hardcoded `'equality'`. In v3 the query type is ONLY a capability +// gate (the operand is always the full storage envelope), so a wrong query type +// is a wrong accept/reject — it does not corrupt the ciphertext. The victim is +// `bio` (public.text_match, MATCH_ONLY): equality:false, freeTextSearch:true. +// --------------------------------------------------------------------------- + +describe('v3 raw filter() resolves the query type from the operator', () => { + it('accepts cs on a match-only column and emits it verbatim', async () => { + const { es, supabase } = v3Instance() + + const { error, status } = await es + .from('users', users) + .select('id') + .filter('bio', 'cs', 'ada') + + expect(error).toBeNull() + expect(status).toBe(200) + const [call] = supabase.callsFor('filter') + expect(call.args[0]).toBe('bio') + expect(call.args[1]).toBe('cs') + expect(JSON.parse(call.args[2] as string).pt).toBe('ada') + }) + + it('accepts gte on an ord column as an orderAndRange query', async () => { + const { es } = v3Instance() + + const { error, status } = await es + .from('users', users) + .select('id') + .filter('amount', 'gte', 10) + + expect(error).toBeNull() + expect(status).toBe(200) + }) + + it('rejects cs on an ord column, which has no freeTextSearch capability', async () => { + const { es } = v3Instance() + + const { error, status } = await es + .from('users', users) + .select('id') + .filter('amount', 'cs', 'ada') + + expect(status).toBe(500) + expect(error?.message).toContain('does not support freeTextSearch') + }) + + it('rejects an unsupported raw operator rather than defaulting to equality', async () => { + const { es } = v3Instance() + + const { error, status } = await es + .from('users', users) + .select('id') + .filter('email', 'ov', 'ada') + + expect(status).toBe(500) + expect(error?.message).toContain('unsupported raw filter operator "ov"') + }) + + it('leaves a raw filter on a plaintext column untouched', async () => { + const { es, supabase } = v3Instance() + + const { error } = await es + .from('users', users) + .select('id') + .filter('note', 'ov', 'anything') + + expect(error).toBeNull() + expect(supabase.callsFor('filter')[0].args).toEqual([ + 'note', + 'ov', + 'anything', + ]) + }) +}) diff --git a/packages/stack/__tests__/supabase-v3-factory.test.ts b/packages/stack/__tests__/supabase-v3-factory.test.ts new file mode 100644 index 000000000..4ea19a0d3 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-factory.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import type { SupabaseClientLike } from '@/supabase' +import { encryptedSupabaseV3 } from '@/supabase' +import type { IntrospectionData } from '@/supabase/introspect' +import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3' + +// --- Mocks ----------------------------------------------------------------- +// +// `vi.mock` factories are hoisted above every import, so they cannot close over +// a plain top-level `const` (it would still be in its TDZ when the factory runs +// on first import). `vi.hoisted` lifts the spies alongside them. + +const { introspectMock, encryptionMock, createClientMock } = vi.hoisted(() => ({ + introspectMock: vi.fn<(url: string) => Promise>(), + encryptionMock: vi.fn<(cfg: unknown) => Promise>(), + createClientMock: vi.fn(() => ({ from: () => ({}) })), +})) + +vi.mock('@/supabase/introspect', async (importActual) => { + const actual = await importActual() + return { ...actual, introspect: (url: string) => introspectMock(url) } +}) + +vi.mock('@/encryption', async (importActual) => { + const actual = await importActual() + return { ...actual, Encryption: (cfg: unknown) => encryptionMock(cfg) } +}) + +vi.mock('@supabase/supabase-js', () => ({ createClient: createClientMock })) + +const fakeClient = { from: () => ({}) } as unknown as SupabaseClientLike + +function introspectionOf(data: Partial): IntrospectionData { + return { + tables: data.tables ?? [], + unmodelled: data.unmodelled ?? new Map(), + } +} + +const usersIntrospection = introspectionOf({ + tables: [ + { + tableName: 'users', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'email', domainName: 'text_search' }, + ], + }, + ], +}) + +beforeEach(() => { + introspectMock.mockReset().mockResolvedValue(usersIntrospection) + encryptionMock.mockReset().mockResolvedValue({}) + createClientMock.mockClear() + delete process.env.DATABASE_URL +}) +afterEach(() => vi.restoreAllMocks()) + +describe('encryptedSupabaseV3 factory', () => { + it('url+key overload builds a client and introspects the given databaseUrl', async () => { + await encryptedSupabaseV3('http://sb', 'anon-key', { + databaseUrl: 'postgres://x', + }) + expect(createClientMock).toHaveBeenCalledWith('http://sb', 'anon-key') + expect(introspectMock).toHaveBeenCalledWith('postgres://x') + expect(encryptionMock).toHaveBeenCalledTimes(1) + }) + + it('client overload uses the supplied client (no createClient)', async () => { + await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }) + expect(createClientMock).not.toHaveBeenCalled() + }) + + it('falls back to process.env.DATABASE_URL', async () => { + process.env.DATABASE_URL = 'postgres://env' + await encryptedSupabaseV3(fakeClient) + expect(introspectMock).toHaveBeenCalledWith('postgres://env') + }) + + it('throws naming DATABASE_URL when no URL is available', async () => { + await expect(encryptedSupabaseV3(fakeClient)).rejects.toThrow( + /DATABASE_URL/, + ) + expect(introspectMock).not.toHaveBeenCalled() + }) + + it('verifies BEFORE building the encryption client (wrong domain)', async () => { + // email is text_search in the DB; declaring text_eq must fail... + const users = encryptedTable('users', { email: types.TextEq('email') }) + await expect( + encryptedSupabaseV3(fakeClient, { + databaseUrl: 'postgres://x', + schemas: { users }, + }), + ).rejects.toThrow(/text_eq|text_search/) + // ...and Encryption must never be reached. + expect(encryptionMock).not.toHaveBeenCalled() + }) + + it('passes only non-empty tables to Encryption', async () => { + introspectMock.mockResolvedValue( + introspectionOf({ + tables: [ + { + tableName: 'users', + columns: [{ columnName: 'email', domainName: 'text_search' }], + }, + { + tableName: 'logs', + columns: [{ columnName: 'line', domainName: null }], + }, + ], + }), + ) + await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }) + const arg = encryptionMock.mock.calls[0][0] as { schemas: unknown[] } + expect(arg.schemas).toHaveLength(1) + }) + + it('throws a diagnosis when no modelled EQL v3 columns exist anywhere', async () => { + introspectMock.mockResolvedValue( + introspectionOf({ + tables: [ + { + tableName: 'logs', + columns: [{ columnName: 'line', domainName: null }], + }, + ], + }), + ) + await expect( + encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }), + ).rejects.toThrow(/no EQL v3 encrypted columns found/) + expect(encryptionMock).not.toHaveBeenCalled() + }) + + it('throws from from() on an unknown table', async () => { + const supabase = await encryptedSupabaseV3(fakeClient, { + databaseUrl: 'postgres://x', + }) + expect(() => supabase.from('nope')).toThrow(/unknown table/) + }) + + it('returns a v3 builder from from() on a known table', async () => { + const supabase = await encryptedSupabaseV3(fakeClient, { + databaseUrl: 'postgres://x', + }) + expect(supabase.from('users')).toBeInstanceOf(EncryptedQueryBuilderV3Impl) + }) + + it('throws when a schemas record key ≠ its table name', async () => { + const mislabelled = encryptedTable('users', { + email: types.TextSearch('email'), + }) + await expect( + encryptedSupabaseV3(fakeClient, { + databaseUrl: 'postgres://x', + schemas: { orders: mislabelled }, + }), + ).rejects.toThrow(/orders.*users|record key/) + }) + + // An unmodelled EQL domain is a silent-leak hazard: the column stays in + // `allColumns` so `select('*')` selects it, but it never enters the encrypt + // config, so reads return raw ciphertext undecrypted. It MUST throw — but + // only for the table the caller actually touches. Scanning the whole `public` + // schema at construction bricks the adapter for every consumer of a database + // that happens to contain one such column on an unrelated table. + describe('unmodelled EQL domains', () => { + // `users` is fully modelled. `metrics.score` is not. `metrics.label` is. + const withUnmodelledMetrics = introspectionOf({ + tables: [ + { + tableName: 'users', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'email', domainName: 'text_search' }, + ], + }, + { + tableName: 'metrics', + columns: [ + { columnName: 'label', domainName: 'text_eq' }, + { columnName: 'score', domainName: 'integer_ord_ope' }, + ], + }, + ], + unmodelled: new Map([ + ['metrics', [{ columnName: 'score', domainName: 'integer_ord_ope' }]], + ]), + }) + + beforeEach(() => { + introspectMock.mockResolvedValue(withUnmodelledMetrics) + }) + + it('constructs when the unmodelled column is on a table the caller never names', async () => { + await expect( + encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }), + ).resolves.toBeDefined() + expect(encryptionMock).toHaveBeenCalledTimes(1) + }) + + it('still serves a fully-modelled table from that same database', async () => { + const es = await encryptedSupabaseV3(fakeClient, { + databaseUrl: 'postgres://x', + }) + expect(() => es.from('users')).not.toThrow() + }) + + it('throws from from() naming the table, column and domain', async () => { + const es = await encryptedSupabaseV3(fakeClient, { + databaseUrl: 'postgres://x', + }) + expect(() => es.from('metrics')).toThrow( + /metrics\.score.*integer_ord_ope/, + ) + }) + + // A declared table IS named by the caller, so it is validated eagerly — + // preserving "fails at construction, not on the first query" exactly where + // the caller asked for compile-time types. + it('throws at construction when the unmodelled table is declared in schemas', async () => { + const metrics = encryptedTable('metrics', { + label: types.TextEq('label'), + }) + await expect( + encryptedSupabaseV3(fakeClient, { + databaseUrl: 'postgres://x', + schemas: { metrics }, + }), + ).rejects.toThrow(/metrics\.score.*integer_ord_ope/) + expect(encryptionMock).not.toHaveBeenCalled() + }) + }) + + // `eqlVersion` is forced, not defaulted. A caller who passes `eqlVersion: 2` + // against v3 domains would otherwise get a v2 encryption client and fail at + // runtime with a 23514 CHECK violation, far from the cause. + it('forces eqlVersion 3 over a caller-supplied config, passing other keys through', async () => { + await encryptedSupabaseV3(fakeClient, { + databaseUrl: 'postgres://x', + config: { eqlVersion: 2, workspaceCrn: 'crn:test' } as never, + }) + + const arg = encryptionMock.mock.calls[0][0] as { + config: Record + } + expect(arg.config.eqlVersion).toBe(3) + expect(arg.config.workspaceCrn).toBe('crn:test') + }) + + it('defaults config to { eqlVersion: 3 } when none is supplied', async () => { + await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }) + + const arg = encryptionMock.mock.calls[0][0] as { config: unknown } + expect(arg.config).toEqual({ eqlVersion: 3 }) + }) +}) diff --git a/packages/stack/__tests__/supabase-v3-grants-pg.test.ts b/packages/stack/__tests__/supabase-v3-grants-pg.test.ts new file mode 100644 index 000000000..768f030c3 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-grants-pg.test.ts @@ -0,0 +1,179 @@ +/** + * Live proof that the shipped Supabase v3 grants let `anon` actually run the + * queries the v3 adapter emits. + * + * ## What went wrong + * `supabasePermissionsSql` granted exactly one schema, `eql_v3`. But the public + * entry points the query path calls — `eql_v3.eq_term`, `ord_term`, + * `match_term` — are SECURITY INVOKER and qualify `eql_v3_internal.*` by name in + * their bodies. Postgres resolves those names with the CALLER's privileges and + * checks schema USAGE at name resolution, so `anon` got + * `permission denied for schema eql_v3_internal` on EVERY encrypted filter: + * `=` (eq_term), `>=` (ord_term) and `@>`/`cs` (match_term) alike. + * + * `packages/stack/__tests__/supabase-v3-builder.test.ts` cannot see this — it + * records wire strings against a mock. Only a real Postgres can. + * + * ## Why the grants SQL is imported from `stash` + * Via `../../cli/src/installer/grants` — an import-free module — rather than a + * package dependency: `stash` already depends on `@cipherstash/stack`, so a + * dependency the other way would be a cycle. Asserting against the EXACT string + * the CLI installs (and embeds into the generated Supabase migration) is the + * whole point; a local copy would drift and this suite would prove nothing. + * + * ## Why no CipherStash credentials + * The probe never needs ciphertext. `eql_v3_internal.ore_block_256('{}')` fails + * on DATA for a caller that can resolve the schema, and on PERMISSION for one + * that cannot. Those two errors are what separate a working `anon` from a + * broken one, and neither requires a valid envelope — so this runs on the + * `DATABASE_URL`-only gate, alongside `supabase-v3-introspect-pg`. + */ + +import 'dotenv/config' +import postgres from 'postgres' +import { afterAll, beforeAll, expect, it } from 'vitest' +import { + SUPABASE_PERMISSIONS_SQL_V3, + supabaseInternalPermissionsSql, +} from '../../cli/src/installer/grants' +import { installEqlV3IfNeeded } from './helpers/eql-v3' +import { describeLivePgOnly, LIVE_PG_ENABLED } from './helpers/live-gate' + +const databaseUrl = process.env.DATABASE_URL +const sql = LIVE_PG_ENABLED + ? postgres(databaseUrl as string, { prepare: false }) + : (undefined as unknown as postgres.Sql) + +const INTERNAL_SCHEMA = 'eql_v3_internal' + +/** The roles `SUPABASE_PERMISSIONS_SQL_V3` names. `postgres` is named too, by + * the `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` lines — a plain Postgres + * image has none of them. */ +const REQUIRED_ROLES = ['postgres', 'anon', 'authenticated', 'service_role'] + +/** + * The probe: resolvable-but-invalid input. + * + * - caller CAN reach the schema → PL/pgSQL raises `Expected an ore index (ob)…` + * - caller CANNOT → the parser raises `permission denied for schema` + * + * The distinction is the entire test, and it needs no encryption client. + */ +const PROBE = `SELECT ${INTERNAL_SCHEMA}.ore_block_256('{}'::jsonb)` +const DATA_ERROR = /Expected an ore index/i +const PERMISSION_ERROR = /permission denied for schema eql_v3_internal/i + +/** Run `PROBE` as `anon`, returning the error message Postgres raised. */ +async function probeAsAnon(): Promise { + const reserved = await sql.reserve() + try { + await reserved.unsafe('SET ROLE anon') + await reserved.unsafe(PROBE) + return '' + } catch (error) { + return (error as Error).message + } finally { + await reserved.unsafe('RESET ROLE').catch(() => {}) + reserved.release() + } +} + +beforeAll(async () => { + if (!LIVE_PG_ENABLED) return + await installEqlV3IfNeeded(sql) + + // `CREATE ROLE IF NOT EXISTS` does not exist; a shared CI database may + // already carry these from an earlier run. + for (const role of REQUIRED_ROLES) { + await sql.unsafe(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN + CREATE ROLE ${role} NOLOGIN; + END IF; + END $$; + `) + } + + // Start from no internal-schema access. GRANTs persist in the database, so on + // a reused local database (or a rerun) the roles would still carry USAGE from + // the previous run and this suite would pass without the SQL under test ever + // granting anything. Revoking first makes the assertions depend on + // `SUPABASE_PERMISSIONS_SQL_V3` rather than on accumulated state. Only these + // three roles are touched; the owner every other live suite connects as is + // unaffected. + await sql.unsafe( + `REVOKE ALL ON SCHEMA ${INTERNAL_SCHEMA} FROM anon, authenticated, service_role`, + ) + + // The SQL under test, executed exactly as the CLI installer executes it: + // one multi-statement string. + await sql.unsafe(SUPABASE_PERMISSIONS_SQL_V3) +}, 120_000) + +afterAll(async () => { + if (!LIVE_PG_ENABLED) return + await sql.end() +}) + +describeLivePgOnly('supabase v3 grants against real Postgres', () => { + it('grants anon USAGE on eql_v3_internal', async () => { + const [row] = await sql<{ usage: boolean }[]>` + SELECT has_schema_privilege('anon', ${INTERNAL_SCHEMA}, 'USAGE') AS usage + ` + expect(row.usage).toBe(true) + }) + + // The regression itself. Before the fix this raised + // "permission denied for schema eql_v3_internal". + it('lets anon resolve the internal schema the term extractors reach into', async () => { + const message = await probeAsAnon() + + expect(message).not.toMatch(PERMISSION_ERROR) + expect(message).toMatch(DATA_ERROR) + }) + + // Proves the assertion above is not vacuous: strip ONLY the internal-schema + // grants and anon breaks; re-apply them and it recovers. Without this, a + // future change that made `ore_block_256` fail early — or that dropped the + // internal grants while some other GRANT happened to cover them — would leave + // the test above passing for the wrong reason. + it('breaks anon when the internal-schema grants are removed', async () => { + await sql.unsafe(`REVOKE ALL ON SCHEMA ${INTERNAL_SCHEMA} FROM anon`) + try { + expect(await probeAsAnon()).toMatch(PERMISSION_ERROR) + } finally { + await sql.unsafe(supabaseInternalPermissionsSql(INTERNAL_SCHEMA)) + } + + expect(await probeAsAnon()).toMatch(DATA_ERROR) + }) + + // WHY the grant is needed. If EQL upstream ever makes these SECURITY DEFINER, + // or stops qualifying the internal schema, the grant becomes unnecessary and + // this suite should be revisited rather than silently kept alive. + it('term extractors are SECURITY INVOKER and qualify eql_v3_internal', async () => { + const rows = await sql< + { proname: string; total: bigint; qualifies: bigint; invoker: boolean }[] + >` + SELECT p.proname, + count(*) AS total, + count(*) FILTER (WHERE p.prosrc LIKE '%eql_v3_internal.%') AS qualifies, + bool_and(NOT p.prosecdef) AS invoker + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'eql_v3' + AND p.proname IN ('eq_term', 'ord_term', 'match_term') + GROUP BY p.proname + ` + + expect(rows.map((r) => r.proname).sort()).toEqual([ + 'eq_term', + 'match_term', + 'ord_term', + ]) + for (const row of rows) { + expect(row.invoker).toBe(true) + expect(Number(row.qualifies)).toBeGreaterThan(0) + } + }) +}) diff --git a/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts b/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts new file mode 100644 index 000000000..3d108bab7 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts @@ -0,0 +1,133 @@ +import 'dotenv/config' +import postgres from 'postgres' +import { afterAll, beforeAll, expect, it } from 'vitest' +import { factoryForDomain } from '@/eql/v3/domain-registry' +import { introspect } from '@/supabase/introspect' +import { synthesizeTables } from '@/supabase/schema-builder' +import { installEqlV3IfNeeded } from './helpers/eql-v3' +import { describeLivePgOnly, LIVE_PG_ENABLED } from './helpers/live-gate' + +const databaseUrl = process.env.DATABASE_URL +const sql = LIVE_PG_ENABLED + ? postgres(databaseUrl as string, { prepare: false }) + : (undefined as unknown as postgres.Sql) + +const MODELLED = 'protect_ci_v3_introspect' +const UNMODELLED = 'protect_ci_v3_unmodelled' +const USER_DOMAIN = 'protect_ci_v3_user_json' + +beforeAll(async () => { + if (!LIVE_PG_ENABLED) return + await installEqlV3IfNeeded(sql) + await sql.unsafe(`DROP TABLE IF EXISTS ${MODELLED}`) + await sql.unsafe(`DROP TABLE IF EXISTS ${UNMODELLED}`) + await sql.unsafe(` + CREATE TABLE ${MODELLED} ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + email public.text_search NOT NULL, + amount public.integer_ord NOT NULL, + note TEXT, + meta jsonb + ) + `) + // A user's OWN jsonb domain, with no EQL comment. It must never be reported + // as unmodelled — it is an ordinary plaintext passthrough. + await sql.unsafe(`DROP DOMAIN IF EXISTS public.${USER_DOMAIN}`) + await sql.unsafe(`CREATE DOMAIN public.${USER_DOMAIN} AS jsonb`) + // Columns typed with EQL domains that have NO types factory. + await sql.unsafe(` + CREATE TABLE ${UNMODELLED} ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + score public.integer_ord_ope NOT NULL, + doc public.json, + own public.${USER_DOMAIN} + ) + `) +}, 30000) + +afterAll(async () => { + if (!LIVE_PG_ENABLED) return + await sql.unsafe(`DROP TABLE IF EXISTS ${MODELLED}`) + await sql.unsafe(`DROP TABLE IF EXISTS ${UNMODELLED}`) + await sql.unsafe(`DROP DOMAIN IF EXISTS public.${USER_DOMAIN}`) + await sql.end() +}, 30000) + +describeLivePgOnly('eql_v3 supabase introspection', () => { + it('detects EQL v3 domains and classifies plaintext columns', async () => { + const { tables } = await introspect(databaseUrl as string) + const table = tables.find((t) => t.tableName === MODELLED) + expect(table).toBeDefined() + const domains = Object.fromEntries( + table!.columns.map((c) => [c.columnName, c.domainName]), + ) + expect(domains.email).toBe('text_search') + expect(domains.amount).toBe('integer_ord') + // Plaintext + plain jsonb → NULL (udt_name is jsonb for all of these). + expect(domains.id).toBeNull() + expect(domains.note).toBeNull() + expect(domains.meta).toBeNull() + }, 30000) + + it('round-trips the domain → builder mapping via synthesizeTables', async () => { + const { tables } = await introspect(databaseUrl as string) + + const { tables: synth, allColumns } = synthesizeTables(tables) + const table = synth.get(MODELLED) + expect(Object.keys(table!.columnBuilders).sort()).toEqual([ + 'amount', + 'email', + ]) + expect(table!.columnBuilders.email.getEqlType()).toBe('public.text_search') + expect(table!.columnBuilders.amount.getEqlType()).toBe('public.integer_ord') + expect(allColumns.get(MODELLED)).toEqual([ + 'id', + 'email', + 'amount', + 'note', + 'meta', + ]) + }, 30000) + + // The three-way classification now lives entirely in the SQL predicate of + // `UNMODELLED_COLUMNS_QUERY`: EQL-by-COMMENT, and not in `DOMAIN_REGISTRY`. + // These prove it against a real catalog — nothing else does. + it('reports unmodelled EQL columns, keyed by table', async () => { + const { unmodelled } = await introspect(databaseUrl as string) + + // Sanity: these really are EQL domains with no types factory. + expect(factoryForDomain('integer_ord_ope')).toBeUndefined() + expect(factoryForDomain('json')).toBeUndefined() + + const offenders = unmodelled.get(UNMODELLED) + expect(offenders).toBeDefined() + expect(offenders?.map((c) => c.columnName).sort()).toEqual(['doc', 'score']) + expect(offenders?.find((c) => c.columnName === 'score')?.domainName).toBe( + 'integer_ord_ope', + ) + }, 30000) + + it('does not report a fully-modelled table', async () => { + const { unmodelled } = await introspect(databaseUrl as string) + expect(unmodelled.has(MODELLED)).toBe(false) + }, 30000) + + it("does not report a user's own jsonb domain as unmodelled", async () => { + const { unmodelled } = await introspect(databaseUrl as string) + // `own` carries a public domain with NO EQL comment → plaintext, not a leak. + const offenders = unmodelled.get(UNMODELLED) ?? [] + expect(offenders.map((c) => c.columnName)).not.toContain('own') + }, 30000) + + // The precondition that makes the `from()` guard load-bearing: an unmodelled + // column is silently dropped from the encrypt config, yet stays in + // `allColumns` — so `select('*')` would select it and return raw ciphertext. + it('synthesizeTables drops an unmodelled column but allColumns keeps it', async () => { + const { tables } = await introspect(databaseUrl as string) + const { tables: synth, allColumns } = synthesizeTables(tables) + + expect(Object.keys(synth.get(UNMODELLED)!.columnBuilders)).toEqual([]) + expect(allColumns.get(UNMODELLED)).toContain('score') + expect(allColumns.get(UNMODELLED)).toContain('doc') + }, 30000) +}) diff --git a/packages/stack/__tests__/supabase-v3-matrix.test.ts b/packages/stack/__tests__/supabase-v3-matrix.test.ts new file mode 100644 index 000000000..f48caf097 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-matrix.test.ts @@ -0,0 +1,225 @@ +/** + * Type-driven Supabase v3 wire sweep — every domain, every capability tier. + * + * The hand-written wire tests (`supabase-v3-builder.test.ts`) drive only five + * of the catalog's 39 domains through `EncryptedQueryBuilderV3Impl`; the whole + * numeric family never reached the adapter at all. This file closes that by + * reusing the SAME compile-enforced catalog (`v3-matrix/catalog.ts`) the + * Drizzle live suite tiers off, so adding a domain to the SDK yields a Supabase + * wire assertion for free — or fails to compile until it does. + * + * Tiers are derived from `indexes` exactly as `drizzle-v3/operators-live-pg. + * test.ts` derives them, rather than from `capabilities`. The adapter's guard + * reads `capabilities`, so deriving the tier from the other field makes a + * future capability/index divergence surface here as a tier mismatch instead of + * silently agreeing with itself. + * + * Only the WIRE ENCODING is under test — the mock client records `{method, + * args}` and no SQL runs. `supabase-v3-operators-live-pg.test.ts` is what + * proves Postgres accepts what this file pins. + */ + +import { describe, expect, it } from 'vitest' +import type { AnyV3Table } from '@/eql/v3' +import { encryptedTable } from '@/eql/v3' +import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3' +import { + createMockEncryptionClient, + createMockSupabase, + isFakeEnvelope, +} from './helpers/supabase-mock' +import { + type DomainSpec, + type EqlV3TypeName, + eqlTypeSlug as slug, + typedEntries, + V3_MATRIX, +} from './v3-matrix/catalog' + +const matrixEntries = typedEntries(V3_MATRIX) + +// Tiering, mirroring `drizzle-v3/operators-live-pg.test.ts`. +const equalityDomains = matrixEntries.filter( + ([, spec]) => spec.indexes.unique || spec.indexes.ore, +) +const orderDomains = matrixEntries.filter(([, spec]) => spec.indexes.ore) +const matchDomains = matrixEntries.filter(([, spec]) => spec.indexes.match) +/** No index at all — the `public.boolean`/`public.text`/… storage-only domains. */ +const storageOnlyDomains = matrixEntries.filter( + ([, spec]) => + !spec.indexes.unique && !spec.indexes.ore && !spec.indexes.match, +) + +/** + * One table per domain, property name == DB name (`builder(slug(eqlType))`) — + * the shape introspection synthesizes. Renames are covered by the declared + * `users` table in `supabase-v3-builder.test.ts`. + */ +function tableFor(eqlType: EqlV3TypeName, spec: DomainSpec): AnyV3Table { + const name = slug(eqlType) + return encryptedTable('matrix', { + [name]: spec.builder(name), + }) as unknown as AnyV3Table +} + +function instanceFor( + eqlType: EqlV3TypeName, + spec: DomainSpec, + resultData: unknown = [], +) { + const supabase = createMockSupabase(resultData) + const builder = new EncryptedQueryBuilderV3Impl( + 'matrix', + tableFor(eqlType, spec), + createMockEncryptionClient(), + supabase.client, + null, + ) + // The typed surface is keyed by the declared row type; these tests address + // columns by a runtime-computed slug and deliberately reach past it (the + // storage-only tier exists to prove the RUNTIME guard fires). + // biome-ignore lint/suspicious/noExplicitAny: see above + return { q: builder as any, supabase, name: slug(eqlType) } +} + +/** `samples[0]`, asserted non-null: a null operand short-circuits encryption + * entirely (`isEncryptableTerm`), so a future null sample would silently turn + * the capability-guard tier below into a no-op that passes for the wrong + * reason. Fail loudly instead. */ +function firstSample(spec: DomainSpec): unknown { + const sample = spec.samples[0] + expect(sample).not.toBeNull() + expect(sample).not.toBeUndefined() + return sample +} + +describe('supabase v3 wire encoding, every domain', () => { + // Guards the tier arithmetic itself. A domain silently dropping out of a + // tier would otherwise just shrink an `it.each` with no test turning red. + it('tiers all 39 domains', () => { + expect(matrixEntries).toHaveLength(39) + expect(equalityDomains).toHaveLength(28) + expect(orderDomains).toHaveLength(19) + expect(matchDomains).toHaveLength(2) + expect(storageOnlyDomains).toHaveLength(10) + }) + + describe.each(matrixEntries)('%s', (eqlType, spec) => { + it('inserts a raw envelope keyed by the column name (no composite wrap)', async () => { + const { q, supabase, name } = instanceFor(eqlType, spec) + + await q.insert({ [name]: firstSample(spec) }) + + const [insert] = supabase.callsFor('insert') + const body = insert.args[0] as Record + expect(Object.keys(body)).toEqual([name]) + expect(isFakeEnvelope(body[name])).toBe(true) + // v2 wraps in `{ data: … }`; the v3 domains are `DOMAIN … AS jsonb`. + expect((body[name] as Record).data).toBeUndefined() + }) + + it('adds a ::jsonb cast in select', async () => { + const { q, supabase, name } = instanceFor(eqlType, spec) + + await q.select(`id, ${name}`) + + expect(supabase.callsFor('select')[0].args[0]).toBe(`id, ${name}::jsonb`) + }) + }) + + describe.each(equalityDomains)('%s (equality)', (eqlType, spec) => { + it('encrypts an eq() operand as a full-envelope jsonb string', async () => { + const { q, supabase, name } = instanceFor(eqlType, spec) + + await q.select(`id, ${name}`).eq(name, firstSample(spec)) + + const [eq] = supabase.callsFor('eq') + expect(eq.args[0]).toBe(name) + // The FULL storage envelope, not a narrowed `encryptQuery` term: the + // `public.*` domain CHECK requires `v`/`i`/`c`. + expect(JSON.parse(eq.args[1] as string).c).toBeDefined() + }) + }) + + describe.each(orderDomains)('%s (orderAndRange)', (eqlType, spec) => { + it('encrypts a gte() operand as a full-envelope jsonb string', async () => { + const { q, supabase, name } = instanceFor(eqlType, spec) + + await q.select(`id, ${name}`).gte(name, firstSample(spec)) + + const [gte] = supabase.callsFor('gte') + expect(gte.args[0]).toBe(name) + expect(JSON.parse(gte.args[1] as string).c).toBeDefined() + }) + + // `gte` works — the `>=` operators ARE declared on the ord domains — but + // `order()` does not: no btree OPERATOR CLASS exists on any domain, so + // `ORDER BY col` falls through to jsonb's default opclass and sorts the + // ciphertext envelope. Filtering and sorting resolve through different + // machinery, and only sorting is broken. + it('rejects order() even though gte() is supported', async () => { + const { q, supabase, name } = instanceFor(eqlType, spec) + + const { error, status } = await q.select(`id, ${name}`).order(name) + + expect(status).toBe(500) + expect(error?.message).toContain('cannot order by encrypted column') + expect(error?.message).toContain('ord_term') + expect(supabase.callsFor('order')).toHaveLength(0) + }) + }) + + describe.each(matchDomains)('%s (freeTextSearch)', (eqlType, spec) => { + it('emits contains() as a cs containment filter', async () => { + const { q, supabase, name } = instanceFor(eqlType, spec) + + await q.select(`id, ${name}`).contains(name, firstSample(spec)) + + const [filter] = supabase.callsFor('filter') + expect(filter.args[0]).toBe(name) + expect(filter.args[1]).toBe('cs') + expect(JSON.parse(filter.args[2] as string).c).toBeDefined() + // The v3 domains define no LIKE operator — a bare `like` would 42883. + expect(supabase.callsFor('like')).toHaveLength(0) + }) + + it('refuses like(), directing the caller to contains()', async () => { + const { q, name } = instanceFor(eqlType, spec) + + expect(() => q.like(name, firstSample(spec) as string)).toThrow( + /Use contains\(\)/, + ) + }) + }) + + describe.each(storageOnlyDomains)('%s (storage only)', (eqlType, spec) => { + it('rejects eq() with the equality capability message', async () => { + const { q, name } = instanceFor(eqlType, spec) + + const { error, status } = await q.select('id').eq(name, firstSample(spec)) + + expect(status).toBe(500) + expect(error?.message).toContain('does not support equality') + }) + + it('rejects gte() with the orderAndRange capability message', async () => { + const { q, name } = instanceFor(eqlType, spec) + + const { error, status } = await q + .select('id') + .gte(name, firstSample(spec)) + + expect(status).toBe(500) + expect(error?.message).toContain('does not support orderAndRange') + }) + + it('rejects order() with the encrypted-ordering message', async () => { + const { q, name } = instanceFor(eqlType, spec) + + const { error, status } = await q.select('id').order(name) + + expect(status).toBe(500) + expect(error?.message).toContain('cannot order by encrypted column') + }) + }) +}) diff --git a/packages/stack/__tests__/supabase-v3-pgrest-live.test.ts b/packages/stack/__tests__/supabase-v3-pgrest-live.test.ts new file mode 100644 index 000000000..a6fc1c9f2 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-pgrest-live.test.ts @@ -0,0 +1,447 @@ +/** + * The supabase v3 adapter, executed against a real PostgREST and real + * `public.*` domains, as the `anon` role. + * + * Every other supabase v3 test drives `createMockSupabase` — an argument + * recorder with no SQL behind it. It can prove the adapter EMITS + * `email.cs."{…}"`; it cannot prove PostgREST parses that, that `cs` maps to + * `@>` on a domain column, that a full storage envelope clears the domain CHECK + * as a filter operand, or that `anon` holds the grants those operators need. + * Those are exactly the things that break in production, and this is the only + * suite that runs them. + * + * ## No CipherStash credentials + * The domain CHECKs are structural (`v`/`i`/`c` + the domain's index terms), + * not cryptographic, so `helpers/v3-envelope.ts` builds valid envelopes + * directly and the encryption client is a stub. The REAL parts are the adapter, + * `@supabase/postgrest-js`, PostgREST, the domains, the `eql_v3` operators and + * the Supabase grants. What is faked is ZeroKMS — and only ZeroKMS. + * + * Consequently this suite must never assert ORDER semantics: synthetic ORE + * terms compare equal to themselves and are otherwise meaningless. + * `drizzle-v3/operators-live-pg.test.ts` proves ordering with real ciphertext. + * + * ## As `anon`, not as the owner + * `PGRST_DB_ANON_ROLE=anon` and PostgREST connects as `authenticator`, a + * non-superuser member of `anon`. Pointing it at the DB owner would make every + * permission check pass vacuously. Running as `anon` is what caught + * `permission denied for schema eql_v3_internal` (see `supabase-v3-grants-pg`). + */ + +import 'dotenv/config' +import postgres from 'postgres' +import { afterAll, beforeAll, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' +import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3' +import { SUPABASE_PERMISSIONS_SQL_V3 } from '../../cli/src/installer/grants' +import { installEqlV3IfNeeded } from './helpers/eql-v3' +import { + describeLiveSupabasePgrest, + LIVE_SUPABASE_PGREST_ENABLED, +} from './helpers/live-gate' +import { makePostgrestClient, reloadSchemaCache } from './helpers/pgrest' +import { narrowedQueryTerm, storageEnvelope } from './helpers/v3-envelope' + +const TABLE = 'protect_ci_v3_pgrest' + +const sql = LIVE_SUPABASE_PGREST_ENABLED + ? postgres(process.env.DATABASE_URL as string, { prepare: false }) + : (undefined as unknown as postgres.Sql) + +// A DECLARED table: `createdAt → created_at` is the rename the aliasing +// `prop:db_name::jsonb` select exists for, and a synthesized table (property == +// DB name) cannot express it. +const users = encryptedTable(TABLE, { + email: types.TextSearch('email'), + nickname: types.TextEq('nickname'), + amount: types.IntegerOrd('amount'), + createdAt: types.TimestampOrd('created_at'), + active: types.Boolean('active'), +}) + +/** Which index terms each DB column's domain CHECK demands. */ +const COLUMN_TERMS: Record< + string, + { hmac?: boolean; ore?: boolean; bloom?: boolean } +> = { + email: { hmac: true, ore: true, bloom: true }, // text_search + nickname: { hmac: true }, // text_eq + amount: { ore: true }, // integer_ord + created_at: { ore: true }, // timestamp_ord + active: {}, // boolean — storage only +} + +/** Deterministic 3-gram token set, plus the whole value as one extra token — + * emulating the default `include_original: true`. That is precisely why a + * SUBSTRING `like` does not match: the pattern's bloom carries the whole + * pattern as a token the stored value's bloom lacks (see the class doc on + * `query-builder-v3.ts`). */ +function bloomTokens(value: string): number[] { + const hash = (s: string) => { + let h = 2166136261 + for (let i = 0; i < s.length; i++) + h = ((h ^ s.charCodeAt(i)) * 16777619) >>> 0 + return h % 2048 + } + const lower = value.toLowerCase() + const tokens = [hash(lower)] // include_original + for (let i = 0; i + 3 <= lower.length; i++) + tokens.push(hash(lower.slice(i, i + 3))) + return tokens +} + +function seedOf(value: unknown): number { + const s = String(value) + let h = 7 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0 + return h +} + +/** Plaintext is carried in `c` so the stub decrypt can undo it. Nothing in the + * domain CHECK inspects `c` beyond its presence. */ +function envelopeFor( + value: unknown, + dbColumn: string, +): Record { + const terms = COLUMN_TERMS[dbColumn] ?? {} + const pt = value instanceof Date ? value.toISOString() : value + const env = storageEnvelope({ + table: TABLE, + column: dbColumn, + seed: seedOf(pt), + hmac: terms.hmac ? String(pt) : undefined, + ore: terms.ore, + bloom: terms.bloom ? bloomTokens(String(pt)) : undefined, + }) + env.c = `ct:${JSON.stringify(pt)}` + return env +} + +function decryptValue(value: unknown): unknown { + if (value && typeof value === 'object' && 'c' in value) { + const c = (value as { c: string }).c + if (typeof c === 'string' && c.startsWith('ct:')) + return JSON.parse(c.slice(3)) + } + return value +} + +/** Chainable op, matching the real encryption client's surface. */ +function op(data: T) { + const self = { + withLockContext: () => self, + audit: () => self, + then: ( + onfulfilled?: ((value: { data: T }) => unknown) | null, + onrejected?: ((reason: unknown) => unknown) | null, + ) => Promise.resolve({ data }).then(onfulfilled, onrejected), + } + return self +} + +const PROP_TO_DB = users.buildColumnKeyMap() + +function stubEncryptionClient(): EncryptionClient { + const encryptModel = (model: Record) => { + const out: Record = { ...model } + for (const [prop, dbName] of Object.entries(PROP_TO_DB)) { + if (out[prop] != null) out[prop] = envelopeFor(out[prop], dbName) + } + return op(out) + } + const decryptModel = (model: Record) => { + const out: Record = {} + for (const [key, value] of Object.entries(model)) + out[key] = decryptValue(value) + return op(out) + } + const client = { + // The adapter passes the COLUMN BUILDER; its getName() is the DB name. + encrypt: (value: unknown, opts: { column: { getName(): string } }) => + op(envelopeFor(value, opts.column.getName())), + encryptModel, + bulkEncryptModels: (models: Record[]) => + op( + models.map((m) => { + const out: Record = { ...m } + for (const [prop, dbName] of Object.entries(PROP_TO_DB)) { + if (out[prop] != null) out[prop] = envelopeFor(out[prop], dbName) + } + return out + }), + ), + decryptModel, + bulkDecryptModels: (models: Record[]) => + op( + models.map((m) => { + const out: Record = {} + for (const [k, v] of Object.entries(m)) out[k] = decryptValue(v) + return out + }), + ), + } + return client as unknown as EncryptionClient +} + +const ALL_COLUMNS = [ + 'id', + 'row_key', + 'email', + 'nickname', + 'amount', + 'created_at', + 'active', + 'note', +] + +// biome-ignore lint/suspicious/noExplicitAny: the suite addresses columns outside the declared row type +function from(): any { + return new EncryptedQueryBuilderV3Impl( + TABLE, + users, + stubEncryptionClient(), + makePostgrestClient(), + ALL_COLUMNS, + ) +} + +const ADA_CREATED = new Date('2026-01-02T03:04:05.000Z') + +beforeAll(async () => { + if (!LIVE_SUPABASE_PGREST_ENABLED) return + await installEqlV3IfNeeded(sql) + + // The shipped Supabase grants — the thing under test, not a hand-rolled + // approximation. Re-applied after install because the bundle DROPs eql_v3. + await sql.unsafe(SUPABASE_PERMISSIONS_SQL_V3) + await sql.unsafe(`DROP TABLE IF EXISTS ${TABLE}`) + await sql.unsafe(` + CREATE TABLE ${TABLE} ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + row_key TEXT NOT NULL, + email public.text_search, + nickname public.text_eq, + amount public.integer_ord, + created_at public.timestamp_ord, + active public.boolean, + note TEXT + ) + `) + // The grants block covers eql_v3 objects, not application tables. + await sql.unsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON ${TABLE} TO anon, authenticated`, + ) + + await reloadSchemaCache(sql, TABLE) +}, 180_000) + +afterAll(async () => { + if (!LIVE_SUPABASE_PGREST_ENABLED) return + await sql.unsafe(`DROP TABLE IF EXISTS ${TABLE}`) + await sql.end() +}) + +describeLiveSupabasePgrest('supabase v3 adapter over real PostgREST', () => { + it('inserts raw envelopes that clear every domain CHECK, as anon', async () => { + const { error, status } = await from().insert({ + row_key: 'ada', + email: 'ada@example.com', + nickname: 'ada', + amount: 42, + createdAt: ADA_CREATED, + active: true, + note: 'plain', + }) + + expect(error).toBeNull() + expect(status).toBeLessThan(300) + }) + + // The load-bearing claim in `query-builder-v3.ts`: a narrowed `encryptQuery` + // term carries no `c` and fails the CHECK with 23514 for EVERY domain, which + // is why filter operands are full storage envelopes. Sent straight to + // PostgREST — the adapter cannot produce this shape. + it('rejects a narrowed encryptQuery-shaped term with 23514', async () => { + const client = makePostgrestClient() as unknown as { + from(t: string): { + insert(body: unknown): Promise<{ error: { code?: string } | null }> + } + } + const { error } = await client.from(TABLE).insert({ + row_key: 'narrowed', + email: narrowedQueryTerm({ + table: TABLE, + column: 'email', + seed: 1, + hmac: 'x', + ore: true, + bloom: [1, 2], + }), + }) + + expect(error?.code).toBe('23514') + }) + + it('aliases the renamed column and reconstructs its Date', async () => { + const { data, error } = await from() + .select('row_key, createdAt') + .eq('row_key', 'ada') + + expect(error).toBeNull() + expect(data).toHaveLength(1) + expect(data[0].createdAt).toBeInstanceOf(Date) + expect((data[0].createdAt as Date).toISOString()).toBe( + ADA_CREATED.toISOString(), + ) + }) + + it('matches an eq() filter whose operand is a full storage envelope', async () => { + const { data, error } = await from().select('row_key').eq('nickname', 'ada') + + expect(error).toBeNull() + expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) + }) + + // Executes `>=` through `eql_v3.gte` → `ord_term` → `eql_v3_internal`, which + // is the call chain the grants fix unblocked. Compares a term against itself, + // so it must match; no order semantics are asserted (synthetic ORE terms + // carry none). + it('executes a gte() range filter through the ORE operators', async () => { + const { data, error } = await from() + .select('row_key') + .gte('createdAt', ADA_CREATED) + + expect(error).toBeNull() + expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) + }) + + // `cs` → `@>` on the encrypted domain. This is the load-bearing assertion of + // the whole suite: the bundle declares + // CREATE OPERATOR @> (FUNCTION = eql_v3.contains, LEFTARG = public.text_search, + // RIGHTARG = jsonb) + // whose body is `match_term(a) @> match_term(b::public.text_search)` — a + // smallint[] containment of the two BLOOM FILTERS. It is NOT the built-in + // `jsonb @> jsonb`, which would compare whole envelopes and so could only ever + // match on an identical ciphertext. The three tests below discriminate: a + // 3-char needle matches while a 7-char one does not, and neither shares the + // stored `c`. Only bloom containment explains that. + // + // With the default `include_original: true` the needle's bloom carries the + // WHOLE needle as an extra token, so only an exact-value needle matches. + // Asserts the DOCUMENTED semantics, not the intuitive ones. + it('resolves contains() through cs containment for an exact value', async () => { + const { data, error } = await from() + .select('row_key') + .contains('email', 'ada@example.com') + + expect(error).toBeNull() + expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) + }) + + // A needle LONGER than the tokenizer's 3-gram window contributes an + // `include_original` token no stored trigram can supply, so containment + // fails. (A needle of exactly 3 characters is the degenerate case: its + // whole-value token IS a trigram, so `contains('email','ada')` DOES match.) + // This is the KNOWN-BROKEN substring defect, shared with v3 Drizzle's + // `contains` and tracked upstream in EQL. Pinned so a fix is a visible change. + it('does not match a longer substring under include_original', async () => { + const { data, error } = await from() + .select('row_key') + .contains('email', 'example') + + expect(error).toBeNull() + expect(data).toEqual([]) + }) + + it('matches a 3-character substring, the degenerate include_original case', async () => { + const { data, error } = await from() + .select('row_key') + .contains('email', 'ada') + + expect(error).toBeNull() + expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) + }) + + // The reason `like` is gone: `~~` is not defined on public.text_search, so + // had the adapter emitted it, PostgREST/Postgres would answer 42883. The + // client-side guard turns that into an actionable error before the round-trip. + it('refuses like() on an encrypted column rather than emitting an undefined operator', async () => { + expect(() => from().select('row_key').like('email', 'ada')).toThrow( + /Use contains\(\)/, + ) + }) + + // PostgREST must re-parse a double-quoted JSON envelope inside `or=(…)`. The + // envelope's own quotes and backslashes have to be escaped or the logic tree + // fails to parse with PGRST100. + it('re-parses a quoted envelope inside an or() condition', async () => { + const { data, error } = await from() + .select('row_key') + .or('nickname.eq.ada,row_key.eq.nobody') + + expect(error).toBeNull() + expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) + }) + + // An `in`-list inside `or()` has never been executed against a real PostgREST. + // Each element is a separate JSON envelope, so the list is a comma-separated + // sequence of quote-dense operands nested inside `(…)` inside the or-tree — + // the densest thing this adapter emits. A mock cannot catch a PGRST100 here. + it('parses an encrypted in-list inside an or() condition', async () => { + const { data, error } = await from() + .select('row_key') + .or('nickname.in.(ada,nobody)') + + expect(error).toBeNull() + expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) + }) + + // PostgREST negation: `column.not..`. The parser used to read `not` + // AS the operator, encrypt the literal string `in.(ada,nobody)` as one + // plaintext, and emit a filter that silently matched nothing. + it('parses a NEGATED encrypted in-list inside an or() condition', async () => { + const { data, error } = await from() + .select('row_key') + .or('nickname.not.in.(ada,nobody)') + + expect(error).toBeNull() + // `ada` is the only row and it IS in the list, so negation excludes it. + // Before the fix this returned `['ada']`: the bogus operand matched nothing, + // and `not` of nothing is everything. + expect(data).toEqual([]) + }) + + it('parses a negated encrypted scalar inside an or() condition', async () => { + const { data, error } = await from() + .select('row_key') + .or('nickname.not.eq.nobody') + + expect(error).toBeNull() + expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) + }) + + // `is` reaches Postgres as `is` with a bare `null` — never encrypted. This is + // the wire shape `fd33aadf` established and it had never been executed. + it('sends is.null on an encrypted column unencrypted', async () => { + const { data, error } = await from() + .select('row_key') + .or('createdAt.is.null') + + expect(error).toBeNull() + expect(data).toEqual([]) + }) + + it('updates and deletes through encrypted WHERE operands', async () => { + const updated = await from() + .update({ note: 'changed' }) + .eq('nickname', 'ada') + expect(updated.error).toBeNull() + + const deleted = await from().delete().eq('nickname', 'ada') + expect(deleted.error).toBeNull() + + const { data } = await from().select('row_key') + expect(data).toEqual([]) + }) +}) diff --git a/packages/stack/__tests__/supabase-v3-select-star.test.ts b/packages/stack/__tests__/supabase-v3-select-star.test.ts new file mode 100644 index 000000000..3e1b3522d --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-select-star.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' +import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3' + +/** + * Supabase double that records the select string AND simulates the part of + * PostgREST this adapter depends on: a `alias:column::jsonb` token returns the + * value under `alias`, a bare `column::jsonb` (or `column`) under `column`. + * + * Simulating the rename is the point. The alias is the ONLY thing that makes a + * renamed column come back under its JS property name, and it is produced + * server-side — a double that echoes rows verbatim would assert nothing about + * it. `dbRows` are keyed by DB column name, exactly as Postgres stores them. + */ +function mockSupabase(dbRows: Record[] = []) { + const selects: string[] = [] + + const project = (select: string) => + dbRows.map((dbRow) => { + const row: Record = {} + for (const token of select.split(',')) { + const bare = token.trim().replace(/::jsonb$/, '') + const [alias, column] = bare.includes(':') + ? bare.split(':') + : [bare, bare] + row[alias] = dbRow[column] + } + return row + }) + + // biome-ignore lint/suspicious/noExplicitAny: test double + const qb: any = { + select: (s: string) => { + selects.push(s) + return qb + }, + then: ( + onfulfilled?: ((v: unknown) => unknown) | null, + onrejected?: ((r: unknown) => unknown) | null, + ) => + Promise.resolve({ + data: project(selects[0] ?? ''), + error: null, + count: null, + status: 200, + statusText: 'OK', + }).then(onfulfilled, onrejected), + } + return { client: { from: () => qb }, selects } +} + +/** Identity decrypt — this suite is about column naming, not envelopes. */ +function mockEncryptionClient() { + const operation = (data: T) => ({ + withLockContext: () => operation(data), + audit: () => operation(data), + then: (f?: ((v: { data: T }) => unknown) | null) => + Promise.resolve({ data }).then(f), + }) + return { + decryptModel: (m: Record) => operation(m), + bulkDecryptModels: (m: Record[]) => operation(m), + } as unknown as EncryptionClient +} + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + amount: types.IntegerOrd('amount'), + createdAt: types.TimestampOrd('created_at'), +}) + +// DB column names as introspection would report them (plaintext id/note included). +const ALL_COLUMNS = ['id', 'email', 'amount', 'created_at', 'note'] + +function builderFor( + supabase: ReturnType, + allColumns: string[] | null = ALL_COLUMNS, +) { + return new EncryptedQueryBuilderV3Impl( + 'users', + users, + mockEncryptionClient(), + supabase.client, + allColumns, + ) +} + +describe("v3 select('*') expansion", () => { + it('expands * to the full column list and casts encrypted columns', async () => { + const supabase = mockSupabase() + await builderFor(supabase).select('*') + + // `created_at` is aliased back to its JS property name; `id`/`note` are + // plaintext passthrough and `email`/`amount` are named identically in both. + expect(supabase.selects[0]).toBe( + 'id, email::jsonb, amount::jsonb, createdAt:created_at::jsonb, note', + ) + }) + + it('no-arg select() behaves exactly like select("*")', async () => { + const supabase = mockSupabase() + await builderFor(supabase).select() + + expect(supabase.selects[0]).toBe( + 'id, email::jsonb, amount::jsonb, createdAt:created_at::jsonb, note', + ) + }) + + it("still throws select('*') when no column list is available", async () => { + const supabase = mockSupabase() + const builder = builderFor(supabase, null) + + expect(() => builder.select('*')).toThrow(/select\('\*'\)/) + // v2 regression: a bare select() takes the same path and throws the same way. + expect(() => builder.select()).toThrow(/select\('\*'\)/) + }) + + it("throws select('*') for an empty column list, not an empty select", async () => { + // The guard is `=== null || .length === 0`. Only the null arm has a caller + // today (`index.ts` passes `?? null`), so nothing stops a future `?? []` + // from turning an unusable `*` into a silent zero-column select. + const supabase = mockSupabase() + const builder = builderFor(supabase, []) + + expect(() => builder.select('*')).toThrow(/select\('\*'\)/) + expect(() => builder.select()).toThrow(/select\('\*'\)/) + }) +}) + +describe("REGRESSION: select('*') keys rows by JS property, not DB column", () => { + // A declared column whose property name differs from its DB column is the + // only case that can drift: synthesized columns always have property == DB + // name. Before the `expandAllColumns` override, `select('*')` emitted the + // unaliased `created_at::jsonb`, so rows came back keyed `created_at` while + // the declared row type promises `createdAt` — `row.createdAt` was silently + // `undefined` for a field TypeScript guaranteed as a Date. + const dbRow = { + id: 1, + email: 'a@b.com', + amount: 30, + created_at: '2026-01-02T03:04:05.000Z', + note: 'hi', + } + + it('returns the renamed column under its property name', async () => { + const supabase = mockSupabase([dbRow]) + const { data } = await builderFor(supabase).select('*') + + expect(data![0].createdAt).toBeInstanceOf(Date) + expect((data![0].createdAt as Date).toISOString()).toBe( + '2026-01-02T03:04:05.000Z', + ) + expect(data![0]).not.toHaveProperty('created_at') + }) + + it("select('*') and an explicit property select agree on row shape", async () => { + const star = mockSupabase([dbRow]) + const explicit = mockSupabase([dbRow]) + + const { data: starData } = await builderFor(star).select('*') + const { data: explicitData } = await builderFor(explicit).select( + 'id, email, amount, createdAt, note', + ) + + expect(Object.keys(starData![0]).sort()).toEqual( + Object.keys(explicitData![0]).sort(), + ) + expect(starData![0]).toEqual(explicitData![0]) + }) + + it('leaves plaintext passthrough columns under their DB name', async () => { + const supabase = mockSupabase([dbRow]) + const { data } = await builderFor(supabase).select('*') + + expect(data![0].id).toBe(1) + expect(data![0].note).toBe('hi') + }) + + it('does not treat a DB column named like an Object.prototype member as a property', async () => { + // `dbToProp['constructor']` would resolve to Object.prototype.constructor on + // a plain object, emitting a function where a column name belongs. + const supabase = mockSupabase() + const builder = new EncryptedQueryBuilderV3Impl( + 'weird', + encryptedTable('weird', { email: types.TextSearch('email') }), + mockEncryptionClient(), + supabase.client, + ['constructor', 'toString', 'email'], + ) + await builder.select('*') + + expect(supabase.selects[0]).toBe('constructor, toString, email::jsonb') + }) +}) diff --git a/packages/stack/__tests__/supabase-v3.test-d.ts b/packages/stack/__tests__/supabase-v3.test-d.ts new file mode 100644 index 000000000..647743821 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3.test-d.ts @@ -0,0 +1,214 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { encryptedTable, type InferPlaintext, types } from '@/eql/v3' +import { encryptedColumn, encryptedTable as v2EncryptedTable } from '@/schema' +import { + type EncryptedSupabaseResponse, + encryptedSupabase, + encryptedSupabaseV3, + type SupabaseClientLike, +} from '@/supabase' + +declare const supabaseClient: SupabaseClientLike + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + amount: types.IntegerOrd('amount'), + createdAt: types.TimestampOrd('created_at'), + active: types.Boolean('active'), + nickname: types.TextEq('nickname'), + bio: types.TextMatch('bio'), +}) + +type UserRow = InferPlaintext + +describe('encryptedSupabaseV3 typed surface (with schemas)', () => { + it('rows carry each column its domain plaintext type', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + const { data } = await supabase.from('users').select('id, email, amount') + expectTypeOf(data![0].email).toEqualTypeOf() + expectTypeOf(data![0].amount).toEqualTypeOf() + expectTypeOf(data![0].createdAt).toEqualTypeOf() + expectTypeOf(data![0].active).toEqualTypeOf() + }) + + it('pins filter value types to the column plaintext', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + const builder = supabase.from('users') + builder.eq('email', 'a@b.com') + builder.gte('amount', 10) + builder.gte('createdAt', new Date()) + // @ts-expect-error — email is a string column + builder.eq('email', 42) + // @ts-expect-error — amount is a number column + builder.gte('amount', 'ten') + }) + + it('rejects filters on storage-only columns at the type level', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + const builder = supabase.from('users') + // @ts-expect-error — active is public.boolean (storage only) + builder.eq('active', true) + // @ts-expect-error — storage-only column is excluded from filter keys + builder.is('active', true) + }) + + it('rejects order() on every encrypted column at the type level', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + const builder = supabase.from('users') + // No btree opclass exists on any EQL v3 domain, so `ORDER BY col` sorts the + // ciphertext envelope. This holds for the ORE-capable domains too — which is + // the whole point: those are the ones where the wrongness is silent. + // @ts-expect-error — timestamp_ord: ORDER BY sorts ciphertext + builder.order('createdAt') + // @ts-expect-error — integer_ord: ORDER BY sorts ciphertext + builder.order('amount', { ascending: false }) + // @ts-expect-error — active is public.boolean: storage only + builder.order('active') + }) + + it('still allows order() on a plaintext row key', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + // `note` is not a declared column, so it is a plaintext passthrough. + const builder = supabase.from<{ note: string }>('users') + builder.order('note') + }) + + it('narrows contains() to freeTextSearch-capable columns', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + const builder = supabase.from('users') + // public.text_search — equality + orderAndRange + freeTextSearch + builder.contains('email', 'ada') + // public.text_match — freeTextSearch only + builder.contains('bio', 'ada') + // @ts-expect-error — nickname is public.text_eq: no match index + builder.contains('nickname', 'ada') + // @ts-expect-error — amount is public.integer_ord: no match index + builder.contains('amount', 'ada') + // @ts-expect-error — active is public.boolean (storage only) + builder.contains('active', 'ada') + }) + + it('does not expose like/ilike on the v3 builder, at any chain depth', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + const builder = supabase.from('users') + // @ts-expect-error — v3 free-text search is token containment: use contains() + builder.like('email', '%ada%') + // @ts-expect-error — v3 free-text search is token containment: use contains() + builder.ilike('email', '%ada%') + // The chain must not launder the removal back in via a widened return type. + // @ts-expect-error — use contains() + builder.select('id').eq('email', 'a@b.com').like('email', '%ada%') + // contains() survives the chain. + builder.select('id').eq('email', 'a@b.com').contains('email', 'ada') + }) + + it('accepts plaintext model values on insert', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + const builder = supabase.from('users') + builder.insert({ email: 'a@b.com', amount: 3, createdAt: new Date() }) + // @ts-expect-error — createdAt is a Date column + builder.insert({ createdAt: 'not-a-date' }) + }) + + it('resolves responses to the row type', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + expectTypeOf( + supabase.from('users').select('id, email'), + ).resolves.toEqualTypeOf>() + }) + + it('keeps undeclared tables reachable on the untyped surface (the gradient)', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + // `orders` was introspected but not declared. It MUST still compile, falling + // through to the untyped `from(table: string)` overload — declaring one + // table must not make every other table unreachable. + const builder = supabase.from('orders') + builder.eq('anything', 1) + const { data } = await builder.select('id') + expectTypeOf(data![0]).toEqualTypeOf>() + }) + + it('rejects a v2 table in schemas', async () => { + const v2Table = v2EncryptedTable('users', { + email: encryptedColumn('email').equality(), + }) + // The directive sits on the call, not the property: no overload accepts a + // v2 table, so TypeScript reports the failure at the call expression. + // @ts-expect-error — schemas only accepts v3 tables + await encryptedSupabaseV3(supabaseClient, { + schemas: { users: v2Table }, + }) + }) +}) + +describe('encryptedSupabaseV3 untyped surface (no schemas)', () => { + it('rows default to Record and from accepts any string', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient) + const builder = supabase.from('anything') + const { data } = await builder.select('id, email') + expectTypeOf(data![0]).toEqualTypeOf>() + builder.eq('whatever', 123) + }) + + it('accepts an explicit row generic', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient) + const builder = supabase.from<{ id: number; email: string }>('users') + builder.eq('email', 'a@b.com') + builder.eq('id', 1) + // @ts-expect-error — not a row key + builder.eq('missing', 1) + }) + + it('exposes contains and not like/ilike, exactly as the typed surface does', async () => { + // Without `schemas` there is no capability information, so `contains` cannot + // be narrowed — but the DIALECT is still v3, so `like`/`ilike` must be gone. + // Otherwise the untyped surface silently hands back the v2 builder type. + const supabase = await encryptedSupabaseV3(supabaseClient) + const builder = supabase.from<{ id: number; email: string }>('users') + builder.contains('email', 'ada') + // @ts-expect-error — v3 free-text search is token containment: use contains() + builder.like('email', '%ada%') + // @ts-expect-error — v3 free-text search is token containment: use contains() + builder.ilike('email', '%ada%') + }) + + it('keeps like/ilike on the v2 builder', () => { + const v2Users = v2EncryptedTable('users', { + email: encryptedColumn('email').freeTextSearch(), + }) + const v2 = encryptedSupabase({ + encryptionClient: {} as never, + supabaseClient, + }) + const builder = v2.from<{ email: string }>('users', v2Users) + builder.like('email', '%ada%') + builder.ilike('email', '%ada%') + // @ts-expect-error — contains is the v3 dialect's method + builder.contains('email', 'ada') + }) + + it('supports a no-arg select(), like supabase-js', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient) + supabase.from('users').select() + }) +}) diff --git a/packages/stack/__tests__/supabase-verify.test.ts b/packages/stack/__tests__/supabase-verify.test.ts new file mode 100644 index 000000000..f4b0aef48 --- /dev/null +++ b/packages/stack/__tests__/supabase-verify.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import type { IntrospectionResult } from '@/supabase/introspect' +import { verifyDeclaredSchemas } from '@/supabase/verify' + +const introspection: IntrospectionResult = [ + { + tableName: 'users', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'email', domainName: 'text_search' }, + { columnName: 'amount', domainName: 'integer_ord' }, + ], + }, +] + +describe('verifyDeclaredSchemas', () => { + it('passes when every declared column matches its introspected domain', () => { + const users = encryptedTable('users', { + email: types.TextSearch('email'), + amount: types.IntegerOrd('amount'), + }) + expect(() => verifyDeclaredSchemas({ users }, introspection)).not.toThrow() + }) + + it('passes when only a subset of encrypted columns is declared', () => { + const users = encryptedTable('users', { email: types.TextSearch('email') }) + expect(() => verifyDeclaredSchemas({ users }, introspection)).not.toThrow() + }) + + it('throws naming the table when a declared table is absent', () => { + const orders = encryptedTable('orders', { + total: types.IntegerOrd('total'), + }) + expect(() => verifyDeclaredSchemas({ orders }, introspection)).toThrow( + /table "orders"/, + ) + }) + + it('throws naming the column when a declared column is absent', () => { + const users = encryptedTable('users', { missing: types.TextEq('missing') }) + expect(() => verifyDeclaredSchemas({ users }, introspection)).toThrow( + /users\.missing/, + ) + }) + + it('throws naming both domains when the domain differs', () => { + // email is actually text_search; declaring text_eq must fail at startup. + const users = encryptedTable('users', { email: types.TextEq('email') }) + expect(() => verifyDeclaredSchemas({ users }, introspection)).toThrow( + /text_eq.*text_search|text_search.*text_eq/, + ) + }) + + // The `actual ?? '(none)'` arm: `id` exists but carries no domain. Declaring + // it encrypted must fail at construction, not as a 23514 CHECK violation on + // the first insert. + it('throws "(none)" when a declared column is plaintext in the database', () => { + const users = encryptedTable('users', { id: types.IntegerEq('id') }) + expect(() => verifyDeclaredSchemas({ users }, introspection)).toThrow( + /users\.id.*\(none\).*integer_eq/, + ) + }) +}) + +// Two declared properties resolving to the same DB column pass verification — +// each checks out against the real column — and only blow up later, inside +// `EncryptedTable.build()`, with an error from the eql/v3 layer that names +// neither the colliding properties nor the `schemas` entry they came from. +describe('duplicate declared DB names', () => { + it('throws naming the table and both colliding properties', () => { + const users = encryptedTable('users', { + a: types.TextSearch('email'), + b: types.TextSearch('email'), + }) + expect(() => verifyDeclaredSchemas({ users }, introspection)).toThrow( + /users.*email.*"a".*"b"|users.*"a".*"b".*email/, + ) + }) + + it('allows two properties on different DB columns', () => { + const users = encryptedTable('users', { + a: types.TextSearch('email'), + b: types.IntegerOrd('amount'), + }) + expect(() => verifyDeclaredSchemas({ users }, introspection)).not.toThrow() + }) +}) diff --git a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts b/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts index 1252b8378..5ff7cb9c1 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts @@ -14,7 +14,7 @@ * each kind of v3 domain in SQL — useful reference for engineers and agents * writing new domain-consuming code. * - * ONE mega table (all 35 domains, one column each, like `matrix-live.test.ts`), + * ONE mega table (all 39 domains, one column each, like `matrix-live.test.ts`), * two seeded rows (`samples[0]` / `samples[1]` from the catalog — every domain * has at least two), and per domain one proof per query permutation its indexes * support — proving each selects the expected row and not the other. Beyond the @@ -346,7 +346,7 @@ afterAll(async () => { await sql.end() }, 30000) -describeLivePg('v3 matrix live Postgres coverage (all 35 domains)', () => { +describeLivePg('v3 matrix live Postgres coverage (all 39 domains)', () => { it.each( eqDomains, )('%s: eql_v3.eq(col, operand) selects the exact row', async (eqlType) => { diff --git a/packages/stack/__tests__/v3-matrix/matrix-live.test.ts b/packages/stack/__tests__/v3-matrix/matrix-live.test.ts index c3189cace..ba08e2656 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-live.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-live.test.ts @@ -2,9 +2,9 @@ * Live round-trip half of the type-driven v3 matrix — closes the "live cliff". * * The structural `matrix.test.ts` proves builder/eqlType/capabilities/`build()` - * wiring for all 35 domains WITHOUT ever touching real FFI ciphertext. This file + * wiring for all 39 domains WITHOUT ever touching real FFI ciphertext. This file * completes the picture: every domain × every catalog `sample` is encrypted and - * decrypted through a live CipherStash client, so all 35 domains gain live + * decrypted through a live CipherStash client, so all 39 domains gain live * behavioral proof (the Rust harness's whole premise) — not just 7. * * Round-trips go through the MODEL path (`encryptModel`/`decryptModel`) so @@ -35,7 +35,7 @@ import { // `errorSamples` field literally lack that key, rather than typing it // `undefined`). Explicit type arguments pin `typedEntries`'s inferred `V` back // to the declared `DomainSpec` shape — without them, `spec` below is inferred -// as the union of all 35 distinct row literals, and `.errorSamples` fails to +// as the union of all 39 distinct row literals, and `.errorSamples` fails to // resolve on members that omit the key (`tsc` catches this; `vitest run` // alone would not, since it only transpiles `.test.ts` files, never // typechecks them). diff --git a/packages/stack/package.json b/packages/stack/package.json index edbcbc1a0..d6b6fcb4b 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -239,13 +239,17 @@ "devDependencies": { "@cipherstash/eql": "3.0.0-alpha.3", "@clack/prompts": "^1.4.0", + "@supabase/postgrest-js": "2.105.4", "@supabase/supabase-js": "^2.105.4", + "@types/pg": "^8.20.0", "@types/uuid": "^11.0.0", "dotenv": "17.4.2", "drizzle-orm": "^0.45.2", "execa": "^9.5.2", + "fast-check": "^4.8.0", "fta-cli": "3.0.0", "json-schema-to-typescript": "^15.0.2", + "pg": "8.20.0", "postgres": "^3.4.8", "tsup": "catalog:repo", "tsx": "catalog:repo", @@ -274,7 +278,8 @@ }, "peerDependencies": { "@supabase/supabase-js": ">=2", - "drizzle-orm": ">=0.33" + "drizzle-orm": ">=0.33", + "pg": ">=8" }, "peerDependenciesMeta": { "drizzle-orm": { @@ -282,6 +287,9 @@ }, "@supabase/supabase-js": { "optional": true + }, + "pg": { + "optional": true } }, "engines": { diff --git a/packages/stack/src/eql/v3/domain-registry.ts b/packages/stack/src/eql/v3/domain-registry.ts new file mode 100644 index 000000000..01bc01ba4 --- /dev/null +++ b/packages/stack/src/eql/v3/domain-registry.ts @@ -0,0 +1,97 @@ +import type { AnyEncryptedV3Column } from './columns' +import { types } from './types' + +/** A factory that builds a concrete v3 column for a given DB column name. */ +export type V3ColumnFactory = (name: string) => AnyEncryptedV3Column + +/** + * Unqualified Postgres `domain_name` → the existing `types` factory. Values are + * the `eql/v3/types.ts` factories (which pass the literal domain constants), + * NOT direct `new EncryptedXColumn(...)` calls — the constant carried by each + * factory is what keeps the domains nominally distinct. `TextSearch` has a + * different arity, so this is a value map, not a mechanical transform. + */ +// NULL PROTOTYPE — load-bearing. A plain object literal inherits from +// Object.prototype, so `DOMAIN_REGISTRY['constructor']` returns a *function* +// and passes a truthiness check. A column whose domain is named `constructor`, +// `toString`, `valueOf` or `__proto__` would then be treated as a modelled EQL +// domain and "synthesized" from Object.prototype.constructor. `factoryForDomain` +// additionally guards with `Object.hasOwn`; both are kept — belt and braces, +// because a future refactor that drops the null prototype must not silently +// reopen the hole. +export const DOMAIN_REGISTRY: Record = Object.assign( + Object.create(null) as Record, + { + // integer + integer: types.Integer, + integer_eq: types.IntegerEq, + integer_ord_ore: types.IntegerOrdOre, + integer_ord: types.IntegerOrd, + // smallint + smallint: types.Smallint, + smallint_eq: types.SmallintEq, + smallint_ord_ore: types.SmallintOrdOre, + smallint_ord: types.SmallintOrd, + // bigint + bigint: types.Bigint, + bigint_eq: types.BigintEq, + bigint_ord_ore: types.BigintOrdOre, + bigint_ord: types.BigintOrd, + // date + date: types.Date, + date_eq: types.DateEq, + date_ord_ore: types.DateOrdOre, + date_ord: types.DateOrd, + // timestamp + timestamp: types.Timestamp, + timestamp_eq: types.TimestampEq, + timestamp_ord_ore: types.TimestampOrdOre, + timestamp_ord: types.TimestampOrd, + // numeric + numeric: types.Numeric, + numeric_eq: types.NumericEq, + numeric_ord_ore: types.NumericOrdOre, + numeric_ord: types.NumericOrd, + // text + text: types.Text, + text_eq: types.TextEq, + text_match: types.TextMatch, + text_ord_ore: types.TextOrdOre, + text_ord: types.TextOrd, + text_search: types.TextSearch, + // boolean + boolean: types.Boolean, + // real + real: types.Real, + real_eq: types.RealEq, + real_ord_ore: types.RealOrdOre, + real_ord: types.RealOrd, + // double + double: types.Double, + double_eq: types.DoubleEq, + double_ord_ore: types.DoubleOrdOre, + double_ord: types.DoubleOrd, + }, +) + +/** Strip a leading `public.` schema qualifier from a qualified `eqlType`. */ +export function stripDomainSchema(eqlType: string): string { + return eqlType.startsWith('public.') + ? eqlType.slice('public.'.length) + : eqlType +} + +/** + * Look up the factory for an unqualified domain name, or `undefined`. + * + * `Object.hasOwn` is required, not decorative: without it a domain named + * `constructor` / `toString` / `valueOf` / `__proto__` resolves to an inherited + * `Object.prototype` member and violates the "unknown domain = plaintext" rule. + */ +export function factoryForDomain( + domainName: string, +): V3ColumnFactory | undefined { + return Object.hasOwn(DOMAIN_REGISTRY, domainName) + ? DOMAIN_REGISTRY[domainName] + : undefined +} diff --git a/packages/stack/src/eql/v3/table.ts b/packages/stack/src/eql/v3/table.ts index 8ea144971..467249250 100644 --- a/packages/stack/src/eql/v3/table.ts +++ b/packages/stack/src/eql/v3/table.ts @@ -25,7 +25,14 @@ export class EncryptedTable { ) {} build(): TableDefinition { - const builtColumns: Record = {} + // NULL PROTOTYPE — load-bearing, for the same reason as + // {@link buildColumnKeyMap}. Keys are DB column names, which the database + // supplies. On a plain object literal, `builtColumns['__proto__'] = schema` + // REPARENTS the object rather than adding an own key, so the column never + // reaches the encrypt config — `decryptModel` then skips it and its + // ciphertext is returned undecrypted. `encryptedTable()` rejects such names + // as JS properties, but nothing constrains a table's DB column names. + const builtColumns: Record = Object.create(null) for (const builder of Object.values(this.columnBuilders)) { // Key by the column's DB name (`getName()`), NOT the JS property name. // `encrypt`/`decrypt` look columns up in the config by `column.getName()`, @@ -56,9 +63,19 @@ export class EncryptedTable { * encrypt config and FFI by DB name — `build()` keys columns by DB name, so * the two only agree when property == name. This recovers the mapping that * `build()` discards. + * + * NULL PROTOTYPE — load-bearing. Callers index this map by a column name that + * ultimately comes from the database (`addJsonbCastsV3`, `filterColumnName`, + * the mutation transform). On a plain object literal, a column named + * `constructor` / `toString` / `valueOf` / `__proto__` resolves to an + * inherited `Object.prototype` member, which is truthy — so a *plaintext* + * column with such a name would be mistaken for a mapped encrypted column and + * its `Object.prototype` value interpolated into the emitted select string. + * `encryptedTable()` rejects such names as JS *properties*, but nothing + * constrains the DB column names a table may contain. */ buildColumnKeyMap(): Record { - const map: Record = {} + const map = Object.create(null) as Record for (const [property, builder] of Object.entries(this.columnBuilders)) { map[property] = builder.getName() } diff --git a/packages/stack/src/supabase/helpers.ts b/packages/stack/src/supabase/helpers.ts index 82ea2a101..281f32851 100644 --- a/packages/stack/src/supabase/helpers.ts +++ b/packages/stack/src/supabase/helpers.ts @@ -1,6 +1,12 @@ import type { EncryptedTable, EncryptedTableColumn } from '@/schema' import type { QueryTypeName } from '@/types' -import type { FilterOp, PendingOrCondition } from './types' +import type { + DbFilterString, + DbPendingOrCondition, + DbSelect, + FilterOp, + PendingOrCondition, +} from './types' /** * Get the names of all encrypted columns defined in a table schema. @@ -33,7 +39,8 @@ export function isEncryptedColumn( export function addJsonbCasts( columns: string, encryptedColumnNames: string[], -): string { +): DbSelect { + // The mapping below emits DB-space tokens; the brand is asserted once, here. return columns .split(',') .map((col) => { @@ -65,7 +72,111 @@ export function addJsonbCasts( return col }) - .join(',') + .join(',') as DbSelect +} + +/** + * Resolve a select token to its DB column name, or `undefined`. + * + * `Object.hasOwn` is required, not decorative: the token comes from the caller's + * select string (or, for `select('*')`, from the database's own column list). + * `buildColumnKeyMap()` already returns a null-prototype map, but an inherited + * `Object.prototype` member is truthy, so a plain-object map would let a column + * named `constructor` interpolate `function Object() { … }` into the emitted + * select string. Both guards are kept — a future refactor that drops the null + * prototype must not silently reopen the hole. + */ +function lookupDbName( + propToDb: Record, + token: string, +): string | undefined { + return Object.hasOwn(propToDb, token) ? propToDb[token] : undefined +} + +/** + * Parse a Supabase select string and add `::jsonb` casts to encrypted EQL v3 + * columns, resolving JS property names to DB column names via PostgREST + * aliasing. + * + * Input: `'id, email, createdAt'` with `{ email: 'email', createdAt: 'created_at' }` + * Output: `'id, email::jsonb, createdAt:created_at::jsonb'` + * + * - A property whose DB name differs is emitted as `prop:db_name::jsonb` + * (PostgREST rename syntax), so result rows come back keyed by the JS + * property name. + * - A DB column name used directly is cast in place (`db_name::jsonb`). + * - Tokens that already carry a cast, or contain parens/dots (functions, + * foreign tables), are left untouched — same rules as the v2 helper. + */ +export function addJsonbCastsV3( + columns: string, + propToDb: Record, +): DbSelect { + const dbNames = new Set(Object.values(propToDb)) + + return columns + .split(',') + .map((col) => { + const trimmed = col.trim() + + if (!trimmed) return col + if (trimmed.includes('::')) return col + if (trimmed.includes('(') || trimmed.includes('.')) return col + + const leadingWhitespace = col.match(/^(\s*)/)?.[1] ?? '' + + // Already-aliased token: `alias:column` + const aliasMatch = trimmed.match( + /^([A-Za-z_][A-Za-z0-9_]*):([A-Za-z_][A-Za-z0-9_]*)$/, + ) + if (aliasMatch) { + const [, alias, name] = aliasMatch + const db = + lookupDbName(propToDb, name) ?? (dbNames.has(name) ? name : undefined) + if (db !== undefined) { + return `${leadingWhitespace}${alias}:${db}::jsonb` + } + return col + } + + const db = lookupDbName(propToDb, trimmed) + if (db !== undefined) { + return db === trimmed + ? `${leadingWhitespace}${trimmed}::jsonb` + : `${leadingWhitespace}${trimmed}:${db}::jsonb` + } + + if (dbNames.has(trimmed)) { + return `${leadingWhitespace}${trimmed}::jsonb` + } + + return col + }) + .join(',') as DbSelect +} + +/** + * Whether a filter operand is a value to be encrypted and compared, rather than + * a SQL predicate. Every term collector must consult this before pushing an + * encryption term. + * + * - `is` is a predicate, not a comparison: PostgREST accepts only + * `null`/`true`/`false`/`unknown` after it, so an encrypted operand is + * rejected. The operand is non-null for `is(col, false)`, so the operator + * must be checked independently of the value. + * - A `null`/`undefined` operand is SQL NULL. A null plaintext is stored as a + * NULL column, not as ciphertext, so it is found with an unencrypted + * `IS NULL` — encrypting the operand can never match anything. + * + * `operator` is widened to `string` because raw `filter()` accepts any + * PostgREST operator, not just a {@link FilterOp}. + */ +export function isEncryptableTerm( + operator: FilterOp | string, + value: unknown, +): boolean { + if (operator === 'is') return false + return value != null } /** @@ -80,6 +191,7 @@ export function mapFilterOpToQueryType(op: FilterOp): QueryTypeName { return 'equality' case 'like': case 'ilike': + case 'contains': return 'freeTextSearch' case 'gt': case 'gte': @@ -95,7 +207,15 @@ export function mapFilterOpToQueryType(op: FilterOp): QueryTypeName { * Parse a Supabase `.or()` filter string into structured conditions. * * Input: `'email.eq.john@example.com,name.ilike.%john%'` - * Output: `[{ column: 'email', op: 'eq', value: 'john@example.com' }, { column: 'name', op: 'ilike', value: '%john%' }]` + * Output: `[{ column: 'email', op: 'eq', negate: false, value: 'john@example.com' }, …]` + * + * PostgREST spells negation `column.not..`. It is lifted onto its own + * `negate` flag rather than left as the operator: the term collector keys the + * `in`-list split on `op === 'in'`, so a negated list parsed as + * `{ op: 'not', value: 'in.(a,b)' }` skipped the split and encrypted the literal + * string `in.(a,b)` as a single plaintext — a filter that silently matched + * nothing. Only a `not` in the OPERATOR position is a prefix; a column or value + * of that name is untouched. */ export function parseOrString(orString: string): PendingOrCondition[] { const conditions: PendingOrCondition[] = [] @@ -106,12 +226,24 @@ export function parseOrString(orString: string): PendingOrCondition[] { const trimmed = part.trim() if (!trimmed) continue - // Format: column.op.value + // Format: column.op.value — or column.not.op.value const firstDot = trimmed.indexOf('.') if (firstDot === -1) continue const column = trimmed.slice(0, firstDot) - const rest = trimmed.slice(firstDot + 1) + let rest = trimmed.slice(firstDot + 1) + + let negate = false + if (rest.startsWith('not.')) { + const afterNot = rest.slice('not.'.length) + // `col.not..` needs an operator AND a value after the prefix. + // Without the second dot, `not` IS the operator (or the string is + // malformed) — leave it alone rather than swallow the operator. + if (afterNot.includes('.')) { + negate = true + rest = afterNot + } + } const secondDot = rest.indexOf('.') if (secondDot === -1) continue @@ -122,7 +254,7 @@ export function parseOrString(orString: string): PendingOrCondition[] { // Handle special value formats const parsedValue = parseOrValue(value) - conditions.push({ column, op, value: parsedValue }) + conditions.push({ column, op, negate, value: parsedValue }) } return conditions @@ -131,13 +263,17 @@ export function parseOrString(orString: string): PendingOrCondition[] { /** * Rebuild an `.or()` string from structured conditions. */ -export function rebuildOrString(conditions: PendingOrCondition[]): string { +export function rebuildOrString( + conditions: DbPendingOrCondition[], +): DbFilterString { + // Callers must hand DB-space `c.column` values (see `transformOrConditions`). return conditions .map((c) => { const value = formatOrValue(c.value) - return `${c.column}.${c.op}.${value}` + const op = c.negate ? `not.${c.op}` : c.op + return `${c.column}.${op}.${value}` }) - .join(',') + .join(',') as DbFilterString } // --------------------------------------------------------------------------- @@ -150,8 +286,19 @@ function splitOrString(input: string): string[] { let depth = 0 let inQuotes = false + let escaped = false + for (const char of input) { - if (char === '"' && depth === 0) { + // A backslash-escaped character is literal: `\"` must NOT toggle `inQuotes`, + // or an escaped quote inside an encrypted operand would end the token and + // the next comma would split mid-value. + if (escaped) { + escaped = false + current += char + } else if (char === '\\' && inQuotes) { + escaped = true + current += char + } else if (char === '"' && depth === 0) { inQuotes = !inQuotes current += char } else if (char === '(' && !inQuotes) { @@ -176,9 +323,11 @@ function splitOrString(input: string): string[] { } function parseOrValue(value: string): unknown { - // Handle double-quoted values (PostgREST quoting for reserved characters) - if (value.startsWith('"') && value.endsWith('"')) { - return value.slice(1, -1) + // Handle double-quoted values (PostgREST quoting for reserved characters). + // Must undo `escapeOrValue`, or a parse → rebuild round-trip doubles every + // backslash. The two functions are only correct as a pair. + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + return unescapeOrValue(value.slice(1, -1)) } // Handle parenthesized lists: (val1,val2,val3) @@ -200,14 +349,30 @@ function parseOrValue(value: string): unknown { } /** - * PostgREST reserved characters that require double-quoting in filter values. + * PostgREST characters that require double-quoting in filter values. + * + * `"` and `\` are here because a value containing either must be quoted AND + * escaped: unquoted, a bare `"` is a syntax error mid-value; quoted but + * unescaped, it terminates the value early. Every v3 encrypted operand is + * `JSON.stringify(envelope)`, so this is the common case, not the exotic one. + * * See: https://docs.postgrest.org/en/latest/references/api/tables_views.html */ -const POSTGREST_RESERVED = /[,().]/ +const POSTGREST_RESERVED = /["\\,().]/ + +/** Escape `\` first, then `"` — the reverse order would double-escape. */ +function escapeOrValue(str: string): string { + return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +/** Inverse of {@link escapeOrValue}: consume `\x` as a literal `x`. */ +function unescapeOrValue(str: string): string { + return str.replace(/\\(.)/g, '$1') +} function formatOrValue(value: unknown): string { if (Array.isArray(value)) { - return `(${value.join(',')})` + return `(${value.map((v) => formatOrValue(v)).join(',')})` } if (value === null) return 'null' if (value === true) return 'true' @@ -219,7 +384,7 @@ function formatOrValue(value: unknown): string { // This is required for encrypted values (JSON with commas, braces, etc.) // and is safe for all string values per PostgREST spec. if (POSTGREST_RESERVED.test(str)) { - return `"${str}"` + return `"${escapeOrValue(str)}"` } return str diff --git a/packages/stack/src/supabase/index.ts b/packages/stack/src/supabase/index.ts index 471876ff1..28e6ae715 100644 --- a/packages/stack/src/supabase/index.ts +++ b/packages/stack/src/supabase/index.ts @@ -1,9 +1,21 @@ +import { Encryption } from '@/encryption' import type { EncryptedTable, EncryptedTableColumn } from '@/schema' +import type { UnmodelledColumn } from './introspect' +import { introspect } from './introspect' import { EncryptedQueryBuilderImpl } from './query-builder' +import { EncryptedQueryBuilderV3Impl } from './query-builder-v3' +import { mergeDeclaredTables, synthesizeTables } from './schema-builder' import type { + EncryptedQueryBuilder, EncryptedSupabaseConfig, EncryptedSupabaseInstance, + EncryptedSupabaseV3Instance, + EncryptedSupabaseV3Options, + SupabaseClientLike, + TypedEncryptedSupabaseV3Instance, + V3Schemas, } from './types' +import { verifyDeclaredSchemas } from './verify' /** * Create an encrypted Supabase wrapper that transparently handles encryption @@ -58,12 +70,216 @@ export function encryptedSupabase( } } +/** + * Throw if `tableName` carries an EQL v3 column this SDK version cannot model. + * + * Such a column is a silent data leak: it never enters the encrypt config, but + * it IS in `allColumns`, so `select('*')` selects it and `decryptModel` skips + * it — the caller gets raw ciphertext typed as data. (Writes fail loudly on the + * domain CHECK; only reads are silent.) + * + * Keyed by table, not applied to the whole schema, because the hazard exists + * only for a table the caller actually queries. An `audit_log.payload + * public.json` column on a table you never name cannot leak, and must not stop + * you constructing a client for `users`. + */ +function assertTableIsModelled( + tableName: string, + unmodelled: Map, +): void { + const columns = unmodelled.get(tableName) + if (!columns?.length) return + const detail = columns + .map((c) => `"${tableName}.${c.columnName}" (public.${c.domainName})`) + .join(', ') + throw new Error( + `[supabase v3]: table "${tableName}" has EQL v3 columns this @cipherstash/stack version does not model: ${detail}. Upgrade the package, or stop using this table — the columns cannot be plaintext passthroughs (reads would return ciphertext undecrypted).`, + ) +} + +/** + * Create an encrypted Supabase wrapper for **EQL v3** schemas by introspecting + * the database at connect time. Detects EQL v3 columns by their Postgres domain + * and derives each column's encryption config from it — callers no longer pass a + * schema to `from()`. Supplying `schemas` is optional: it adds compile-time + * types and verifies the declared tables against the database at construction. + * + * Requires a Postgres connection (`options.databaseUrl` or `DATABASE_URL`) for + * introspection, so it cannot run in a Worker or the browser. + * + * A column is an EQL v3 column when its type is one of the `public` domains the + * EQL v3 bundle installs. The domain names the capabilities, and introspection + * turns it into the column's encryption config: + * + * ```sql + * CREATE TABLE users ( + * id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + * email public.text_search, -- equality, ordering, free-text match + * age public.integer_ord, -- equality, ordering + * name text -- plaintext passthrough, untouched + * ); + * ``` + * + * @example + * ```typescript + * const supabase = await encryptedSupabaseV3(supabaseUrl, supabaseKey) + * await supabase.from('users').insert({ email: 'alice@example.com' }) + * const { data } = await supabase.from('users').select().eq('email', 'alice@example.com') + * ``` + */ +export async function encryptedSupabaseV3( + supabaseUrl: string, + supabaseKey: string, + options: EncryptedSupabaseV3Options & { schemas: S }, +): Promise> +export async function encryptedSupabaseV3( + supabaseUrl: string, + supabaseKey: string, + options?: EncryptedSupabaseV3Options, +): Promise +export async function encryptedSupabaseV3( + supabaseClient: SupabaseClientLike, + options: EncryptedSupabaseV3Options & { schemas: S }, +): Promise> +export async function encryptedSupabaseV3( + supabaseClient: SupabaseClientLike, + options?: EncryptedSupabaseV3Options, +): Promise +// The implementation's option params are `EncryptedSupabaseV3Options`, NOT ``. The no-schemas overloads take +// `EncryptedSupabaseV3Options` — i.e. ``, whose `schemas` is typed +// `undefined` — and TS2394s against an implementation param whose `schemas` is +// typed `V3Schemas`. Widening the type argument to the full constraint makes +// every overload relatable to the implementation signature. +export async function encryptedSupabaseV3( + clientOrUrl: SupabaseClientLike | string, + keyOrOptions?: string | EncryptedSupabaseV3Options, + maybeOptions?: EncryptedSupabaseV3Options, +): Promise< + EncryptedSupabaseV3Instance | TypedEncryptedSupabaseV3Instance +> { + // 1. Resolve the Supabase client + options from the overload shape. + let supabaseClient: SupabaseClientLike + let options: EncryptedSupabaseV3Options + if (typeof clientOrUrl === 'string') { + const url = clientOrUrl + const key = keyOrOptions as string + options = maybeOptions ?? {} + const { createClient } = await import('@supabase/supabase-js') + supabaseClient = createClient(url, key) as unknown as SupabaseClientLike + } else { + supabaseClient = clientOrUrl + options = + (keyOrOptions as EncryptedSupabaseV3Options) ?? {} + } + + // 2. Resolve the database URL for introspection. + const databaseUrl = options.databaseUrl ?? process.env.DATABASE_URL + if (!databaseUrl) { + throw new Error( + '[supabase v3]: no database URL — pass options.databaseUrl or set the DATABASE_URL environment variable', + ) + } + + // 3. Introspect. Unmodelled EQL columns are NOT a construction-time veto — + // they are checked per table, at the point the caller names one. + const { tables, unmodelled } = await introspect(databaseUrl) + + // 4. Synthesize; if declared, guard record keys, verify, then merge. + // A DECLARED table is one the caller named, so it is validated eagerly, + // before the encryption client is built. + let synth = synthesizeTables(tables) + if (options.schemas) { + for (const [key, table] of Object.entries(options.schemas)) { + if (key !== table.tableName) { + throw new Error( + `[supabase v3]: schemas key "${key}" does not match its table name "${table.tableName}" — the record key must equal the table's name`, + ) + } + assertTableIsModelled(key, unmodelled) + } + verifyDeclaredSchemas(options.schemas, tables) + synth = mergeDeclaredTables(synth, options.schemas) + } + + // 5. Build the raw (eqlVersion 3) encryption client from the merged tables. + // NB: `Encryption`, not `EncryptionV3` — the query builder consumes the raw + // chainable `EncryptionClient`, whereas `EncryptionV3` returns the typed + // wrapper whose `decryptModel` returns a plain Promise. Pass only + // tables that carry at least one encrypted column (`Encryption` requires a + // non-empty schema list). + const encryptionSchemas = [...synth.tables.values()].filter( + (t) => Object.keys(t.columnBuilders).length > 0, + ) + + // A database with no modelled EQL v3 columns anywhere would hand `Encryption` + // an empty array, which throws "[encryption]: At least one encryptedTable must + // be provided to initialize the encryption client" (encryption/index.ts:693). + // That message is about a caller-supplied schema list the caller never + // supplied — actively misleading here. Fail with a diagnosis instead. The + // realistic causes are: EQL v3 is not installed, the tables live outside the + // `public` schema, or the columns were never migrated to `eql_v3` domains. + if (encryptionSchemas.length === 0) { + throw new Error( + '[supabase v3]: no EQL v3 encrypted columns found in schema "public". ' + + 'Check that EQL v3 is installed (`stash eql install --eql-version 3`) ' + + 'and that at least one column uses an eql_v3 domain type.', + ) + } + + const encryptionClient = await Encryption({ + schemas: encryptionSchemas as unknown as Parameters< + typeof Encryption + >[0]['schemas'], + config: { ...options.config, eqlVersion: 3 }, + }) + + // 6. Return the instance. `from` resolves the introspected/merged table and + // threads the full column list for select('*'). Casts are localized to the + // builder/instance boundary (this-chaining does not match structurally), + // NOT `as any` — the four overloads above remain the caller-facing contract. + const instance = { + from(tableName: string) { + const table = synth.tables.get(tableName) + if (!table) { + throw new Error( + `[supabase v3]: unknown table "${tableName}" — it was not found during introspection`, + ) + } + // Unconditional: `synthesizeTables` silently drops unmodelled columns, so + // this is the only thing preventing `select('*')` from returning raw + // ciphertext for one. Never make it optional. + assertTableIsModelled(tableName, unmodelled) + const allColumns = synth.allColumns.get(tableName) ?? null + return new EncryptedQueryBuilderV3Impl( + tableName, + table, + encryptionClient, + supabaseClient, + allColumns, + ) as unknown as EncryptedQueryBuilder> + }, + } + return instance as unknown as + | EncryptedSupabaseV3Instance + | TypedEncryptedSupabaseV3Instance +} + export type { EncryptedQueryBuilder, + EncryptedQueryBuilderCore, + EncryptedQueryBuilderV3, + EncryptedQueryBuilderV3Untyped, EncryptedSupabaseConfig, EncryptedSupabaseError, EncryptedSupabaseInstance, EncryptedSupabaseResponse, + EncryptedSupabaseV3Instance, + EncryptedSupabaseV3Options, PendingOrCondition, SupabaseClientLike, + TypedEncryptedSupabaseV3Instance, + V3FilterableKeys, + V3FreeTextSearchableKeys, + V3Schemas, } from './types' diff --git a/packages/stack/src/supabase/introspect.ts b/packages/stack/src/supabase/introspect.ts new file mode 100644 index 000000000..4b58ea919 --- /dev/null +++ b/packages/stack/src/supabase/introspect.ts @@ -0,0 +1,214 @@ +import { DOMAIN_REGISTRY } from '@/eql/v3/domain-registry' + +/** One introspected column: its DB name and its `public` EQL v3 domain (or `null`). */ +export interface IntrospectedColumn { + columnName: string + domainName: string | null +} + +/** One introspected base table with its columns in ordinal order. */ +export interface IntrospectedTable { + tableName: string + columns: IntrospectedColumn[] +} + +export type IntrospectionResult = IntrospectedTable[] + +/** + * Raw `information_schema` column row. `domain_name` is the column's domain when + * that domain lives in `public`, else `NULL` (the query nulls out non-`public` + * domains — see the CASE below), so a same-named domain in another schema is + * never mistaken for an EQL v3 domain. + */ +export interface IntrospectionRow { + table_name: string + column_name: string + domain_name: string | null +} + +/** One column carrying an EQL v3 domain this SDK version cannot model. */ +export interface UnmodelledColumn { + columnName: string + domainName: string +} + +/** + * Tables, plus the EQL v3 columns this SDK cannot model, keyed by table name. + * + * `unmodelled` is deliberately a per-table index rather than a flat list: such + * a column is a silent-leak hazard ONLY for a table the caller actually + * queries, so the guard is a lookup at `from()`, not a whole-schema veto at + * construction. A table absent from this map is safe to serve. + */ +export interface IntrospectionData { + tables: IntrospectionResult + unmodelled: Map +} + +/** Raw row of {@link UNMODELLED_COLUMNS_QUERY}. */ +export interface UnmodelledRow { + table_name: string + column_name: string + domain_name: string +} + +/** Group unmodelled-column rows by table name. */ +export function groupUnmodelledRows( + rows: UnmodelledRow[], +): Map { + const byTable = new Map() + for (const row of rows) { + let cols = byTable.get(row.table_name) + if (!cols) { + cols = [] + byTable.set(row.table_name, cols) + } + cols.push({ columnName: row.column_name, domainName: row.domain_name }) + } + return byTable +} + +/** + * Group flat `information_schema` rows into tables. Row order is the query's + * `ORDER BY table_name, ordinal_position`, so pushing in order preserves both + * table grouping and per-table column order. + */ +export function groupIntrospectionRows( + rows: IntrospectionRow[], +): IntrospectionResult { + const byTable = new Map() + const order: string[] = [] + for (const row of rows) { + let cols = byTable.get(row.table_name) + if (!cols) { + cols = [] + byTable.set(row.table_name, cols) + order.push(row.table_name) + } + cols.push({ columnName: row.column_name, domainName: row.domain_name }) + } + return order.map((tableName) => ({ + tableName, + columns: byTable.get(tableName) as IntrospectedColumn[], + })) +} + +// DELIBERATE FORK of packages/cli/src/commands/init/lib/introspect.ts — keep the +// two in sync. `stack` cannot depend on `cli`, and the projections differ: the +// CLI detects v2 composites via `udt_name = 'eql_v2_encrypted'`; this reads v3 +// domains via `domain_name`. `udt_name` is `jsonb` for a v3 domain column, so it +// cannot be reused here. +const COLUMNS_QUERY = ` + SELECT + c.table_name, + c.column_name, + CASE WHEN c.domain_schema = 'public' THEN c.domain_name ELSE NULL END + AS domain_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_name = c.table_name AND t.table_schema = c.table_schema + WHERE c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY c.table_name, c.ordinal_position +` + +// The authoritative EQL-domain signal is the domain's COMMENT: every EQL v3 +// domain in the bundle is `COMMENT ON DOMAIN public. IS 'EQL…'` (89/89, +// zero exceptions). The CHECK bodies are NOT usable — they are non-uniform +// (`integer_ord` names no function; `json` calls a `public.eql_v3_*` function). +// `obj_description(oid, 'pg_type')` reads that comment for a domain type. +// +// INVERTED: rather than fetching all 89 EQL domain names and re-deriving the +// offenders client-side, push the modelled set ($1) into the predicate and +// return only the columns this SDK cannot model. The registry IS the query +// parameter, so the two cannot drift. +const UNMODELLED_COLUMNS_QUERY = ` + SELECT c.table_name, c.column_name, c.domain_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_name = c.table_name AND t.table_schema = c.table_schema + JOIN pg_type tp ON tp.typname = c.domain_name + JOIN pg_namespace ns + ON ns.oid = tp.typnamespace AND ns.nspname = c.domain_schema + WHERE c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + AND c.domain_schema = 'public' + AND tp.typtype = 'd' + AND obj_description(tp.oid, 'pg_type') LIKE 'EQL%' + AND c.domain_name <> ALL ($1::text[]) + ORDER BY c.table_name, c.ordinal_position +` + +/** `pg` ships its API on the CJS default export, not the module namespace. */ +type PgDefaultExport = typeof import('pg')['default'] + +/** + * `pg` is an optional peer dependency, so a missing install surfaces here as a + * module-resolution error. Remap it to the actionable message; let every other + * failure propagate. Guard on `err.code` rather than message text — CJS throws + * `MODULE_NOT_FOUND`, ESM throws `ERR_MODULE_NOT_FOUND`. + * + * `importPg` is injectable because `vi.mock` cannot reproduce a module that + * fails to resolve — it replaces any factory rejection with its own error, + * discarding the `code` this function branches on. + * + * @internal + */ +export async function loadPg( + importPg: () => Promise<{ default: PgDefaultExport }> = () => import('pg'), +) { + try { + const { default: pg } = await importPg() + return pg + } catch (err) { + const code = (err as { code?: string }).code + if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') + throw err + throw new Error( + '[supabase v3]: encryptedSupabaseV3 introspects the database over a direct ' + + "Postgres connection, but the optional peer dependency 'pg' is not installed. " + + 'Install it (`npm install pg`). This also means encryptedSupabaseV3 cannot run ' + + 'in a Worker or the browser — use encryptedSupabase (EQL v2) there.', + { cause: err }, + ) + } +} + +/** + * Connect over `databaseUrl`, read every base table in the `public` schema with + * its EQL v3 domain (`domain_name`), plus the EQL v3 columns this SDK cannot + * model, indexed by table. `pg` is loaded with a dynamic import so bundlers do + * not pull it in unless introspection runs. + * + * `udt_name` is `jsonb` for a domain column, so ONLY `domain_name` distinguishes + * an EQL v3 column from a plain `jsonb` column (whose `domain_name` is NULL). + */ +export async function introspect( + databaseUrl: string, +): Promise { + const pg = await loadPg() + // Mirror the CLI introspector's bounded connect so an unreachable DB fails + // fast rather than hanging construction. + const client = new pg.Client({ + connectionString: databaseUrl, + connectionTimeoutMillis: 10_000, + }) + await client.connect() + try { + const [columns, unmodelled] = await Promise.all([ + client.query(COLUMNS_QUERY), + client.query(UNMODELLED_COLUMNS_QUERY, [ + Object.keys(DOMAIN_REGISTRY), + ]), + ]) + return { + tables: groupIntrospectionRows(columns.rows), + unmodelled: groupUnmodelledRows(unmodelled.rows), + } + } finally { + // `end()` runs only after a successful connect; swallow its own failure so it + // can never mask a query error (and, on the connect-failure path above, + // `connect()` throws before this try/finally is entered at all). + await client.end().catch(() => {}) + } +} diff --git a/packages/stack/src/supabase/query-builder-v3.ts b/packages/stack/src/supabase/query-builder-v3.ts new file mode 100644 index 000000000..e48cc536c --- /dev/null +++ b/packages/stack/src/supabase/query-builder-v3.ts @@ -0,0 +1,493 @@ +import type { EncryptionClient } from '@/encryption' +import type { AnyV3Table } from '@/eql/v3' +import { DATE_LIKE_CASTS, EncryptedV3Column } from '@/eql/v3/columns' +import type { + ColumnSchema, + EncryptedTable, + EncryptedTableColumn, +} from '@/schema' +import type { + BuildableQueryColumn, + QueryTypeName, + ScalarQueryTerm, +} from '@/types' +import { logger } from '@/utils/logger' +import { addJsonbCastsV3 } from './helpers' +import { + EncryptedQueryBuilderImpl, + EncryptionFailedError, +} from './query-builder' +import type { + DbName, + DbPendingOrCondition, + DbSelect, + FilterOp, + SupabaseClientLike, + SupabaseQueryBuilder, +} from './types' + +/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 + * client's decrypt-model path (see `encryption/v3.ts`). */ +const DATE_LIKE_CAST_SET = new Set(DATE_LIKE_CASTS) + +/** + * The subset of a v3 column builder the dialect relies on. Structural rather + * than the concrete class union so the runtime `instanceof EncryptedV3Column` + * gate and this type stay independent. + */ +type V3ColumnLike = { + getName(): string + getEqlType(): string + getQueryCapabilities(): { + equality: boolean + orderAndRange: boolean + freeTextSearch: boolean + } + build(): ColumnSchema +} + +/** + * Reject a declared property name that is also a DIFFERENT physical column. + * + * `select('*')` expands the introspected DB names into property names, so a + * column renamed `created_at → createdAt` and a distinct plaintext column + * literally named `createdAt` both emit the token `createdAt`, which + * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST + * returns the encrypted column under that key and the plaintext one is never + * selected, silently yielding the wrong value for a field the row type + * guarantees. + * + * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s + * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to + * construct instead. + */ +function assertNoPropertyDbNameCollision( + tableName: string, + propToDb: Record, + allColumns: string[] | null, +): void { + if (!allColumns) return + const dbNames = new Set(allColumns) + + for (const [property, dbName] of Object.entries(propToDb)) { + if (property === dbName) continue + if (!dbNames.has(property)) continue + throw new Error( + `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, + ) + } +} + +/** + * EQL v3 dialect of {@link EncryptedQueryBuilderImpl} for native concrete-domain + * columns (`public.*` type domains, `eql_v3` operators). The query mechanism is + * v2's — direct EQL operators over PostgREST — with four narrow forks: + * + * - **Column recognition / naming** — v3 columns are `EncryptedV3Column` + * builders and may map a JS property name to a different DB column name + * (`buildColumnKeyMap`). Filters, select casts, and mutations resolve + * property → DB name; select casts alias the DB column back to the property + * (`prop:db_name::jsonb`) so result rows keep property keys. + * - **Mutation encoding** — the raw encrypted payload object is sent (the + * `public.*` domains are `DOMAIN … AS jsonb`), not v2's `{ data: … }` + * composite wrap. + * - **Query-term encoding** — every filter operand is the FULL storage + * envelope from `encrypt()`, serialized as jsonb text. + * + * NOT because narrowed terms fail the domain CHECK: the bundle defines a + * `public._query` companion for each storage domain, whose CHECK + * requires `NOT (VALUE ? 'c')` — i.e. it accepts exactly the no-ciphertext + * shape `encryptQuery` produces. Those domains are simply unreachable from + * here. PostgREST has no syntax to cast a filter VALUE, and an uncast literal + * is ambiguous between the `_query` and `jsonb` `@>`/`=` overloads (42725 — + * the bundle says so itself, see `cipherstash-encrypt-v3-supabase.sql`, the + * `_query_types.sql` note). The reachable overload is the `jsonb` one, whose + * body coerces its operand to the STORAGE domain, which does require `c`. + * Independently, protect-ffi 0.28 throws `EQL_V3_QUERY_UNSUPPORTED` for any + * v3 scalar `encryptQuery`, so a narrowed term cannot be produced today. + * + * The full envelope satisfies the storage-domain CHECK by construction, and + * the operators extract the term they need (`eq_term`/`ord_term`/ + * `match_term`). + * - **`contains`, not `like`/`ilike`** — the v3 domains define no LIKE operator. + * Free-text search is TOKEN CONTAINMENT: the bundle declares `@>` on each + * match domain (`CREATE OPERATOR @> … FUNCTION = eql_v3.contains`), whose body + * is `match_term(a) @> match_term(b)` — `smallint[]` containment of the two + * bloom filters. PostgREST reaches it as `cs`. + * + * Match is tokenized + downcased, so `%` is NOT a wildcard — it is tokenized + * like any other character, and a `like` pattern is a category error. v3 + * Drizzle omits `like`/`ilike` for this reason and exposes `contains`; so do + * we. The typed builder has no `like`; the runtime methods throw on an + * encrypted column and pass through on a plaintext one. + * + * KNOWN BROKEN for real substrings, and not fixable from this file. The + * operand is a storage payload, so its bloom carries the whole needle as an + * extra `include_original` token, which the haystack's bloom cannot contain + * unless the needle equals the stored value or is exactly `token_length` (3) + * characters. v3 Drizzle's `contains` has the same defect for the same + * reason. Tracked in EQL; do not paper over it here. + * + * Decrypted rows additionally get `Date` reconstruction from the + * encrypt-config `cast_as`, mirroring the typed v3 client. + */ +export class EncryptedQueryBuilderV3Impl< + T extends Record = Record, +> extends EncryptedQueryBuilderImpl { + private v3Table: AnyV3Table + /** JS property name → DB column name, for every encrypted column. */ + private propToDb: Record + /** DB column name → JS property name — the inverse of {@link propToDb}, used + * to expand `select('*')` back into property names. Null prototype: a DB + * column literally named `constructor` / `toString` would otherwise resolve + * to an inherited `Object.prototype` member and be emitted as a select token. */ + private dbToProp: Record + /** Built column schemas keyed by DB column name (for `cast_as`). */ + private columnSchemas: Record + /** Column builders keyed by BOTH property name and DB name. */ + private v3Columns: Record + + constructor( + tableName: string, + table: AnyV3Table, + encryptionClient: EncryptionClient, + supabaseClient: SupabaseClientLike, + allColumns: string[] | null = null, + ) { + super( + tableName, + // The base class only ever calls BuildableTable members on the schema + // (build / encryptModel plumbing); every v2-specific behaviour is + // overridden below. + table as unknown as EncryptedTable, + encryptionClient, + supabaseClient, + allColumns, + ) + + this.v3Table = table + this.propToDb = table.buildColumnKeyMap() + this.columnSchemas = table.build().columns + + this.dbToProp = Object.create(null) as Record + for (const [property, dbName] of Object.entries(this.propToDb)) { + this.dbToProp[dbName] = property + } + + assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) + + // Null-prototype: keyed by DB column names, and `validateTransforms` reads + // it without an own-key guard — an inherited `constructor`/`toString` would + // otherwise resolve truthy for a plaintext column of that name. + this.v3Columns = Object.create(null) as Record + for (const [property, builder] of Object.entries(table.columnBuilders)) { + if (builder instanceof EncryptedV3Column) { + const col = builder as unknown as V3ColumnLike + this.v3Columns[property] = col + this.v3Columns[col.getName()] = col + } + } + + // The base class derives encrypted column names from build(), which v3 + // keys by DB name. Filters and select strings address columns by JS + // property name, so recognition must cover both. + this.encryptedColumnNames = Object.keys(this.v3Columns) + } + + // --------------------------------------------------------------------------- + // Dialect overrides + // --------------------------------------------------------------------------- + + protected override getColumnMap(): Record { + return this.v3Columns as unknown as Record + } + + /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards + * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ + private dbNameFor(name: string): string { + return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name + } + + protected override filterColumnName(column: string): DbName { + return this.dbNameFor(column) as DbName + } + + /** + * Ordering by ANY encrypted v3 column is rejected — including the ORE-capable + * ones, which is the non-obvious half. + * + * The `*_ord` domains are `CREATE DOMAIN … AS jsonb`, and the bundle declares + * NO btree operator class on any domain — it actively lints against one + * (`domain_opclass`), because an opclass on a domain bypasses operator + * resolution. So `ORDER BY col` does not reach `eql_v3`'s ORE comparisons at + * all: it resolves through jsonb's DEFAULT btree opclass, `jsonb_cmp`, and + * sorts by the envelope's byte structure — keys compare alphabetically, so the + * sort is effectively on the `bf` bloom array. No error, no warning, and a + * stable, plausible-looking, meaningless row order. + * + * Correct ordering is `ORDER BY eql_v3.ord_term(col)`, which PostgREST's + * `order=` parameter cannot express. The v3 Drizzle integration emits exactly + * that (`sql-dialect.ts` `orderBy`), and proves it against live Postgres. + * + * The `>=`/`<=` operators ARE declared on the ord domains, so `gte`/`lte` + * filters remain correct. Filtering and sorting resolve through different + * machinery; only sorting is broken. + * + * A column absent from {@link v3Columns} is a plaintext passthrough, and + * orders normally. This runtime guard is the only protection the untyped + * (no-`schemas`) surface has. + */ + protected override validateTransforms(): void { + for (const t of this.transforms) { + if (t.kind !== 'order') continue + const column = this.v3Columns[t.column] + if (!column) continue + throw new Error( + `[supabase v3]: cannot order by encrypted column "${column.getName()}" (${column.getEqlType()}) — PostgREST cannot emit \`ORDER BY eql_v3.ord_term("${column.getName()}")\`, and a bare \`ORDER BY\` sorts the raw ciphertext envelope, not the plaintext. Order by a plaintext column, expose \`eql_v3.ord_term()\` as a generated column or view and order by that, or use the EQL v3 Drizzle integration.`, + ) + } + } + + /** + * Resolve a raw `.filter()` operator to the capability it exercises. Unlike + * v2, the v3 operand is always the full storage envelope, so `queryType` + * never selects a narrowing — it only tells {@link encryptCollectedTerms} + * which capability to demand of the column. Getting it wrong therefore + * produces a wrong accept/reject, not a wrong ciphertext: the base class's + * `'equality'` default rejects `.filter('bio', 'cs', …)` on a + * `public.text_match` column, the one query that column can answer. + * + * Unknown operators throw rather than silently defaulting to equality, which + * would encrypt a term the column may not even be able to compare. + */ + protected override queryTypeForRawOp(operator: string): QueryTypeName { + switch (operator) { + case 'cs': + return 'freeTextSearch' + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return 'orderAndRange' + case 'eq': + case 'neq': + case 'in': + case 'is': + return 'equality' + default: + throw new Error( + `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, + ) + } + } + + protected override buildSelectString(): DbSelect | null { + if (this.selectColumns === null) return null + return addJsonbCastsV3(this.selectColumns, this.propToDb) + } + + /** + * Expand the introspected column list (DB names) into JS property names. + * + * Load-bearing for `select('*')` on a DECLARED table that renames a column. + * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing + * that makes PostgREST return the column under its property name — when the + * token it sees is a property name. Feeding it the raw DB name instead takes + * the unaliased `dbNames.has(...)` branch, so the row comes back keyed + * `created_at` while the declared row type promises `createdAt`, silently + * yielding `undefined` for a field TypeScript guarantees. + * + * A DB column with no encrypted builder (plaintext passthrough, and every + * synthesized column, where property == DB name) maps to itself. + */ + protected override expandAllColumns(columns: string[]): string[] { + return columns.map((dbName) => + Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, + ) + } + + /** v3 domains are plain jsonb — send the raw payload, keyed by DB name. */ + protected override transformEncryptedMutationModel( + model: Record, + ): Record { + const out: Record = Object.create(null) + for (const [key, value] of Object.entries(model)) { + out[this.dbNameFor(key)] = value + } + return out + } + + protected override transformEncryptedMutationModels( + models: Record[], + ): Record[] { + return models.map((model) => this.transformEncryptedMutationModel(model)) + } + + /** + * Encrypt every filter operand as a full storage envelope (see the class + * doc for why `encryptQuery` terms cannot be used), serialized to jsonb + * text for the PostgREST filter value. + */ + protected override async encryptCollectedTerms( + terms: ScalarQueryTerm[], + ): Promise { + return Promise.all( + terms.map(async (term) => { + const column = term.column as unknown as V3ColumnLike + const queryType = term.queryType ?? 'equality' + const capabilities = column.getQueryCapabilities() + + if ( + queryType !== 'equality' && + queryType !== 'orderAndRange' && + queryType !== 'freeTextSearch' + ) { + throw new Error( + `[supabase v3]: query type "${queryType}" is not supported on scalar EQL v3 columns`, + ) + } + + if (!capabilities[queryType]) { + throw new Error( + `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, + ) + } + + const baseOp = this.encryptionClient.encrypt(term.value, { + column, + table: this.v3Table, + }) + const op = this.lockContext + ? baseOp.withLockContext(this.lockContext) + : baseOp + if (this.auditConfig) op.audit(this.auditConfig) + + const result = await op + if (result.failure) { + logger.error( + `Supabase: failed to encrypt query terms for table "${this.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to encrypt query terms: ${result.failure.message}`, + result.failure, + ) + } + + return JSON.stringify(result.data) + }), + ) + } + + /** + * `like`/`ilike` do not exist on the v3 surface (see the class doc). The + * typed builder omits them, but an untyped JS caller can still reach them — + * refuse loudly rather than emit a `~~` the domain has no operator for. + * + * A plaintext column is a genuine PostgREST text column, so `like` there is + * exactly what the caller means; let it through. + */ + private assertNotEncryptedPattern(column: string, op: string): void { + if (!this.v3Columns[column]) return + throw new Error( + `[supabase v3]: "${op}" is not supported on encrypted column "${column}" — EQL v3 free-text search is token containment, not SQL wildcard matching ("%" is tokenized like any other character). Use contains().`, + ) + } + + override like(column: string, pattern: string): this { + this.assertNotEncryptedPattern(column, 'like') + return super.like(column, pattern) + } + + override ilike(column: string, pattern: string): this { + this.assertNotEncryptedPattern(column, 'ilike') + return super.ilike(column, pattern) + } + + /** + * Encrypted `contains` goes through the bloom-filter `@>`, which the bundle + * declares on the domain as PostgREST's `cs`. The operand is the full storage + * envelope; `eql_v3.contains` extracts the `bf` array from both sides. + * + * Emitted via `filter(col, 'cs', json)` rather than `q.contains(col, json)`: + * postgrest-js's `contains` re-serializes a non-string operand, and our + * operand is already `JSON.stringify`d. + */ + protected override applyContainsFilter( + q: SupabaseQueryBuilder, + column: DbName, + value: unknown, + wasEncrypted: boolean, + ): SupabaseQueryBuilder { + if (wasEncrypted) { + return q.filter(column, 'cs', value) + } + return super.applyContainsFilter(q, column, value, wasEncrypted) + } + + protected override notFilterOperator( + op: FilterOp, + wasEncrypted: boolean, + ): string { + if (wasEncrypted && op === 'contains') { + return 'cs' + } + return op + } + + /** + * `.or()` string conditions carry raw PostgREST operators, so a free-text + * condition arrives as `cs` — not a {@link FilterOp}. Resolve it through the + * same table the raw `.filter()` path uses, so `.or('amount.cs.5')` on an + * `integer_ord` column is rejected by the capability guard rather than + * silently encrypted as an equality term. + */ + protected override queryTypeForOrOp(op: FilterOp): QueryTypeName { + if (op === 'contains') return 'freeTextSearch' + return this.queryTypeForRawOp(op) + } + + /** + * Rewrite the structured form's `contains` to the PostgREST operator token + * `cs` before the or-string is rebuilt. String-form callers already write + * `cs` — PostgREST syntax — so those pass through untouched. + * + * Operator shaping stays here rather than in `toDbSpace` because it depends + * on `wasEncrypted`, which is only known after encryption. Column names + * arrive already in DB-space. + */ + protected override transformOrConditions( + conditions: DbPendingOrCondition[], + encryptedIndexes: Set, + ): DbPendingOrCondition[] { + return conditions.map((cond, j) => { + const op = + encryptedIndexes.has(j) && cond.op === 'contains' + ? ('cs' as FilterOp) + : cond.op + return op === cond.op ? cond : { ...cond, op } + }) + } + + /** Rebuild `Date` values from the encrypt-config `cast_as` (date/timestamp), + * mirroring the typed v3 client's decrypt-model path. */ + protected override postprocessDecryptedRow( + row: Record, + ): Record { + const out: Record = { ...row } + for (const [property, dbName] of Object.entries(this.propToDb)) { + const castAs = this.columnSchemas[dbName]?.cast_as + if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue + // Rows are keyed by property name when selected via the aliasing cast + // helper, but a caller selecting by raw DB name gets DB-name keys. + for (const key of property === dbName ? [property] : [property, dbName]) { + const value = out[key] + if (value == null || value instanceof Date) continue + if (typeof value === 'string' || typeof value === 'number') { + out[key] = new Date(value) + } + } + } + return out + } +} diff --git a/packages/stack/src/supabase/query-builder.ts b/packages/stack/src/supabase/query-builder.ts index 1e0483cf0..334bfbf17 100644 --- a/packages/stack/src/supabase/query-builder.ts +++ b/packages/stack/src/supabase/query-builder.ts @@ -8,17 +8,32 @@ import type { AuditConfig } from '@/encryption/operations/base-operation' import type { LockContext } from '@/identity' import type { EncryptedTable, EncryptedTableColumn } from '@/schema' import { EncryptedColumn } from '@/schema' -import type { ScalarQueryTerm } from '@/types' +import type { + BuildableQueryColumn, + QueryTypeName, + ScalarQueryTerm, +} from '@/types' import { logger } from '@/utils/logger' import { addJsonbCasts, getEncryptedColumnNames, + isEncryptableTerm, isEncryptedColumn, mapFilterOpToQueryType, parseOrString, rebuildOrString, } from './helpers' import type { + DbConflictList, + DbFilterString, + DbMutationOp, + DbMutationOptions, + DbName, + DbPendingOrCondition, + DbPendingOrFilter, + DbQuerySpace, + DbSelect, + DbTransformOp, EncryptedSupabaseError, EncryptedSupabaseResponse, FilterOp, @@ -46,42 +61,48 @@ import type { export class EncryptedQueryBuilderImpl< T extends Record = Record, > { - private tableName: string - private schema: EncryptedTable - private encryptionClient: EncryptionClient - private supabaseClient: SupabaseClientLike - private encryptedColumnNames: string[] + protected tableName: string + protected schema: EncryptedTable + protected encryptionClient: EncryptionClient + protected supabaseClient: SupabaseClientLike + protected encryptedColumnNames: string[] + /** All column names for the table (encrypted + plaintext), in ordinal order, + * used to expand `select('*')`. `null` when the caller supplied no column + * list (v2, or a v3 client that could not introspect). */ + protected allColumns: string[] | null = null // Recorded operations - private mutation: MutationOp | null = null - private selectColumns: string | null = null - private selectOptions: + protected mutation: MutationOp | null = null + protected selectColumns: string | null = null + protected selectOptions: | { head?: boolean; count?: 'exact' | 'planned' | 'estimated' } | undefined = undefined - private filters: PendingFilter[] = [] - private orFilters: PendingOrFilter[] = [] - private matchFilters: PendingMatchFilter[] = [] - private notFilters: PendingNotFilter[] = [] - private rawFilters: PendingRawFilter[] = [] - private transforms: TransformOp[] = [] - private resultMode: ResultMode = 'array' - private shouldThrowOnError = false + protected filters: PendingFilter[] = [] + protected orFilters: PendingOrFilter[] = [] + protected matchFilters: PendingMatchFilter[] = [] + protected notFilters: PendingNotFilter[] = [] + protected rawFilters: PendingRawFilter[] = [] + protected transforms: TransformOp[] = [] + protected resultMode: ResultMode = 'array' + protected shouldThrowOnError = false // Encryption-specific state - private lockContext: LockContext | null = null - private auditConfig: AuditConfig | null = null + protected lockContext: LockContext | null = null + protected auditConfig: AuditConfig | null = null constructor( tableName: string, schema: EncryptedTable, encryptionClient: EncryptionClient, supabaseClient: SupabaseClientLike, + allColumns: string[] | null = null, ) { this.tableName = tableName this.schema = schema this.encryptionClient = encryptionClient this.supabaseClient = supabaseClient this.encryptedColumnNames = getEncryptedColumnNames(schema) + this.allColumns = allColumns } // --------------------------------------------------------------------------- @@ -89,19 +110,34 @@ export class EncryptedQueryBuilderImpl< // --------------------------------------------------------------------------- select( - columns: string, + columns = '*', options?: { head?: boolean; count?: 'exact' | 'planned' | 'estimated' }, ): this { if (columns === '*') { - throw new Error( - "encryptedSupabase does not support select('*'). Please list columns explicitly so that encrypted columns can be cast with ::jsonb.", - ) + if (this.allColumns === null || this.allColumns.length === 0) { + throw new Error( + "encryptedSupabase does not support select('*'). Please list columns explicitly so that encrypted columns can be cast with ::jsonb.", + ) + } + this.selectColumns = this.expandAllColumns(this.allColumns).join(', ') + } else { + this.selectColumns = columns } - this.selectColumns = columns this.selectOptions = options return this } + /** + * Turn the introspected column list (DB names) into select tokens. The base + * returns them unchanged — v2 never supplies a column list, so this is dead + * for v2. The v3 dialect overrides it to emit JS property names, which is + * what makes `addJsonbCastsV3` alias a renamed column back to its property + * (`createdAt:created_at::jsonb`) rather than returning it under its DB name. + */ + protected expandAllColumns(columns: string[]): string[] { + return columns + } + insert( data: Partial | Partial[], options?: { @@ -196,6 +232,11 @@ export class EncryptedQueryBuilderImpl< return this } + contains(column: string, value: unknown): this { + this.filters.push({ op: 'contains', column, value }) + return this + } + is(column: string, value: null | boolean): this { this.filters.push({ op: 'is', column, value }) return this @@ -340,7 +381,7 @@ export class EncryptedQueryBuilderImpl< // Core execution // --------------------------------------------------------------------------- - private async execute(): Promise> { + protected async execute(): Promise> { try { logger.debug(`Supabase encrypted query on table "${this.tableName}".`) @@ -350,17 +391,21 @@ export class EncryptedQueryBuilderImpl< // 2. Build select string with ::jsonb casts const selectString = this.buildSelectString() - // 3. Batch-encrypt filter values - const encryptedFilters = await this.encryptFilterValues() + // 3. Translate every recorded column name into DB-space, once. + const dbSpace = this.toDbSpace() - // 4. Build and execute real Supabase query + // 4. Batch-encrypt filter values + const encryptedFilters = await this.encryptFilterValues(dbSpace) + + // 5. Build and execute real Supabase query const result = await this.buildAndExecuteQuery( encryptedMutation, selectString, encryptedFilters, + dbSpace, ) - // 5. Decrypt results + // 6. Decrypt results return await this.decryptResults(result) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -391,7 +436,7 @@ export class EncryptedQueryBuilderImpl< // Step 1: Encrypt mutation data // --------------------------------------------------------------------------- - private async encryptMutationData(): Promise< + protected async encryptMutationData(): Promise< Record | Record[] | null > { if (!this.mutation) return null @@ -420,7 +465,7 @@ export class EncryptedQueryBuilderImpl< ) } - return bulkModelsToEncryptedPgComposites(result.data) + return this.transformEncryptedMutationModels(result.data) } // Single model @@ -442,14 +487,33 @@ export class EncryptedQueryBuilderImpl< ) } - return modelToEncryptedPgComposites(result.data) + return this.transformEncryptedMutationModel(result.data) + } + + /** + * Encode an encrypted model for the Supabase request body. v2 wraps each + * encrypted value in the `{ data: ... }` object expected by the + * `eql_v2_encrypted` composite type. The v3 dialect overrides this — native + * `eql_v3.*` domains are plain jsonb, so the raw payload is sent instead + * (keyed by DB column name). + */ + protected transformEncryptedMutationModel( + model: Record, + ): Record { + return modelToEncryptedPgComposites(model) + } + + protected transformEncryptedMutationModels( + models: Record[], + ): Record[] { + return bulkModelsToEncryptedPgComposites(models) } // --------------------------------------------------------------------------- // Step 2: Build select string with casts // --------------------------------------------------------------------------- - private buildSelectString(): string | null { + protected buildSelectString(): DbSelect | null { if (this.selectColumns === null) return null return addJsonbCasts(this.selectColumns, this.encryptedColumnNames) } @@ -458,7 +522,9 @@ export class EncryptedQueryBuilderImpl< // Step 3: Encrypt filter values // --------------------------------------------------------------------------- - private async encryptFilterValues(): Promise { + protected async encryptFilterValues( + dbSpace: DbQuerySpace, + ): Promise { // Collect all terms that need encryption const terms: ScalarQueryTerm[] = [] const termMap: TermMapping[] = [] @@ -466,16 +532,18 @@ export class EncryptedQueryBuilderImpl< const tableColumns = this.getColumnMap() // Regular filters - for (let i = 0; i < this.filters.length; i++) { - const f = this.filters[i] + for (let i = 0; i < dbSpace.filters.length; i++) { + const f = dbSpace.filters[i] if (!isEncryptedColumn(f.column, this.encryptedColumnNames)) continue const column = tableColumns[f.column] if (!column) continue if (f.op === 'in' && Array.isArray(f.value)) { - // For `in` filters, encrypt each value separately + // For `in` filters, encrypt each value separately. A null element is + // SQL NULL and passes through; the applier restores it by index. for (let j = 0; j < f.value.length; j++) { + if (!isEncryptableTerm(f.op, f.value[j])) continue terms.push({ value: f.value[j] as JsPlaintext, column, @@ -485,7 +553,8 @@ export class EncryptedQueryBuilderImpl< }) termMap.push({ source: 'filter', filterIndex: i, inIndex: j }) } - } else if (f.op === 'is') { + } else if (!isEncryptableTerm(f.op, f.value)) { + // `is` predicate or null operand — forwarded unencrypted. } else { terms.push({ value: f.value as JsPlaintext, @@ -499,10 +568,12 @@ export class EncryptedQueryBuilderImpl< } // Match filters - for (let i = 0; i < this.matchFilters.length; i++) { - const mf = this.matchFilters[i] - for (const [colName, value] of Object.entries(mf.query)) { + for (let i = 0; i < dbSpace.matchFilters.length; i++) { + const mf = dbSpace.matchFilters[i] + for (const { column: colName, value } of mf.entries) { if (!isEncryptedColumn(colName, this.encryptedColumnNames)) continue + // `match` carries no operator; equality is implied. + if (!isEncryptableTerm('eq', value)) continue const column = tableColumns[colName] if (!column) continue @@ -518,9 +589,10 @@ export class EncryptedQueryBuilderImpl< } // Not filters - for (let i = 0; i < this.notFilters.length; i++) { - const nf = this.notFilters[i] + for (let i = 0; i < dbSpace.notFilters.length; i++) { + const nf = dbSpace.notFilters[i] if (!isEncryptedColumn(nf.column, this.encryptedColumnNames)) continue + if (!isEncryptableTerm(nf.op, nf.value)) continue const column = tableColumns[nf.column] if (!column) continue @@ -534,55 +606,51 @@ export class EncryptedQueryBuilderImpl< termMap.push({ source: 'not', notIndex: i }) } - // Or filters (string form parsed into conditions) - for (let i = 0; i < this.orFilters.length; i++) { - const of_ = this.orFilters[i] - if (of_.kind === 'string') { - const parsed = parseOrString(of_.value) - for (let j = 0; j < parsed.length; j++) { - const cond = parsed[j] - if (!isEncryptedColumn(cond.column, this.encryptedColumnNames)) - continue - const column = tableColumns[cond.column] - if (!column) continue + // Or filters — conditions were parsed once, in `toDbSpace`. The string and + // structured forms differ only in their `source` tag; the encryption rules, + // including the `in`-list split below, are identical. + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] + const source = of_.kind === 'string' ? 'or-string' : 'or-structured' + + for (let j = 0; j < of_.conditions.length; j++) { + const cond = of_.conditions[j] + if (!isEncryptedColumn(cond.column, this.encryptedColumnNames)) continue + const column = tableColumns[cond.column] + if (!column) continue + const pushTerm = (value: JsPlaintext, inIndex?: number) => { terms.push({ - value: cond.value as JsPlaintext, + value, column, table: this.schema, - queryType: mapFilterOpToQueryType(cond.op), + queryType: this.queryTypeForOrOp(cond.op), returnType: 'composite-literal', }) - termMap.push({ source: 'or-string', orIndex: i, conditionIndex: j }) + termMap.push({ source, orIndex: i, conditionIndex: j, inIndex }) } - } else { - for (let j = 0; j < of_.conditions.length; j++) { - const cond = of_.conditions[j] - if (!isEncryptedColumn(cond.column, this.encryptedColumnNames)) - continue - const column = tableColumns[cond.column] - if (!column) continue - terms.push({ - value: cond.value as JsPlaintext, - column, - table: this.schema, - queryType: mapFilterOpToQueryType(cond.op), - returnType: 'composite-literal', - }) - termMap.push({ - source: 'or-structured', - orIndex: i, - conditionIndex: j, - }) + // Mirror the regular filter path: each element of an `in` list is its + // own term. Encrypting the array as one value collapses `(a,b)` into a + // single ciphertext that matches nothing. + if (cond.op === 'in' && Array.isArray(cond.value)) { + for (let k = 0; k < cond.value.length; k++) { + if (!isEncryptableTerm(cond.op, cond.value[k])) continue + pushTerm(cond.value[k] as JsPlaintext, k) + } + continue } + + if (!isEncryptableTerm(cond.op, cond.value)) continue + pushTerm(cond.value as JsPlaintext) } } // Raw filters - for (let i = 0; i < this.rawFilters.length; i++) { - const rf = this.rawFilters[i] + for (let i = 0; i < dbSpace.rawFilters.length; i++) { + const rf = dbSpace.rawFilters[i] if (!isEncryptedColumn(rf.column, this.encryptedColumnNames)) continue + if (!isEncryptableTerm(rf.operator, rf.value)) continue const column = tableColumns[rf.column] if (!column) continue @@ -590,7 +658,7 @@ export class EncryptedQueryBuilderImpl< value: rf.value as JsPlaintext, column, table: this.schema, - queryType: 'equality', + queryType: this.queryTypeForRawOp(rf.operator), returnType: 'composite-literal', }) termMap.push({ source: 'raw', rawIndex: i }) @@ -600,6 +668,20 @@ export class EncryptedQueryBuilderImpl< return { encryptedValues: [], termMap: [] } } + const encryptedValues = await this.encryptCollectedTerms(terms) + return { encryptedValues, termMap } + } + + /** + * Encrypt the collected filter terms, returning one encoded value per term + * (in order). v2 batch-encrypts via `encryptQuery` with the + * `composite-literal` return type — the `("json")` string the + * `eql_v2_encrypted` composite operators compare. The v3 dialect overrides + * this to produce full-envelope jsonb operands instead. + */ + protected async encryptCollectedTerms( + terms: ScalarQueryTerm[], + ): Promise { // Batch encrypt all terms in one call const baseOp = this.encryptionClient.encryptQuery(terms) const op = this.lockContext @@ -619,37 +701,143 @@ export class EncryptedQueryBuilderImpl< ) } - return { encryptedValues: result.data, termMap } + return result.data + } + + // --------------------------------------------------------------------------- + // Phase boundary: property-space -> DB-space + // --------------------------------------------------------------------------- + + /** + * Translate every recorded column name from JS property space into DB space, + * once. Downstream (`encryptFilterValues`, `applyFilters`, + * `buildAndExecuteQuery`) consumes only the branded result, so a column can + * no longer reach PostgREST untranslated — that is a compile error. + * + * Total: `filterColumnName`, `parseOrString`, and `resolveMutationOptions` + * never throw, so this introduces no new early-throw point and cannot perturb + * the order in which capability errors surface. + * + * Safe to run BEFORE encryption: `getColumnMap()`/`encryptedColumnNames` are + * keyed by both property and DB name in v3 (and property == DB name in v2), + * so column lookup resolves identically either side of the translation, and + * `tableColumns[prop]` is the very same builder object as `tableColumns[db]`. + */ + protected toDbSpace(): DbQuerySpace { + return { + filters: this.filters.map((f) => ({ + ...f, + column: this.filterColumnName(f.column), + })), + matchFilters: this.matchFilters.map((mf) => ({ + entries: Object.entries(mf.query).map(([column, value]) => ({ + column: this.filterColumnName(column), + value, + })), + })), + notFilters: this.notFilters.map((nf) => ({ + ...nf, + column: this.filterColumnName(nf.column), + })), + rawFilters: this.rawFilters.map((rf) => ({ + ...rf, + column: this.filterColumnName(rf.column), + })), + orFilters: this.orFilters.map((of_) => this.orFilterToDbSpace(of_)), + transforms: this.transforms.map((t) => this.transformToDbSpace(t)), + mutation: this.mutation ? this.mutationToDbSpace(this.mutation) : null, + } + } + + /** `encryptedIndexes` is deliberately NOT precomputed here — it stays derived + * at apply time from the substitution maps, so this pass never has to agree + * with the encryption predicate about which conditions were encrypted. */ + private orFilterToDbSpace(of_: PendingOrFilter): DbPendingOrFilter { + const toDbCondition = (c: PendingOrCondition): DbPendingOrCondition => ({ + ...c, + column: this.filterColumnName(c.column), + }) + + if (of_.kind === 'string') { + return { + kind: 'string', + original: of_.value, + conditions: parseOrString(of_.value).map(toDbCondition), + referencedTable: of_.referencedTable, + } + } + return { kind: 'structured', conditions: of_.conditions.map(toDbCondition) } + } + + private transformToDbSpace(t: TransformOp): DbTransformOp { + switch (t.kind) { + case 'order': + return { ...t, column: this.filterColumnName(t.column) } + // `returns` is in the union but never pushed (`returns()` is a cast). + case 'limit': + case 'range': + case 'single': + case 'maybeSingle': + case 'csv': + case 'abortSignal': + case 'throwOnError': + case 'returns': + return t + default: { + const exhaustive: never = t + return exhaustive + } + } + } + + private mutationToDbSpace(m: MutationOp): DbMutationOp { + switch (m.kind) { + case 'insert': + case 'upsert': + // `resolveMutationOptions` returns the SAME reference when no column + // needed renaming, which v2 relies on. + return { ...m, options: this.resolveMutationOptions(m.options) } + case 'update': + case 'delete': + return m // options carry no column names + default: { + const exhaustive: never = m + return exhaustive + } + } } // --------------------------------------------------------------------------- // Step 4: Build and execute real Supabase query // --------------------------------------------------------------------------- - private async buildAndExecuteQuery( + protected async buildAndExecuteQuery( encryptedMutation: | Record | Record[] | null, - selectString: string | null, + selectString: DbSelect | null, encryptedFilters: EncryptedFilterState, + dbSpace: DbQuerySpace, ): Promise { + this.validateTransforms() + let query: SupabaseQueryBuilder = this.supabaseClient.from(this.tableName) - // Apply mutation - if (this.mutation) { - switch (this.mutation.kind) { + // Apply mutation — options already resolved to DB-space by `toDbSpace`. + if (dbSpace.mutation) { + switch (dbSpace.mutation.kind) { case 'insert': - query = query.insert(encryptedMutation!, this.mutation.options) + query = query.insert(encryptedMutation!, dbSpace.mutation.options) break case 'update': - query = query.update(encryptedMutation!, this.mutation.options) + query = query.update(encryptedMutation!, dbSpace.mutation.options) break case 'upsert': - query = query.upsert(encryptedMutation!, this.mutation.options) + query = query.upsert(encryptedMutation!, dbSpace.mutation.options) break case 'delete': - query = query.delete(this.mutation.options) + query = query.delete(dbSpace.mutation.options) break } } @@ -659,14 +847,14 @@ export class EncryptedQueryBuilderImpl< query = query.select(selectString, this.selectOptions) } else if (!this.mutation) { // Default select without explicit columns - shouldn't happen but fallback - query = query.select('*', this.selectOptions) + query = query.select('*' as DbSelect, this.selectOptions) } // Apply resolved filters - query = this.applyFilters(query, encryptedFilters) + query = this.applyFilters(query, encryptedFilters, dbSpace) - // Apply transforms - for (const t of this.transforms) { + // Apply transforms — column names already in DB-space. + for (const t of dbSpace.transforms) { switch (t.kind) { case 'order': query = query.order(t.column, t.options) @@ -703,9 +891,10 @@ export class EncryptedQueryBuilderImpl< // Apply filters with encrypted values substituted // --------------------------------------------------------------------------- - private applyFilters( + protected applyFilters( query: SupabaseQueryBuilder, encryptedFilters: EncryptedFilterState, + dbSpace: DbQuerySpace, ): SupabaseQueryBuilder { let q = query @@ -742,24 +931,20 @@ export class EncryptedQueryBuilderImpl< case 'raw': rawValueMap.set(mapping.rawIndex, encValue) break + // `inIndex` widens the key to address one element of an `in` list, so a + // whole-condition value and a per-element value never collide. case 'or-string': - orStringConditionMap.set( - `${mapping.orIndex}:${mapping.conditionIndex}`, - encValue, - ) + orStringConditionMap.set(orKey(mapping), encValue) break case 'or-structured': - orStructuredConditionMap.set( - `${mapping.orIndex}:${mapping.conditionIndex}`, - encValue, - ) + orStructuredConditionMap.set(orKey(mapping), encValue) break } } // Apply regular filters - for (let i = 0; i < this.filters.length; i++) { - const f = this.filters[i] + for (let i = 0; i < dbSpace.filters.length; i++) { + const f = dbSpace.filters[i] let value = f.value if (filterValueMap.has(i)) { @@ -772,46 +957,50 @@ export class EncryptedQueryBuilderImpl< }) } + const column = f.column + const wasEncrypted = filterValueMap.has(i) + switch (f.op) { case 'eq': - q = q.eq(f.column, value) + q = q.eq(column, value) break case 'neq': - q = q.neq(f.column, value) + q = q.neq(column, value) break case 'gt': - q = q.gt(f.column, value) + q = q.gt(column, value) break case 'gte': - q = q.gte(f.column, value) + q = q.gte(column, value) break case 'lt': - q = q.lt(f.column, value) + q = q.lt(column, value) break case 'lte': - q = q.lte(f.column, value) + q = q.lte(column, value) break case 'like': - q = q.like(f.column, value as string) - break case 'ilike': - q = q.ilike(f.column, value as string) + q = this.applyPatternFilter(q, column, f.op, value, wasEncrypted) + break + case 'contains': + q = this.applyContainsFilter(q, column, value, wasEncrypted) break case 'is': - q = q.is(f.column, value) + q = q.is(column, value) break case 'in': - q = q.in(f.column, value as unknown[]) + q = q.in(column, value as unknown[]) break } } // Apply match filters - for (let i = 0; i < this.matchFilters.length; i++) { - const mf = this.matchFilters[i] + for (let i = 0; i < dbSpace.matchFilters.length; i++) { + const mf = dbSpace.matchFilters[i] const resolvedQuery: Record = {} - for (const [colName, originalValue] of Object.entries(mf.query)) { + for (const { column: colName, value: originalValue } of mf.entries) { const key = `${i}:${colName}` resolvedQuery[colName] = matchValueMap.has(key) ? matchValueMap.get(key) @@ -822,52 +1011,81 @@ export class EncryptedQueryBuilderImpl< } // Apply not filters - for (let i = 0; i < this.notFilters.length; i++) { - const nf = this.notFilters[i] - const value = notValueMap.has(i) ? notValueMap.get(i) : nf.value - q = q.not(nf.column, nf.op, value) + for (let i = 0; i < dbSpace.notFilters.length; i++) { + const nf = dbSpace.notFilters[i] + const wasEncrypted = notValueMap.has(i) + const value = wasEncrypted ? notValueMap.get(i) : nf.value + q = q.not(nf.column, this.notFilterOperator(nf.op, wasEncrypted), value) } // Apply or filters - for (let i = 0; i < this.orFilters.length; i++) { - const of_ = this.orFilters[i] + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] if (of_.kind === 'string') { - const parsed = parseOrString(of_.value) - let hasEncrypted = false + // Already parsed (once) and translated by `toDbSpace`. + const parsed = [...of_.conditions] + const encryptedIndexes = new Set() for (let j = 0; j < parsed.length; j++) { - const key = `${i}:${j}` - if (orStringConditionMap.has(key)) { - parsed[j] = { ...parsed[j], value: orStringConditionMap.get(key) } - hasEncrypted = true + const sub = substituteOrValue(orStringConditionMap, i, j, parsed[j]) + if (sub) { + parsed[j] = { ...parsed[j], value: sub.value } + encryptedIndexes.add(j) } } - if (hasEncrypted) { - q = q.or(rebuildOrString(parsed), { + // Rebuild whenever a condition REFERENCES an encrypted column — not + // merely when a value was encrypted. An `is`/null operand on an + // encrypted column encrypts nothing, so keying on `encryptedIndexes` + // would send that condition down the verbatim path below and forward + // the caller's JS property name to a DB that only knows the column's + // real name. `toDbSpace` has already translated `parsed`. + const referencesEncrypted = parsed.some((c) => + isEncryptedColumn(c.column, this.encryptedColumnNames), + ) + + if (referencesEncrypted) { + q = q.or( + rebuildOrString( + this.transformOrConditions(parsed, encryptedIndexes), + ), + { + referencedTable: of_.referencedTable, + }, + ) + } else { + // Every condition names a plaintext column, whose property name IS + // its DB name — nothing to map. Forward the caller's ORIGINAL string + // byte-for-byte: v2 relies on this for nested `and()` and quoted + // values that `parseOrString`/`rebuildOrString` cannot round-trip. + q = q.or(of_.original as DbFilterString, { referencedTable: of_.referencedTable, }) - } else { - q = q.or(of_.value, { referencedTable: of_.referencedTable }) } } else { // Structured: convert to string + const encryptedIndexes = new Set() const conditions = of_.conditions.map((cond, j) => { - const key = `${i}:${j}` - if (orStructuredConditionMap.has(key)) { - return { ...cond, value: orStructuredConditionMap.get(key) } + const sub = substituteOrValue(orStructuredConditionMap, i, j, cond) + if (sub) { + encryptedIndexes.add(j) + return { ...cond, value: sub.value } } return cond }) - q = q.or(rebuildOrString(conditions)) + q = q.or( + rebuildOrString( + this.transformOrConditions(conditions, encryptedIndexes), + ), + ) } } // Apply raw filters - for (let i = 0; i < this.rawFilters.length; i++) { - const rf = this.rawFilters[i] + for (let i = 0; i < dbSpace.rawFilters.length; i++) { + const rf = dbSpace.rawFilters[i] const value = rawValueMap.has(i) ? rawValueMap.get(i) : rf.value q = q.filter(rf.column, rf.operator, value) } @@ -875,11 +1093,147 @@ export class EncryptedQueryBuilderImpl< return q } + // --------------------------------------------------------------------------- + // Dialect seams — every default preserves the v2 behaviour byte-for-byte. + // The v3 builder (see ./query-builder-v3) overrides these for native + // `eql_v3.*` domain columns. + // --------------------------------------------------------------------------- + + /** + * Map a filter's column name to the DB column name PostgREST must see. + * v2 schemas key columns by their DB name already, so this is the identity; + * the v3 dialect resolves a JS property name to its DB name. + * + * This is the ONLY place a {@link DbName} is minted. The + * {@link SupabaseQueryBuilder} seam accepts nothing else, so every column + * name reaching PostgREST must pass through here. + */ + protected filterColumnName(column: string): DbName { + return column as DbName + } + + /** + * Resolve the column names carried by a mutation's options. `onConflict` is a + * comma-separated column list, so it needs the same property→DB mapping as a + * filter. Returns the original object when nothing changed, so v2 — where + * {@link filterColumnName} is the identity — passes the caller's reference on + * untouched. + */ + protected resolveMutationOptions< + O extends { onConflict?: string } | undefined, + >(options: O): DbMutationOptions | undefined { + if (!options?.onConflict) return options as DbMutationOptions | undefined + const mapped = options.onConflict + .split(',') + .map((column) => this.filterColumnName(column.trim())) + .join(',') as DbConflictList + return ( + mapped === options.onConflict + ? options + : { ...options, onConflict: mapped } + ) as DbMutationOptions + } + + /** + * Validate the accumulated transforms before the query is built. Called from + * inside {@link execute}'s try, so a throw surfaces as a `status: 500` error + * result (or rethrows under `throwOnError`), matching the filter-path + * capability guard. v2 imposes no constraints. + */ + protected validateTransforms(): void {} + + /** + * The CipherStash query type to encrypt a raw `.filter(column, operator, …)` + * term under. `operator` is an arbitrary PostgREST operator string, not a + * {@link FilterOp}, so it cannot go through `mapFilterOpToQueryType`. + * + * v2 encrypts every raw filter as an equality term. That is wrong — a raw + * `.filter('amount', 'gte', …)` wants an ORE term — but in v2 `queryType` + * selects the `encryptQuery` narrowing, so correcting it changes the + * ciphertext on the wire. Preserved verbatim here and tracked separately; + * the v3 dialect, where `queryType` is only a capability gate, overrides it. + */ + protected queryTypeForRawOp(_operator: string): QueryTypeName { + return 'equality' + } + + /** + * Apply a `like`/`ilike` filter. v2 relies on the `~~` operator defined on + * `eql_v2_encrypted`; the v3 dialect overrides this for encrypted columns + * because the `eql_v3.*` domains expose free-text match via `@>` + * (PostgREST `cs`) rather than a LIKE operator. + */ + protected applyPatternFilter( + q: SupabaseQueryBuilder, + column: DbName, + op: 'like' | 'ilike', + value: unknown, + _wasEncrypted: boolean, + ): SupabaseQueryBuilder { + return op === 'like' + ? q.like(column, value as string) + : q.ilike(column, value as string) + } + + /** + * Apply a `contains` filter. On a plaintext column this is PostgREST's native + * jsonb/array containment. The v3 dialect overrides it for encrypted columns, + * where `cs` resolves to the `@>` operator the EQL bundle declares on the + * domain, backed by `eql_v3.contains` (bloom-filter containment). + */ + protected applyContainsFilter( + q: SupabaseQueryBuilder, + column: DbName, + value: unknown, + _wasEncrypted: boolean, + ): SupabaseQueryBuilder { + return q.contains(column, value) + } + + /** + * The CipherStash query type for an `.or()` condition's operator on an + * encrypted column. String-form conditions carry raw PostgREST operators + * (`cs`), which are not {@link FilterOp}s; the v3 dialect maps those. + */ + protected queryTypeForOrOp(op: FilterOp): QueryTypeName { + return mapFilterOpToQueryType(op) + } + + /** + * The PostgREST operator to use for a `.not()` filter. The v3 dialect maps + * `like`/`ilike` on encrypted columns to `cs` (see applyPatternFilter). + */ + protected notFilterOperator(op: FilterOp, _wasEncrypted: boolean): string { + return op + } + + /** + * Transform `.or()` conditions before the or-string is rebuilt. The v3 + * dialect maps property names to DB names and `like`/`ilike` on encrypted + * conditions to `cs`. + */ + protected transformOrConditions( + conditions: DbPendingOrCondition[], + _encryptedIndexes: Set, + ): DbPendingOrCondition[] { + return conditions + } + + /** + * Post-process a decrypted result row. The v3 dialect reconstructs `Date` + * values from the encrypt-config `cast_as`; v2 returns rows unchanged. + */ + protected postprocessDecryptedRow( + row: Record, + ): Record { + return row + } + // --------------------------------------------------------------------------- // Step 5: Decrypt results // --------------------------------------------------------------------------- - private async decryptResults( + protected async decryptResults( result: RawSupabaseResult, ): Promise> { // If there's an error from Supabase, pass it through @@ -958,7 +1312,9 @@ export class EncryptedQueryBuilderImpl< } return { - data: decrypted.data as unknown as T[], + data: this.postprocessDecryptedRow( + decrypted.data as Record, + ) as unknown as T[], error: null, count: result.count ?? null, status: result.status, @@ -997,7 +1353,9 @@ export class EncryptedQueryBuilderImpl< } return { - data: decrypted.data as unknown as T[], + data: decrypted.data.map((row) => + this.postprocessDecryptedRow(row as Record), + ) as unknown as T[], error: null, count: result.count ?? null, status: result.status, @@ -1009,8 +1367,8 @@ export class EncryptedQueryBuilderImpl< // Helpers // --------------------------------------------------------------------------- - private getColumnMap(): Record { - const map: Record = {} + protected getColumnMap(): Record { + const map: Record = {} const schema = this.schema as unknown as Record for (const colName of this.encryptedColumnNames) { @@ -1033,14 +1391,65 @@ type TermMapping = | { source: 'match'; matchIndex: number; column: string } | { source: 'not'; notIndex: number } | { source: 'raw'; rawIndex: number } - | { source: 'or-string'; orIndex: number; conditionIndex: number } - | { source: 'or-structured'; orIndex: number; conditionIndex: number } + | { + source: 'or-string' + orIndex: number + conditionIndex: number + inIndex?: number + } + | { + source: 'or-structured' + orIndex: number + conditionIndex: number + inIndex?: number + } type EncryptedFilterState = { encryptedValues: unknown[] termMap: TermMapping[] } +/** Key an `.or()` condition, or one element of its `in` list. */ +function orKey(mapping: { + orIndex: number + conditionIndex: number + inIndex?: number +}): string { + const base = `${mapping.orIndex}:${mapping.conditionIndex}` + return mapping.inIndex === undefined ? base : `${base}:${mapping.inIndex}` +} + +/** + * Substitute encrypted operands back into one `.or()` condition, returning + * `undefined` when nothing was encrypted for it. + * + * An `in` list is reconstructed element-by-element so `formatOrValue` re-emits + * the `(a,b)` list form. Substituting the array as a single value would collapse + * it to one ciphertext that matches nothing. + */ +function substituteOrValue( + map: Map, + orIndex: number, + conditionIndex: number, + cond: { op: FilterOp; value: unknown }, +): { value: unknown } | undefined { + const whole = orKey({ orIndex, conditionIndex }) + if (map.has(whole)) return { value: map.get(whole) } + + if (cond.op === 'in' && Array.isArray(cond.value)) { + let substituted = false + const value = cond.value.map((element, inIndex) => { + const key = orKey({ orIndex, conditionIndex, inIndex }) + if (!map.has(key)) return element + substituted = true + return map.get(key) + }) + if (substituted) return { value } + } + + return undefined +} + type RawSupabaseResult = { data: unknown error: { @@ -1054,7 +1463,7 @@ type RawSupabaseResult = { statusText: string } -class EncryptionFailedError extends Error { +export class EncryptionFailedError extends Error { public encryptionError: unknown constructor(message: string, encryptionError: unknown) { diff --git a/packages/stack/src/supabase/schema-builder.ts b/packages/stack/src/supabase/schema-builder.ts new file mode 100644 index 000000000..ba5f22f9f --- /dev/null +++ b/packages/stack/src/supabase/schema-builder.ts @@ -0,0 +1,94 @@ +import { type AnyV3Table, EncryptedTable } from '@/eql/v3' +import type { AnyEncryptedV3Column } from '@/eql/v3/columns' +import { factoryForDomain } from '@/eql/v3/domain-registry' +import type { IntrospectionResult } from './introspect' + +/** A record of declared v3 tables, keyed by table name. */ +export type V3Schemas = Record + +export interface SynthesizedSchema { + /** Every introspected table (even zero-encrypted ones), keyed by table name. + * Values hold ONLY the encrypted columns; plaintext columns live in + * `allColumns`. */ + tables: Map + /** Full column list per table (encrypted + plaintext), for select('*'). */ + allColumns: Map +} + +/** + * Build one `EncryptedTable` per introspected table from its domain columns. + * A column whose `domainName` is NULL or absent from the registry is treated as + * plaintext — retained in `allColumns` but not added to the table. Synthesized + * columns are keyed by DB name (property == DB name). + * + * NOTE: this does NOT reject recognized-but-unmodelled EQL domains — such a + * column silently becomes a plaintext passthrough here, and reads would return + * raw ciphertext. `assertTableIsModelled` (index.ts) is the ONLY thing standing + * between a caller and that leak; it must run before any builder is handed out. + */ +export function synthesizeTables( + introspection: IntrospectionResult, +): SynthesizedSchema { + const tables = new Map() + const allColumns = new Map() + + for (const table of introspection) { + // Null-prototype: keys are DB column names, so `__proto__` must land as an + // own key rather than reparenting the object (which would drop the column). + const builders: Record = Object.create(null) + for (const col of table.columns) { + if (col.domainName === null) continue + const factory = factoryForDomain(col.domainName) + if (!factory) continue // unknown / unmodelled → guarded elsewhere + builders[col.columnName] = factory(col.columnName) + } + // Raw constructor (not `encryptedTable`) — no accessor copy or reserved-key + // guard is needed, and it avoids throwing on an arbitrary DB column name + // that happens to collide with a reserved table member. + tables.set(table.tableName, new EncryptedTable(table.tableName, builders)) + allColumns.set( + table.tableName, + table.columns.map((c) => c.columnName), + ) + } + + return { tables, allColumns } +} + +/** + * Replace synthesized tables with a merge of declared-over-synthesized columns. + * For each declared column, drop the synthesized entry that resolves to the + * same DB name and add the declared builder under its JS property name (so a + * property→DB rename survives). Undeclared columns stay synthesized. + * `allColumns` is unchanged (DB-name based, from introspection). + */ +export function mergeDeclaredTables( + synth: SynthesizedSchema, + schemas: V3Schemas, +): SynthesizedSchema { + const tables = new Map(synth.tables) + + for (const declared of Object.values(schemas)) { + const tableName = declared.tableName + const synthesized = tables.get(tableName) + + const merged: Record = Object.create(null) + if (synthesized) { + for (const [prop, builder] of Object.entries( + synthesized.columnBuilders, + )) { + merged[prop] = builder as AnyEncryptedV3Column + } + } + for (const [prop, builder] of Object.entries(declared.columnBuilders)) { + const dbName = builder.getName() + if (dbName !== prop && Object.hasOwn(merged, dbName)) { + delete merged[dbName] + } + merged[prop] = builder as AnyEncryptedV3Column + } + tables.set(tableName, new EncryptedTable(tableName, merged)) + } + + return { tables, allColumns: synth.allColumns } +} diff --git a/packages/stack/src/supabase/types.ts b/packages/stack/src/supabase/types.ts index 92220e366..70ea544fc 100644 --- a/packages/stack/src/supabase/types.ts +++ b/packages/stack/src/supabase/types.ts @@ -1,8 +1,11 @@ import type { EncryptionClient } from '@/encryption' import type { AuditConfig } from '@/encryption/operations/base-operation' +import type { AnyV3Table, InferPlaintext, QueryTypesForColumn } from '@/eql/v3' import type { EncryptionError } from '@/errors' import type { LockContext } from '@/identity' import type { EncryptedTable, EncryptedTableColumn } from '@/schema' +import type { ClientConfig } from '@/types' +import type { V3Schemas } from './schema-builder' // --------------------------------------------------------------------------- // Config & instance @@ -20,6 +23,194 @@ export interface EncryptedSupabaseInstance { ): EncryptedQueryBuilder } +// --------------------------------------------------------------------------- +// EQL v3 config & instance +// --------------------------------------------------------------------------- + +export type { V3Schemas } + +/** + * Options for {@link import('./index').encryptedSupabaseV3}. + * + * @typeParam S - declared v3 tables. When present, `from()` is constrained to + * the declared table names and returns typed builders, and the tables are + * verified against the database at construction. + */ +export type EncryptedSupabaseV3Options< + S extends V3Schemas | undefined = undefined, +> = { + /** Postgres connection string for introspection. Defaults to + * `process.env.DATABASE_URL`. */ + databaseUrl?: string + /** Passed through to the encryption client (`eqlVersion` is forced to 3). */ + config?: ClientConfig + /** + * Optional declared v3 tables, keyed by table name (each record key MUST + * equal its table's `tableName`). Declaring a table adds compile-time types + * and startup verification; undeclared tables behave exactly as with no + * `schemas`. + * + * Declaring a `text_search` column does NOT change its match behaviour: a + * declared and a synthesized `text_search` column build byte-identically, and + * neither `types.TextSearch` nor `EncryptedTextSearchColumn` accepts match + * options. `include_original: true` is therefore always in force, so a + * substring `contains` matches nothing on either. See the `contains` note on + * `EncryptedQueryBuilderV3Impl`. + */ + schemas?: S +} + +/** + * The column builders declared on a v3 table, recovered from the table's + * type-level `_columnType` brand. + */ +type V3ColumnsOfTable = Table extends { + readonly _columnType: infer C +} + ? C + : never + +/** + * JS property names of a v3 table's storage-only columns — those whose domain + * exposes no query capability (e.g. `types.Bool`, `types.Text`). Excluded from + * the filterable keys so a filter on one is a type error, matching the runtime + * guard in the v3 term encryption path. + */ +export type NonQueryableV3Keys
= { + [K in Extract, string>]: [ + QueryTypesForColumn[K]>, + ] extends [never] + ? K + : never +}[Extract, string>] + +/** + * Row keys a v3 builder accepts in filter methods: every row key except the + * table's storage-only encrypted columns. Plaintext (non-schema) columns pass + * through untouched, exactly as in v2. + */ +export type V3FilterableKeys< + Table extends AnyV3Table, + Row extends Record, +> = Exclude, NonQueryableV3Keys
> + +/** + * JS property names of a v3 table's columns that carry NO `freeTextSearch` + * capability — i.e. every domain but `public.text_match` and + * `public.text_search`. Excluded from `contains()`'s keys, so a token-containment + * query against a column with no bloom-filter index is a type error rather than + * the runtime capability throw in the v3 term encryption path. + */ +type NonFreeTextSearchV3Keys
= { + [K in Extract< + keyof V3ColumnsOfTable
, + string + >]: 'freeTextSearch' extends QueryTypesForColumn[K]> + ? never + : K +}[Extract, string>] + +/** + * Row keys a v3 builder accepts in `contains()`: every row key except the + * table's encrypted columns that lack a match index. Plaintext columns pass + * through, where `contains` is PostgREST's native jsonb/array containment. + */ +export type V3FreeTextSearchableKeys< + Table extends AnyV3Table, + Row extends Record, +> = Exclude, NonFreeTextSearchV3Keys
> + +/** + * Row keys a v3 builder accepts in `order()`: every row key that is NOT an + * encrypted v3 column. `ORDER BY` on an EQL v3 domain sorts the raw ciphertext + * envelope — the bundle declares no btree operator class on any domain, so the + * sort resolves through jsonb's default `jsonb_cmp`. Correct ordering needs + * `eql_v3.ord_term(col)`, which PostgREST cannot emit. + */ +export type V3OrderableKeys< + Table extends AnyV3Table, + Row extends Record, +> = Exclude< + Extract, + Extract, string> +> + +/** + * The v3 builder type: the shared {@link EncryptedQueryBuilderCore} surface with + * filter methods narrowed to {@link V3FilterableKeys} and `order()` to + * {@link V3OrderableKeys}. + * + * `like`/`ilike` are absent by construction. EQL v3 free-text search is token + * containment over a bloom filter (`@>`), not SQL wildcard matching — `%` is + * tokenized like any other character, so a `like` pattern is a category error. + * The v3 dialect of Drizzle omits them for the same reason. Use `contains`. + */ +export interface EncryptedQueryBuilderV3< + Table extends AnyV3Table, + Row extends Record, +> extends EncryptedQueryBuilderCore< + Row, + V3FilterableKeys & StringKeyOf, + EncryptedQueryBuilderV3, + V3OrderableKeys & StringKeyOf + > { + contains & StringKeyOf>( + column: K, + value: string, + ): EncryptedQueryBuilderV3 +} + +/** + * The v3 builder for a table with no declared schema. Without capability + * information `contains` cannot be narrowed to match-indexed columns — the + * runtime guard in the term-encryption path is the only protection — but the + * DIALECT is still v3, so `like`/`ilike` are absent here too. Typing this as + * {@link EncryptedQueryBuilder} would hand back the v2 surface. + */ +export interface EncryptedQueryBuilderV3Untyped< + Row extends Record, +> extends EncryptedQueryBuilderCore< + Row, + StringKeyOf, + EncryptedQueryBuilderV3Untyped + > { + contains>( + column: K, + value: string, + ): EncryptedQueryBuilderV3Untyped +} + +/** Untyped instance (no `schemas`): rows default to `Record` + * and `from` accepts any table name. */ +export interface EncryptedSupabaseV3Instance { + from = Record>( + tableName: string, + ): EncryptedQueryBuilderV3Untyped +} + +/** Typed instance (with `schemas: S`): a declared table name resolves to the + * narrowed v3 builder; any other table name falls back to the untyped surface. + * + * The fallback overload is REQUIRED, not a convenience. The design spec + * promises a gradient — "declare one table, leave the rest introspected; + * undeclared tables behave exactly as they would with no `schemas` at all". + * With only the `keyof S` overload, `schemas: { users }` makes `from('orders')` + * a compile error even though `orders` was introspected and works perfectly at + * runtime. Declaring one table would silently make every other table + * unreachable. + * + * Overload order matters: the literal-constrained signature is declared first, + * so TypeScript prefers it whenever the argument is a declared key and only + * falls through to `string` otherwise. */ +export interface TypedEncryptedSupabaseV3Instance { + from( + table: K, + ): EncryptedQueryBuilderV3> + from = Record>( + table: string, + ): EncryptedQueryBuilderV3Untyped +} + // --------------------------------------------------------------------------- // Response // --------------------------------------------------------------------------- @@ -49,6 +240,9 @@ export type FilterOp = | 'neq' | 'like' | 'ilike' + /** Token containment. v3-only on encrypted columns; on a plaintext column it + * is PostgREST's native jsonb/array `cs`. */ + | 'contains' | 'in' | 'gt' | 'gte' @@ -69,6 +263,9 @@ export type PendingOrFilter = export type PendingOrCondition = { column: string op: FilterOp + /** PostgREST's `column.not..` negation. Kept off `op` so the + * `in`-list split and the query-type mapping both key on the real operator. */ + negate?: boolean value: unknown } @@ -146,46 +343,139 @@ export type MutationOp = export type ResultMode = 'array' | 'single' | 'maybeSingle' +// --------------------------------------------------------------------------- +// DB-space brands +// --------------------------------------------------------------------------- + +declare const DbBrand: unique symbol + +/** + * A column name in DB-space — i.e. one PostgREST will recognise. + * + * A v3 table may declare a column whose JS property name differs from its DB + * column name (`createdAt: types.TimestampOrd('created_at')`). Both are + * `string`, so before these brands the compiler could not tell which of the two + * reached PostgREST, and each new column-carrying method silently started out + * broken — that is how `order()` shipped sending `createdAt` to a database that + * only has `created_at`. + * + * Branding the {@link SupabaseQueryBuilder} seam means a property name will not + * type-check where a DB name is required. The only way to obtain a `DbName` is + * to call `filterColumnName()`, so forgetting to translate is now a compile + * error rather than a wrong query. The brand is erased at runtime. + */ +export type DbName = string & { readonly [DbBrand]: 'column' } + +/** A PostgREST select list, DB-space and `::jsonb`-cast. Minted by `addJsonbCasts`/`addJsonbCastsV3`. */ +export type DbSelect = string & { readonly [DbBrand]: 'select' } + +/** A PostgREST `or()` filter string in DB-space. Minted by `rebuildOrString`. */ +export type DbFilterString = string & { readonly [DbBrand]: 'filter' } + +/** A comma-separated `onConflict` column list in DB-space. Minted by `resolveMutationOptions`. */ +export type DbConflictList = string & { readonly [DbBrand]: 'conflict' } + +/** Mutation options, with the one column-carrying member in DB-space. */ +export type DbMutationOptions = Record & { + onConflict?: DbConflictList +} + +// --------------------------------------------------------------------------- +// DB-space IR — the recorded query, with every column name translated. +// +// `toDbSpace()` (see ./query-builder) maps the property-space IR above into +// this one, exactly once, before any column name can reach PostgREST. The +// branded `column` fields make that translation a compile-time obligation: +// `applyFilters`/`buildAndExecuteQuery` consume only these types, so feeding +// them the untranslated `PendingFilter[]` does not type-check. +// --------------------------------------------------------------------------- + +export type DbPendingFilter = Omit & { column: DbName } +export type DbPendingNotFilter = Omit & { + column: DbName +} +export type DbPendingRawFilter = Omit & { + column: DbName +} +export type DbPendingOrCondition = Omit & { + column: DbName +} + +/** Entries rather than a Record: a brand cannot ride on an object key, so this + * is the one translation the compiler cannot enforce. Order is preserved. */ +export type DbPendingMatchFilter = { + entries: Array<{ column: DbName; value: unknown }> +} + +/** Retains the caller's ORIGINAL text for the verbatim fallback (which must be + * forwarded byte-for-byte — `parseOrString`/`rebuildOrString` do not round-trip + * nested `and()` or quoted values) alongside the parsed DB-space conditions + * used by the encrypt-and-rebuild path. Parsing happens once, here. */ +export type DbPendingOrFilter = + | { kind: 'structured'; conditions: DbPendingOrCondition[] } + | { + kind: 'string' + original: string + conditions: DbPendingOrCondition[] + referencedTable?: string + } + +type OrderOp = Extract +export type DbTransformOp = + | Exclude + | (Omit & { column: DbName }) + +type InsertOp = Extract +type UpsertOp = Extract +export type DbMutationOp = + | (Omit & { options?: DbMutationOptions }) + | (Omit & { options?: DbMutationOptions }) + | Extract + | Extract + +/** The whole recorded query, in DB-space. */ +export type DbQuerySpace = { + filters: DbPendingFilter[] + matchFilters: DbPendingMatchFilter[] + notFilters: DbPendingNotFilter[] + rawFilters: DbPendingRawFilter[] + orFilters: DbPendingOrFilter[] + transforms: DbTransformOp[] + mutation: DbMutationOp | null +} + // --------------------------------------------------------------------------- // Minimal Supabase client shape (to avoid hard dependency) // --------------------------------------------------------------------------- export interface SupabaseQueryBuilder { select( - columns?: string, + columns?: DbSelect, options?: { head?: boolean; count?: 'exact' | 'planned' | 'estimated' }, ): SupabaseQueryBuilder - insert( - values: unknown, - options?: Record, - ): SupabaseQueryBuilder - update( - values: unknown, - options?: Record, - ): SupabaseQueryBuilder - upsert( - values: unknown, - options?: Record, - ): SupabaseQueryBuilder + insert(values: unknown, options?: DbMutationOptions): SupabaseQueryBuilder + update(values: unknown, options?: DbMutationOptions): SupabaseQueryBuilder + upsert(values: unknown, options?: DbMutationOptions): SupabaseQueryBuilder delete(options?: Record): SupabaseQueryBuilder - eq(column: string, value: unknown): SupabaseQueryBuilder - neq(column: string, value: unknown): SupabaseQueryBuilder - gt(column: string, value: unknown): SupabaseQueryBuilder - gte(column: string, value: unknown): SupabaseQueryBuilder - lt(column: string, value: unknown): SupabaseQueryBuilder - lte(column: string, value: unknown): SupabaseQueryBuilder - like(column: string, value: unknown): SupabaseQueryBuilder - ilike(column: string, value: unknown): SupabaseQueryBuilder - is(column: string, value: unknown): SupabaseQueryBuilder - in(column: string, values: unknown[]): SupabaseQueryBuilder - filter(column: string, operator: string, value: unknown): SupabaseQueryBuilder - not(column: string, operator: string, value: unknown): SupabaseQueryBuilder + eq(column: DbName, value: unknown): SupabaseQueryBuilder + neq(column: DbName, value: unknown): SupabaseQueryBuilder + gt(column: DbName, value: unknown): SupabaseQueryBuilder + gte(column: DbName, value: unknown): SupabaseQueryBuilder + lt(column: DbName, value: unknown): SupabaseQueryBuilder + lte(column: DbName, value: unknown): SupabaseQueryBuilder + like(column: DbName, value: unknown): SupabaseQueryBuilder + ilike(column: DbName, value: unknown): SupabaseQueryBuilder + contains(column: DbName, value: unknown): SupabaseQueryBuilder + is(column: DbName, value: unknown): SupabaseQueryBuilder + in(column: DbName, values: unknown[]): SupabaseQueryBuilder + filter(column: DbName, operator: string, value: unknown): SupabaseQueryBuilder + not(column: DbName, operator: string, value: unknown): SupabaseQueryBuilder or( - filters: string, + filters: DbFilterString, options?: { referencedTable?: string; foreignTable?: string }, ): SupabaseQueryBuilder match(query: Record): SupabaseQueryBuilder - order(column: string, options?: Record): SupabaseQueryBuilder + order(column: DbName, options?: Record): SupabaseQueryBuilder limit(count: number, options?: Record): SupabaseQueryBuilder range( from: number, @@ -229,13 +519,32 @@ export type { /** Helper to extract string keys from T */ type StringKeyOf = Extract -export interface EncryptedQueryBuilder< - T extends Record = Record, +/** + * Every builder method shared by the v2 and v3 dialects. `Self` is the concrete + * builder each method returns, so a dialect that omits a method (v3 omits + * `like`/`ilike`) does not have it laundered back in by a chained call whose + * return type widened to the base interface. + * + * Free-text search is the ONLY axis on which the two dialects differ: v2 + * matches with SQL wildcards (`like`/`ilike` → `~~`), v3 with token containment + * (`contains` → `@>`). Each adds its own method below. + */ +export interface EncryptedQueryBuilderCore< + T extends Record, + FK extends StringKeyOf, + Self, + /** Keys `order()` accepts. Defaults to `FK`, so the v2 surface is unchanged; + * v3 narrows it to plaintext columns (see {@link V3OrderableKeys}). */ + OK extends StringKeyOf = FK, > extends PromiseLike> { + /** `columns` defaults to `'*'`, matching supabase-js. A `'*'` select expands + * to the introspected column list when one is available (v3), and otherwise + * throws — v2 has no column list to cast, so `select()` and `select('*')` + * both throw there. */ select( - columns: string, + columns?: string, options?: { head?: boolean; count?: 'exact' | 'planned' | 'estimated' }, - ): EncryptedQueryBuilder + ): Self insert( data: Partial | Partial[], options?: { @@ -243,11 +552,11 @@ export interface EncryptedQueryBuilder< defaultToNull?: boolean onConflict?: string }, - ): EncryptedQueryBuilder + ): Self update( data: Partial, options?: { count?: 'exact' | 'planned' | 'estimated' }, - ): EncryptedQueryBuilder + ): Self upsert( data: Partial | Partial[], options?: { @@ -256,61 +565,31 @@ export interface EncryptedQueryBuilder< ignoreDuplicates?: boolean defaultToNull?: boolean }, - ): EncryptedQueryBuilder - delete(options?: { - count?: 'exact' | 'planned' | 'estimated' - }): EncryptedQueryBuilder - eq>(column: K, value: T[K]): EncryptedQueryBuilder - neq>( - column: K, - value: T[K], - ): EncryptedQueryBuilder - gt>(column: K, value: T[K]): EncryptedQueryBuilder - gte>( - column: K, - value: T[K], - ): EncryptedQueryBuilder - lt>(column: K, value: T[K]): EncryptedQueryBuilder - lte>( - column: K, - value: T[K], - ): EncryptedQueryBuilder - like>( - column: K, - pattern: string, - ): EncryptedQueryBuilder - ilike>( - column: K, - pattern: string, - ): EncryptedQueryBuilder - is>( - column: K, - value: null | boolean, - ): EncryptedQueryBuilder - in>( - column: K, - values: T[K][], - ): EncryptedQueryBuilder - filter>( - column: K, - operator: string, - value: T[K], - ): EncryptedQueryBuilder - not>( - column: K, - operator: string, - value: T[K], - ): EncryptedQueryBuilder + ): Self + delete(options?: { count?: 'exact' | 'planned' | 'estimated' }): Self + eq(column: K, value: T[K]): Self + neq(column: K, value: T[K]): Self + gt(column: K, value: T[K]): Self + gte(column: K, value: T[K]): Self + lt(column: K, value: T[K]): Self + lte(column: K, value: T[K]): Self + is(column: K, value: null | boolean): Self + in(column: K, values: T[K][]): Self + filter(column: K, operator: string, value: T[K]): Self + not(column: K, operator: string, value: T[K]): Self or( filters: string, options?: { referencedTable?: string; foreignTable?: string }, - ): EncryptedQueryBuilder + ): Self or( conditions: PendingOrCondition[], options?: { referencedTable?: string; foreignTable?: string }, - ): EncryptedQueryBuilder - match(query: Partial): EncryptedQueryBuilder - order>( + ): Self + match(query: Partial): Self + // `OK`, not `FK`: v3 cannot order by ANY encrypted column, because PostgREST + // cannot emit `ORDER BY eql_v3.ord_term(col)` and a bare `ORDER BY` sorts the + // ciphertext envelope. `OK` defaults to `FK`, so the v2 surface is unchanged. + order( column: K, options?: { ascending?: boolean @@ -318,22 +597,32 @@ export interface EncryptedQueryBuilder< referencedTable?: string foreignTable?: string }, - ): EncryptedQueryBuilder + ): Self limit( count: number, options?: { referencedTable?: string; foreignTable?: string }, - ): EncryptedQueryBuilder + ): Self range( from: number, to: number, options?: { referencedTable?: string; foreignTable?: string }, - ): EncryptedQueryBuilder - single(): EncryptedQueryBuilder - maybeSingle(): EncryptedQueryBuilder - csv(): EncryptedQueryBuilder - abortSignal(signal: AbortSignal): EncryptedQueryBuilder - throwOnError(): EncryptedQueryBuilder + ): Self + single(): Self + maybeSingle(): Self + csv(): Self + abortSignal(signal: AbortSignal): Self + throwOnError(): Self + /** Escape hatch: re-types the rows and drops back to the v2 builder surface. */ returns>(): EncryptedQueryBuilder - withLockContext(lockContext: LockContext): EncryptedQueryBuilder - audit(config: AuditConfig): EncryptedQueryBuilder + withLockContext(lockContext: LockContext): Self + audit(config: AuditConfig): Self +} + +/** The v2 builder: free-text search via SQL wildcard matching. */ +export interface EncryptedQueryBuilder< + T extends Record = Record, + FK extends StringKeyOf = StringKeyOf, +> extends EncryptedQueryBuilderCore> { + like(column: K, pattern: string): EncryptedQueryBuilder + ilike(column: K, pattern: string): EncryptedQueryBuilder } diff --git a/packages/stack/src/supabase/verify.ts b/packages/stack/src/supabase/verify.ts new file mode 100644 index 000000000..b20226fd8 --- /dev/null +++ b/packages/stack/src/supabase/verify.ts @@ -0,0 +1,64 @@ +import { stripDomainSchema } from '@/eql/v3/domain-registry' +import type { IntrospectionResult } from './introspect' +import type { V3Schemas } from './schema-builder' + +/** + * Verify declared v3 tables against the introspected database. For every + * declared column, assert the column exists and its introspected `domain_name` + * matches the declared (unqualified) `eqlType`. Any mismatch throws at + * construction — a wrong domain is caught here instead of as a 23514 CHECK + * violation on the first query. Declaring a subset of a table's encrypted + * columns is allowed; undeclared columns are synthesized from their domains. + */ +export function verifyDeclaredSchemas( + schemas: V3Schemas, + introspection: IntrospectionResult, +): void { + const index = new Map>() + for (const table of introspection) { + const cols = new Map() + for (const col of table.columns) cols.set(col.columnName, col.domainName) + index.set(table.tableName, cols) + } + + for (const declared of Object.values(schemas)) { + const tableName = declared.tableName + const cols = index.get(tableName) + if (!cols) { + throw new Error( + `[supabase v3]: declared table "${tableName}" was not found in the database`, + ) + } + // Two properties resolving to the same DB column each verify fine, then + // collide in `mergeDeclaredTables` and blow up inside + // `EncryptedTable.build()` — from the eql/v3 layer, naming neither the + // properties nor the `schemas` entry. Catch it here, where both are known. + const dbNameOwner = new Map() + for (const [property, builder] of Object.entries(declared.columnBuilders)) { + const dbName = builder.getName() + const owner = dbNameOwner.get(dbName) + if (owner !== undefined) { + throw new Error( + `[supabase v3]: table "${tableName}" declares properties "${owner}" and "${property}" on the same DB column "${dbName}" — each column may be declared once`, + ) + } + dbNameOwner.set(dbName, property) + } + + for (const builder of Object.values(declared.columnBuilders)) { + const dbName = builder.getName() + if (!cols.has(dbName)) { + throw new Error( + `[supabase v3]: declared column "${tableName}.${dbName}" was not found in the database`, + ) + } + const expected = stripDomainSchema(builder.getEqlType()) + const actual = cols.get(dbName) + if (actual !== expected) { + throw new Error( + `[supabase v3]: column "${tableName}.${dbName}" has domain "${actual ?? '(none)'}" but the schema declares "${expected}"`, + ) + } + } + } +} diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts index 0a342ae0a..05cdc914c 100644 --- a/packages/stack/tsup.config.ts +++ b/packages/stack/tsup.config.ts @@ -30,7 +30,7 @@ export default defineConfig([ clean: false, target: 'es2022', tsconfig: './tsconfig.json', - external: ['drizzle-orm', '@supabase/supabase-js'], + external: ['drizzle-orm', '@supabase/supabase-js', 'pg'], // zod + @byteslice/result are bundled so dist/wasm-inline.js carries // no bare-specifier transitive imports — important for Deno / Edge / // browser consumers whose runtime won't resolve npm names without an @@ -52,7 +52,7 @@ export default defineConfig([ clean: false, target: 'es2022', tsconfig: './tsconfig.json', - external: ['drizzle-orm', '@supabase/supabase-js'], + external: ['drizzle-orm', '@supabase/supabase-js', 'pg'], noExternal: ['evlog', 'uuid', 'zod', '@byteslice/result'], }, ]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d153e96e..0fe174740 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -576,9 +576,15 @@ importers: '@clack/prompts': specifier: ^1.4.0 version: 1.4.0 + '@supabase/postgrest-js': + specifier: 2.105.4 + version: 2.105.4 '@supabase/supabase-js': specifier: ^2.105.4 version: 2.105.4 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/uuid': specifier: ^11.0.0 version: 11.0.0 @@ -591,12 +597,18 @@ importers: execa: specifier: ^9.5.2 version: 9.6.1 + fast-check: + specifier: ^4.8.0 + version: 4.8.0 fta-cli: specifier: 3.0.0 version: 3.0.0 json-schema-to-typescript: specifier: ^15.0.2 version: 15.0.4 + pg: + specifier: 8.20.0 + version: 8.20.0 postgres: specifier: ^3.4.8 version: 3.4.9