Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5d23e80
feat(stack): encryptedSupabaseV3 — EQL v3 dialect of the Supabase ada…
tobyhede Jul 8, 2026
2079d74
feat(stack): add v3 DOMAIN_REGISTRY keyed by unqualified domain name
tobyhede Jul 9, 2026
cb93504
feat(stack): expand select('*') from an introspected column list
tobyhede Jul 9, 2026
482bdde
feat(stack): introspect public columns + EQL v3 domains (by comment) …
tobyhede Jul 9, 2026
bbe0674
feat(stack): synthesize/merge v3 tables + guard unmodelled EQL domains
tobyhede Jul 9, 2026
722ca60
feat(stack): verify declared v3 schemas against introspected domains
tobyhede Jul 9, 2026
6769ca2
feat(stack): introspecting encryptedSupabaseV3 factory + optional sch…
tobyhede Jul 9, 2026
2354af4
test(stack): live Postgres introspection + unmodelled-domain partition
tobyhede Jul 9, 2026
dcb3745
fix(stack): select('*') must alias renamed v3 columns back to their p…
tobyhede Jul 9, 2026
98f657a
fix(stack): adapt supabase merge test to the removed v3 freeTextSearc…
tobyhede Jul 9, 2026
f785339
fix(stack): resolve v3 column renames in order() and onConflict
tobyhede Jul 9, 2026
3048d88
refactor(stack): translate supabase column names once, at a phase bou…
tobyhede Jul 9, 2026
34622f1
fix(stack): actionable error when the optional `pg` peer is missing
tobyhede Jul 9, 2026
3fe3c0c
fix(stack): null-prototype the synthesized/merged v3 column maps
tobyhede Jul 9, 2026
5fdfd87
docs(stack): show the SQL a v3 column is detected from; add introspec…
tobyhede Jul 9, 2026
3ecece0
fix(stack): null-prototype the built encrypt config; cover the __prot…
tobyhede Jul 9, 2026
fd33aad
fix(stack): never encrypt `is` or null filter operands in the supabas…
tobyhede Jul 9, 2026
24784e2
test(stack): cover addJsonbCastsV3, single() dates, forced eqlVersion…
tobyhede Jul 9, 2026
d02717c
fix(stack): scope the unmodelled-domain guard to the tables the calle…
tobyhede Jul 9, 2026
50494d0
fix(stack): escape double quotes and backslashes in .or() filter oper…
tobyhede Jul 9, 2026
81d2eb2
fix(stack): encrypt each element of an in() list inside .or()
tobyhede Jul 9, 2026
9c20935
test(stack): drive all 39 v3 domains through the supabase adapter
tobyhede Jul 9, 2026
a54bd5b
fix(cli): grant eql_v3_internal to the supabase roles
tobyhede Jul 9, 2026
3d9f3d5
fix(stack): reject property/DB name collisions at construction; corre…
tobyhede Jul 9, 2026
1666655
test(stack): run the supabase v3 adapter against a real PostgREST as …
tobyhede Jul 9, 2026
cbf2da4
test(stack): close the audited v3 coverage gaps
tobyhede Jul 9, 2026
60fd960
feat(stack)!: replace like/ilike with contains on the v3 supabase sur…
tobyhede Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .changeset/eql-v3-supabase-adapter.md
Original file line number Diff line number Diff line change
@@ -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.<op>.<value>` negation. It was
previously parsed as `{ op: 'not', value: '<op>.<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.
27 changes: 27 additions & 0 deletions .changeset/supabase-is-null-operands.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
Loading
Loading