Skip to content

Commit 04290f5

Browse files
authored
Merge pull request #588 from cipherstash/feat/eql-v3-supabase-adapter
feat(stack): encryptedSupabaseV3 — EQL v3 dialect of the Supabase adapter
2 parents 222be16 + 60fd960 commit 04290f5

43 files changed

Lines changed: 7443 additions & 313 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
'@cipherstash/stack': minor
3+
---
4+
5+
Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter. It is
6+
now a connect-time-async factory: `await encryptedSupabaseV3(url, key)` (or
7+
`(client)`) introspects the database over `DATABASE_URL`, detects EQL v3 columns
8+
by their Postgres domain (`information_schema.columns.domain_name`), and derives
9+
each column's encryption config from its domain — callers no longer pass a
10+
schema to `from()`. `select('*')` is supported (expanded from the introspected
11+
column list, and aliased back to each declared column's JS property name so a
12+
property→DB rename round-trips). A column using an EQL v3 domain this SDK version does not model
13+
(e.g. `public.json`, `*_ord_ope`) throws at construction rather than silently
14+
passing through. Supplying `schemas` remains optional and adds compile-time
15+
types plus startup verification of the declared tables against the database.
16+
Requires a Postgres connection for introspection (`pg` is a new optional peer),
17+
so it cannot run in a Worker or the browser.
18+
19+
Every column name a query carries — filters, `match`, `not`, raw `filter`,
20+
`or()`, `order()`, and the `onConflict` option — is now resolved from its JS
21+
property name to its DB column name in a single pass before the query is built,
22+
so a declared rename round-trips everywhere rather than only on the paths that
23+
remembered to translate.
24+
25+
`order()` on ANY encrypted v3 column is now rejected — at compile time when
26+
`schemas` is supplied, and at runtime otherwise. The EQL v3 domains are
27+
`DOMAIN … AS jsonb` and the bundle declares no btree operator class on them, so
28+
`ORDER BY col` resolves through jsonb's default `jsonb_cmp` and sorts by the
29+
envelope's byte structure: a stable, plausible-looking, meaningless row order,
30+
with no error. Correct ordering is `ORDER BY eql_v3.ord_term(col)`, which
31+
PostgREST's `order=` cannot express. Order by a plaintext column, expose
32+
`eql_v3.ord_term()` as a generated column or view, or use the EQL v3 Drizzle
33+
integration, which emits `ord_term` directly. Note `gte`/`lte` filters remain
34+
correct: the comparison operators *are* declared on the ord domains, and only
35+
sorting resolves through the missing operator class.
36+
37+
`.or()` now understands PostgREST's `column.not.<op>.<value>` negation. It was
38+
previously parsed as `{ op: 'not', value: '<op>.<value>' }`, so on an encrypted
39+
column `or('nickname.not.in.(ada,grace)')` encrypted the literal string
40+
`in.(ada,grace)` as a single plaintext and produced a filter that silently
41+
matched nothing.
42+
43+
Free-text search on the v3 builder is `contains(column, value)`. `like`/`ilike`
44+
are not exposed, because EQL v3 free-text search is token containment over a
45+
bloom filter (`@>`, backed by `eql_v3.contains`) rather than SQL wildcard
46+
matching — `%` is tokenized like any other character, so a `like` pattern is a
47+
category error. This matches the v3 Drizzle integration, which omits them for
48+
the same reason. On an encrypted column `like`/`ilike` now throw and name
49+
`contains`; on a plaintext column they remain ordinary PostgREST filters.
50+
51+
`contains` is narrowed at compile time to columns whose domain carries the
52+
`freeTextSearch` capability (`public.text_match`, `public.text_search`), and
53+
guarded at runtime for the untyped surface. A raw `filter(column, operator, …)`
54+
on an encrypted v3 column now derives its query type from the operator instead
55+
of always encrypting an equality term, so `filter('bio', 'cs', …)` on a
56+
`public.text_match` column works rather than being rejected, and an unsupported
57+
operator throws instead of silently encrypting the wrong term.
58+
59+
Substring `contains` still matches only when the needle equals the stored value
60+
or is exactly the tokenizer's window (3 characters): the operand is a storage
61+
envelope whose bloom carries the whole needle as an `include_original` token.
62+
This is shared with v3 Drizzle's `contains` and tracked upstream in EQL.
63+
64+
v2 (`encryptedSupabase`) is unchanged: it keeps `like`/`ilike` (`eql_v2.like`,
65+
`~~`) and its raw-`filter` query-type mapping, so no v2 ciphertext moves.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@cipherstash/stack': patch
3+
---
4+
5+
Fix the Supabase adapter encrypting `is` and `null` filter operands.
6+
7+
`is` is a SQL predicate — PostgREST accepts only `null`/`true`/`false` after it
8+
— and a `null` operand is SQL NULL, never a value to search for. Only the direct
9+
`.is()` filter skipped encryption; `not()`, `or()`, `match()`, raw `filter()`,
10+
and the `in()` element list all encrypted whatever they were handed. So
11+
`or('age.is.null')` emitted `age.is."("null")"` and `eq('email', null)` emitted
12+
`email=("null")` — operands PostgREST rejects. A null plaintext is stored as a
13+
NULL column rather than ciphertext, so it is found with an unencrypted
14+
`IS NULL`; encrypting the operand could never match.
15+
16+
A single `isEncryptableTerm(operator, value)` predicate now guards every term
17+
collector. Affects both `encryptedSupabase` (v2) and `encryptedSupabaseV3`. On
18+
v3 this additionally removes a spurious `does not support equality queries`
19+
error, which `is` raised because it maps to the `equality` query type and so hit
20+
the column-capability guard — `or('active.is.null')` on a storage-only column
21+
threw rather than querying.
22+
23+
Relatedly, an `or()` string is now rebuilt whenever a condition *references* an
24+
encrypted column, not only when one of its values was encrypted. An `is` on an
25+
encrypted column encrypts nothing, and the old condition sent it down the
26+
verbatim path, forwarding the caller's JS property name to a database that only
27+
knows the column's DB name.

.github/workflows/tests.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,34 @@ jobs:
8787
echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env
8888
echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env
8989
90+
# PostgREST for `supabase-v3-pgrest-live.test.ts` — the only suite that
91+
# executes the supabase v3 adapter against a real server rather than a
92+
# mock that records strings.
93+
#
94+
# Started as a STEP, not a `services:` entry. The role it connects as
95+
# (`authenticator`, a non-superuser member of `anon`) does not exist in
96+
# the postgres image, and service containers all start before the repo is
97+
# checked out — so there is no point at which a `services:` PostgREST
98+
# could find it. A step runs after the roles exist, deterministically.
99+
#
100+
# `PGRST_DB_ANON_ROLE: anon` is the whole point: pointing it at the DB
101+
# owner would make every grant check pass vacuously.
102+
- name: Start PostgREST
103+
run: |
104+
PGPASSWORD=password psql -h localhost -U cipherstash -d cipherstash \
105+
-v ON_ERROR_STOP=1 -f ./local/postgrest-roles.sql
106+
docker run -d --name postgrest --network host \
107+
-e PGRST_DB_URI="postgres://authenticator:authpass@localhost:5432/cipherstash" \
108+
-e PGRST_DB_SCHEMAS=public \
109+
-e PGRST_DB_ANON_ROLE=anon \
110+
-e PGRST_DB_CHANNEL_ENABLED=true \
111+
postgrest/postgrest:v12.2.12
112+
for i in $(seq 1 30); do
113+
curl -sf -o /dev/null http://localhost:3000/ && exit 0
114+
sleep 1
115+
done
116+
echo "PostgREST did not become ready"; docker logs postgrest; exit 1
117+
90118
- name: Create .env file in ./packages/stack/
91119
run: |
92120
touch ./packages/stack/.env
@@ -95,6 +123,7 @@ jobs:
95123
echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/stack/.env
96124
echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env
97125
echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env
126+
echo "PGRST_URL=http://localhost:3000" >> ./packages/stack/.env
98127
99128
- name: Create .env file in ./packages/protect-dynamodb/
100129
run: |

0 commit comments

Comments
 (0)