Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions .changeset/eql-v3-supabase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@cipherstash/stack': minor
'stash': minor
---

Add EQL v3 Supabase support.

`@cipherstash/stack/supabase` gains `encryptedSupabaseV3` — the EQL v3
counterpart of `encryptedSupabase` for schemas authored with
`@cipherstash/stack/eql/v3`. The public surface and call shape are identical
to v2 (same filter methods, `withLockContext`, `audit`); only the schema type
and wire encoding differ:

- columns are stored in their native `eql_v3.*` domains (raw jsonb payloads,
no composite wrap), with JS property → DB column name resolution and `Date`
reconstruction from `cast_as` on decrypted rows;
- filter operands are full storage envelopes (every `eql_v3.*` domain CHECK
requires the storage keys, and the SQL operators coerce their operand into
the domain);
- `like`/`ilike` on encrypted columns are emitted as PostgREST `cs`
(bloom-filter `@>`) — the v3 domains define no LIKE operator;
- filters on storage-only columns (e.g. `types.Bool`) are rejected at the
type level (with an explicit row type) and at runtime.

The CLI installer gains an EQL v3 path: `stash db install --eql-version 3`
installs the vendored v3 bundle (`--supabase` selects the opclass-stripped
variant and applies the `eql_v3` grants for the Supabase roles). The v2
`SUPABASE_PERMISSIONS_SQL` block is now generated from a shared
`supabasePermissionsSql(schemaName)` helper, with `SUPABASE_PERMISSIONS_SQL_V3`
keyed to `eql_v3`.
164 changes: 164 additions & 0 deletions docs/reference/supabase-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Supabase SDK reference

`@cipherstash/stack/supabase` wraps a supabase-js client so encrypted columns
are transparently encrypted on mutations, `::jsonb`-cast on selects, encrypted
in filter terms, and decrypted in results.

Two entry points, one query mechanism:

| Entry point | Schema DSL | Column storage |
|---|---|---|
| `encryptedSupabase` | `@cipherstash/stack/schema` (EQL v2) | `eql_v2_encrypted` composite |
| `encryptedSupabaseV3` | `@cipherstash/stack/eql/v3` (EQL v3) | native `eql_v3.*` domains |

Both filter via **direct EQL operators over PostgREST**: the wrapper encrypts
the filter term and emits an ordinary `col <op> term` filter, which resolves
to the custom operator defined on the encrypted type (equality by HMAC, range
by ORE, free-text by bloom-filter containment).

## Quick start (EQL v3)

```typescript
import { Encryption } from '@cipherstash/stack'
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
import { encryptedSupabaseV3 } from '@cipherstash/stack/supabase'
import { createClient } from '@supabase/supabase-js'

const users = encryptedTable('users', {
email: types.TextSearch('email'), // eql_v3.text_search
amount: types.Int4Ord('amount'), // eql_v3.int4_ord
})

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
const client = await Encryption({ schemas: [users] })
const es = encryptedSupabaseV3({ encryptionClient: client, supabaseClient: supabase })

await es.from('users', users).insert({ email: 'a@b.com', amount: 30 })

const { data } = await es
.from('users', users)
.select('id, email, amount')
.eq('email', 'a@b.com')

await es.from('users', users).select('id, amount').gte('amount', 10).lte('amount', 100)
```

The builder surface is identical across v2 and v3:
`.select/.insert/.update/.upsert/.delete`,
`.eq/.neq/.in/.like/.ilike/.is/.gt/.gte/.lt/.lte/.match/.or/.not/.filter`,
transforms (`.order/.limit/.range/.single/.maybeSingle/.csv/.abortSignal/.throwOnError`),
plus `.withLockContext(lockContext)` and `.audit(config)`.

### Typing (v3)

`es.from('users', users)` infers rows from the table (schema columns get their
domain plaintext types — `types.Int4Ord` → `number`, `types.TimestamptzOrd` →
`Date`, …). Pass an explicit row type to also pin passthrough columns:

```typescript
type UserRow = { id: number; email: string; amount: number }
const builder = es.from<typeof users, UserRow>('users', users)
```

With an explicit row type, storage-only columns (e.g. `types.Bool`) are
excluded from the filter methods at the type level; filtering one is always a
clear runtime error.

### Property ↔ DB column names (v3)

A v3 column can map a JS property to a different DB name:

```typescript
const events = encryptedTable('events', {
createdAt: types.TimestamptzOrd('created_at'),
})
```

The adapter resolves the mapping everywhere: filters and mutations address
`created_at`, selects alias it back (`createdAt:created_at::jsonb`), and
result rows are keyed by `createdAt`. `date` / `timestamptz` columns decrypt
to real `Date` objects (reconstructed from the encrypt-config `cast_as`).

## Database setup

### v3: per-domain columns

```sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email eql_v3.text_search,
amount eql_v3.int4_ord,
created_at eql_v3.timestamptz_ord
);
```

The `types.*` member name maps to the domain name: strip the `eql_v3.` prefix
and PascalCase each `_`-separated segment (`types.TextEq` → `eql_v3.text_eq`,
`types.Int4Ord` → `eql_v3.int4_ord`, `types.Timestamptz` → `eql_v3.timestamptz`).

### Install EQL

```bash
# v2 (default)
stash db install --supabase

# v3
stash db install --eql-version 3 --supabase
```

The `--supabase` install uses the opclass-stripped bundle (operator classes /
families require superuser, which Supabase does not grant) and applies the
schema grants for `anon`, `authenticated`, and `service_role`. Without the
grants, encrypted queries fail with `42501`.

### Exposed schemas (manual, required)

For a bare `col <op> term` filter to reach the custom operator, the EQL schema
(`eql_v2` for v2, `eql_v3` for v3) must be on PostgREST's request-time
search_path — add it to **Dashboard → Settings → API → Exposed schemas**
([Supabase custom-schemas guide](https://supabase.com/docs/guides/api/using-custom-schemas)).

> **Warning — silent fallback.** If the schema is not exposed, the operators
> do not error: comparisons silently fall back to the base jsonb operators and
> return **wrong rows with no error**. After changing the setting, verify with
> a known-value round-trip: insert a row, filter for it by an encrypted
> column, and assert the hit.

## v3 encoding details

These are internal to the adapter but explain observable behaviour:

- **Filter operands are full storage envelopes.** Every `eql_v3.*` domain
CHECK requires the storage keys (`v`/`i`/`c` plus the domain's index terms:
`hm` for `text_eq`, `ob` for `int4_ord`, all three for `text_search`), and
the SQL operator functions coerce their jsonb operand into the domain. A
narrowed query-only term (no ciphertext) fails the CHECK with `23514` for
every domain, so the adapter encrypts each filter value with the full
storage path and the operators extract the term they need
(`eq_term`/`ord_term`/`match_term`).
- **`like`/`ilike` are emitted as PostgREST `cs`** (`@>` bloom containment) —
the v3 domains define no LIKE operator. Match is tokenized + downcased, so
`like` and `ilike` behave identically; do not include `%` wildcards.
- **Free-text search needs `include_original: false`** on the column's match
index for substring patterns to match:

```typescript
types.TextSearch('email').freeTextSearch({ include_original: false })
```

With the default `include_original: true`, the full-envelope operand's bloom
carries the whole pattern as an extra token that only matches when the
pattern equals the stored value.
- **Mutations send the raw encrypted payload** (the domains are
`DOMAIN … AS jsonb`), unlike v2's `{ data: … }` composite wrap.

## Caveats (shared by v2 and v3)

- **No `ORDER BY` on encrypted columns.** Operator families need superuser, so
the Supabase install ships without index acceleration and without an
orderable opclass. Range *filtering* (`WHERE col >= term`) works; sorting
does not. OPE index terms that are natively orderable on Supabase (btree +
`ORDER BY`, built-in comparison) are in active development.
- **`select('*')` is rejected** — list columns explicitly so encrypted columns
can be cast.
- **Operator visibility depends on the Exposed-schemas step** (above).
81 changes: 81 additions & 0 deletions packages/cli/scripts/build-eql-v3-sql.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* Vendor the EQL v3 SQL bundles into packages/cli/src/sql/.
*
* Source of truth: the generated monolith checked in at
* packages/stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql
* (itself generated from the upstream encrypt-query-language repo).
*
* Outputs:
* - cipherstash-encrypt-v3.sql — full bundle, byte-identical copy
* - cipherstash-encrypt-v3-supabase.sql — Supabase variant with the two
* `CREATE OPERATOR CLASS`/`FAMILY` chunks removed (they need superuser,
* which Supabase does not grant)
*
* The Supabase strip mirrors the upstream build's `**\/*operator_class.sql`
* exclusion glob: the monolith annotates every constituent file with a
* `--! @file <path>` marker, so the variant drops each
* `--! @file .../operator_class.sql` chunk up to the next `--! @file` marker.
*
* TEMPORARY vendoring strategy (sync risk): once upstream publishes v3 release
* artifacts (like the eql-2.x `cipherstash-encrypt[-supabase].sql` assets),
* regenerate these from the release instead and record the version.
*
* Usage: node packages/cli/scripts/build-eql-v3-sql.mjs
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const source = resolve(
here,
'../../stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql',
)
const outDir = resolve(here, '../src/sql')

const FILE_MARKER = /^--! @file (.+)$/
const EXCLUDE = /operator_class\.sql$/

function stripOperatorClassChunks(sql) {
const lines = sql.split('\n')
const out = []
let skipping = false
let removedChunks = 0

for (const line of lines) {
const marker = line.match(FILE_MARKER)
if (marker) {
skipping = EXCLUDE.test(marker[1])
if (skipping) removedChunks++
}
if (!skipping) out.push(line)
}

if (removedChunks !== 2) {
throw new Error(
`Expected to remove exactly 2 operator_class chunks, removed ${removedChunks} — the bundle layout changed; review the strip logic.`,
)
}

const stripped = out.join('\n')
if (/CREATE OPERATOR (CLASS|FAMILY)/.test(stripped)) {
throw new Error(
'Stripped bundle still contains CREATE OPERATOR CLASS/FAMILY statements.',
)
}

return stripped
}

const sql = readFileSync(source, 'utf8')

writeFileSync(resolve(outDir, 'cipherstash-encrypt-v3.sql'), sql)
writeFileSync(
resolve(outDir, 'cipherstash-encrypt-v3-supabase.sql'),
stripOperatorClassChunks(sql),
)

console.log(
'Wrote cipherstash-encrypt-v3.sql and cipherstash-encrypt-v3-supabase.sql',
)
85 changes: 85 additions & 0 deletions packages/cli/src/__tests__/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,91 @@ describe('EQLInstaller', () => {
expect(SUPABASE_PERMISSIONS_SQL).toContain('service_role')
})

it('installs the v3 bundle and grants eql_v3 permissions with eqlVersion: 3 + supabase', async () => {
mockConnect.mockResolvedValue(undefined)
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
mockEnd.mockResolvedValue(undefined)

const { EQLInstaller, SUPABASE_PERMISSIONS_SQL_V3 } = await import(
'@/installer/index.ts'
)
const installer = new EQLInstaller({
databaseUrl: 'postgresql://localhost:5432/test',
})

await installer.install({ eqlVersion: 3, supabase: true })

const otherCalls = mockQuery.mock.calls
.map((call: unknown[]) => call[0])
.filter(
(sql: unknown): sql is string =>
typeof sql === 'string' &&
sql !== 'BEGIN' &&
sql !== 'COMMIT' &&
sql !== 'ROLLBACK',
)

expect(otherCalls).toHaveLength(2)
// The bundled SQL is the v3 Supabase variant: creates eql_v3, no
// operator classes/families (they need superuser).
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.
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')
})

it('installs the full v3 bundle (with operator classes) without supabase', async () => {
mockConnect.mockResolvedValue(undefined)
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
mockEnd.mockResolvedValue(undefined)

const { EQLInstaller } = await import('@/installer/index.ts')
const installer = new EQLInstaller({
databaseUrl: 'postgresql://localhost:5432/test',
})

await installer.install({ eqlVersion: 3 })

const sqlCall = mockQuery.mock.calls.find(
(call: string[]) =>
typeof call[0] === 'string' &&
call[0] !== 'BEGIN' &&
call[0] !== 'COMMIT',
)
expect(sqlCall).toBeDefined()
expect(sqlCall?.[0]).toContain('eql_v3')
expect(sqlCall?.[0]).toContain('CREATE OPERATOR CLASS')
})

it('rejects latest: true for eqlVersion: 3', async () => {
const { EQLInstaller } = await import('@/installer/index.ts')
const installer = new EQLInstaller({
databaseUrl: 'postgresql://localhost:5432/test',
})

await expect(
installer.install({ eqlVersion: 3, latest: true }),
).rejects.toThrow('not supported for EQL v3')
})

it('checks the eql_v3 schema for isInstalled({ eqlVersion: 3 })', async () => {
mockConnect.mockResolvedValue(undefined)
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
mockEnd.mockResolvedValue(undefined)

const { EQLInstaller } = await import('@/installer/index.ts')
const installer = new EQLInstaller({
databaseUrl: 'postgresql://localhost:5432/test',
})

await installer.isInstalled({ eqlVersion: 3 })

expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['eql_v3'])
})

it('rolls back on SQL execution failure', async () => {
mockConnect.mockResolvedValue(undefined)
mockEnd.mockResolvedValue(undefined)
Expand Down
Loading
Loading