From 5d23e8023949dbdf94d5bc57bef0f7b4d14c5645 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 8 Jul 2026 16:50:44 +1000 Subject: [PATCH 01/27] =?UTF-8?q?feat(stack):=20encryptedSupabaseV3=20?= =?UTF-8?q?=E2=80=94=20EQL=20v3=20dialect=20of=20the=20Supabase=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 query mechanism (direct EQL operators over PostgREST) is unchanged: EncryptedQueryBuilderImpl gains narrow protected seams whose defaults preserve the v2 behaviour byte-for-byte, and a v3 subclass (query-builder-v3.ts) overrides them for native public.* concrete-domain columns: - column recognition + property<->DB name resolution via buildColumnKeyMap (filters, mutations, aliased select casts prop:db::jsonb) - raw jsonb mutation payloads (no eql_v2 composite wrap) - full-envelope filter operands: every public.* domain CHECK requires the storage keys (v/i/c + index terms) and the SQL operators coerce their jsonb operand into the domain, so a narrowed encryptQuery term fails 23514 on EVERY domain — all operands go through encrypt() instead - like/ilike on encrypted columns -> PostgREST cs (bloom @>) - Date reconstruction from cast_as (date/timestamp) on decrypted rows - capability validation: filters on storage-only columns or unsupported query types throw typed + runtime errors; filter keys are type-narrowed Wire-encoding unit tests (mock encryption + supabase clients) cover both dialects, including v2 regression pins for the seams. Re-expressed from james/cip-3291-bigint-stack ecd3f38d against the base's query-builder and the public.* domain surface (protect-ffi 0.28). --- .changeset/eql-v3-supabase-adapter.md | 17 + .../__tests__/supabase-v3-builder.test.ts | 476 ++++++++++++++++++ .../stack/__tests__/supabase-v3.test-d.ts | 114 +++++ packages/stack/src/supabase/helpers.ts | 61 +++ packages/stack/src/supabase/index.ts | 62 +++ .../stack/src/supabase/query-builder-v3.ts | 271 ++++++++++ packages/stack/src/supabase/query-builder.ts | 229 ++++++--- packages/stack/src/supabase/types.ts | 146 ++++-- 8 files changed, 1269 insertions(+), 107 deletions(-) create mode 100644 .changeset/eql-v3-supabase-adapter.md create mode 100644 packages/stack/__tests__/supabase-v3-builder.test.ts create mode 100644 packages/stack/__tests__/supabase-v3.test-d.ts create mode 100644 packages/stack/src/supabase/query-builder-v3.ts diff --git a/.changeset/eql-v3-supabase-adapter.md b/.changeset/eql-v3-supabase-adapter.md new file mode 100644 index 000000000..018ba637f --- /dev/null +++ b/.changeset/eql-v3-supabase-adapter.md @@ -0,0 +1,17 @@ +--- +'@cipherstash/stack': minor +--- + +Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter, for +tables authored with `@cipherstash/stack/eql/v3` (native `public.*` concrete +domains, `eql_v3` operators). The v2 query mechanism (direct EQL operators over +PostgREST) is unchanged: `EncryptedQueryBuilderImpl` gains narrow protected +seams whose defaults preserve v2 byte-for-byte, and a v3 subclass overrides them +for property↔DB-name resolution (`buildColumnKeyMap`, aliased `prop:db::jsonb` +select casts), raw jsonb mutation payloads (no `eql_v2` composite wrap), +full-envelope filter operands (every `public.*` domain CHECK needs the storage +keys, so narrowed query terms are not usable), `like`/`ilike` → PostgREST `cs` +(bloom `@>`), `Date` reconstruction from `cast_as`, and capability validation +(filtering a storage-only column or with an unsupported query type throws a +typed + runtime error). Filter keys are type-narrowed to exclude storage-only +columns. 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..e13e66986 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' +import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' +import { encryptedSupabase, encryptedSupabaseV3 } from '@/supabase' + +// --------------------------------------------------------------------------- +// Mocks +// +// 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. +// --------------------------------------------------------------------------- + +type FakeEnvelope = { + v: 2 + i: { t: string; c: string } + c: string + hm: string + pt: unknown +} + +function fakeEnvelope(value: unknown, column: string): FakeEnvelope { + const pt = value instanceof Date ? value.toISOString() : value + return { + v: 2, + i: { t: 'tbl', c: column }, + c: `ct:${String(pt)}`, + hm: `hm:${String(pt)}`, + pt, + } +} + +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. */ +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 +} + +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 +} + +type RecordedCall = { method: string; args: unknown[] } + +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', + '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 } +} + +// --------------------------------------------------------------------------- +// 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'), +}) + +const usersV2 = encryptedTableV2('users', { + email: encryptedColumn('email').freeTextSearch().equality(), + age: encryptedColumn('age').dataType('number').equality().orderAndRange(), +}) + +function v3Instance(resultData: unknown = []) { + const supabase = createMockSupabase(resultData) + const es = encryptedSupabaseV3({ + encryptionClient: createMockEncryptionClient(), + supabaseClient: supabase.client, + }) + return { es, supabase } +} + +// --------------------------------------------------------------------------- +// 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 like/ilike as PostgREST cs (bloom-filter containment)', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id, email').like('email', 'a@b') + await es.from('users', users).select('id, email').ilike('email', 'a@b') + + const filterCalls = supabase.callsFor('filter') + expect(filterCalls).toHaveLength(2) + for (const call of filterCalls) { + expect(call.args[0]).toBe('email') + expect(call.args[1]).toBe('cs') + expect(JSON.parse(call.args[2] as string).c).toBeDefined() + } + // No bare like/ilike reached PostgREST for the encrypted column + expect(supabase.callsFor('like')).toHaveLength(0) + expect(supabase.callsFor('ilike')).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(like) on encrypted columns to not(cs)', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id, email') + .not('email', 'like', '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') + }) + + 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', + ) + }) +}) + +// --------------------------------------------------------------------------- +// 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') + }) +}) 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..41b7c6d03 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3.test-d.ts @@ -0,0 +1,114 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' +import { encryptedColumn, encryptedTable as v2EncryptedTable } from '@/schema' +import { + type EncryptedQueryBuilder, + type EncryptedSupabaseResponse, + encryptedSupabase, + encryptedSupabaseV3, + type SupabaseClientLike, +} from '@/supabase' + +declare const encryptionClient: EncryptionClient +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'), +}) + +type UserRow = { + id: number + email: string + amount: number + createdAt: Date + active: boolean + note: string +} + +const es = encryptedSupabaseV3({ encryptionClient, supabaseClient }) + +describe('encryptedSupabaseV3 typing', () => { + it('defaults rows to InferPlaintext of the table plus passthrough fields', async () => { + const builder = es.from('users', users) + const { data } = await builder.select('id, email, amount') + + // Schema columns carry their domain plaintext types + 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 with an explicit row type', () => { + const builder = es.from('users', users) + + builder.eq('email', 'a@b.com') + builder.gte('amount', 10) + builder.gte('createdAt', new Date()) + builder.eq('id', 1) + + // Wrong value type for a column + // @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', () => { + const builder = es.from('users', users) + + // active is public.boolean — storage-only, not filterable + // @ts-expect-error — storage-only column is excluded from filter keys + builder.eq('active', true) + // @ts-expect-error — storage-only column is excluded from filter keys + builder.is('active', true) + }) + + it('accepts plaintext model values on insert', () => { + const builder = es.from('users', users) + + builder.insert({ email: 'a@b.com', amount: 3, createdAt: new Date() }) + builder.insert([{ email: 'a@b.com' }, { note: 'plain' }]) + + // @ts-expect-error — createdAt is a Date column + builder.insert({ createdAt: 'not-a-date' }) + }) + + it('resolves responses to the row type', () => { + const builder = es.from('users', users) + expectTypeOf(builder.select('id, email')).resolves.toEqualTypeOf< + EncryptedSupabaseResponse + >() + }) + + it('rejects a v2 schema', () => { + const v2Table = v2EncryptedTable('users', { + email: encryptedColumn('email').equality(), + }) + + // @ts-expect-error — encryptedSupabaseV3 only accepts v3 tables + es.from('users', v2Table) + }) +}) + +describe('encryptedSupabase (v2) typing is unchanged', () => { + it('keeps the single-generic builder shape', () => { + const esV2 = encryptedSupabase({ encryptionClient, supabaseClient }) + const v2Table = v2EncryptedTable('users', { + email: encryptedColumn('email').equality(), + }) + + type V2Row = { id: number; email: string } + const builder = esV2.from('users', v2Table) + expectTypeOf(builder).toEqualTypeOf>() + + builder.eq('email', 'a@b.com') + builder.eq('id', 1) + // @ts-expect-error — not a row key + builder.eq('missing', 1) + }) +}) diff --git a/packages/stack/src/supabase/helpers.ts b/packages/stack/src/supabase/helpers.ts index 82ea2a101..423057b22 100644 --- a/packages/stack/src/supabase/helpers.ts +++ b/packages/stack/src/supabase/helpers.ts @@ -68,6 +68,67 @@ export function addJsonbCasts( .join(',') } +/** + * 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, +): string { + 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 = propToDb[name] ?? (dbNames.has(name) ? name : undefined) + if (db !== undefined) { + return `${leadingWhitespace}${alias}:${db}::jsonb` + } + return col + } + + const db = 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(',') +} + /** * Map a Supabase filter operation to a CipherStash query type. */ diff --git a/packages/stack/src/supabase/index.ts b/packages/stack/src/supabase/index.ts index 471876ff1..73f2ac9c0 100644 --- a/packages/stack/src/supabase/index.ts +++ b/packages/stack/src/supabase/index.ts @@ -1,8 +1,13 @@ +import type { AnyV3Table, InferPlaintext } from '@/eql/v3' import type { EncryptedTable, EncryptedTableColumn } from '@/schema' import { EncryptedQueryBuilderImpl } from './query-builder' +import { EncryptedQueryBuilderV3Impl } from './query-builder-v3' import type { + EncryptedQueryBuilderV3, EncryptedSupabaseConfig, EncryptedSupabaseInstance, + EncryptedSupabaseV3Config, + EncryptedSupabaseV3Instance, } from './types' /** @@ -58,12 +63,69 @@ export function encryptedSupabase( } } +/** + * Create an encrypted Supabase wrapper for **EQL v3** schemas — tables + * authored with `@cipherstash/stack/eql/v3` whose columns are native + * concrete-domain columns (`public.*` type domains, `eql_v3` operators). + * + * The public surface and call shape are identical to {@link encryptedSupabase} + * (`.eq/.neq/.in/.gt/.gte/.lt/.lte/.like/.ilike/.match/.or/.not/.filter`, + * `withLockContext`, `audit`); only the schema type and the wire encoding + * differ. The same Supabase caveats carry over: the `eql_v3` schema must be + * added to the project's **Exposed schemas** and granted to the Supabase + * roles for the operators to resolve, and encrypted `ORDER BY` is + * unsupported (range *filtering* works). + * + * @example + * ```typescript + * import { Encryption } from '@cipherstash/stack' + * import { encryptedTable, types } from '@cipherstash/stack/eql/v3' + * import { encryptedSupabaseV3 } from '@cipherstash/stack/supabase' + * + * const users = encryptedTable('users', { + * email: types.TextSearch('email'), // public.text_search + * amount: types.IntegerOrd('amount'), // public.integer_ord + * }) + * + * 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 }) + * 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) + * ``` + */ +export function encryptedSupabaseV3( + config: EncryptedSupabaseV3Config, +): EncryptedSupabaseV3Instance { + const { encryptionClient, supabaseClient } = config + + return { + from< + Table extends AnyV3Table, + Row extends Record = InferPlaintext & + Record, + >(tableName: string, table: Table) { + return new EncryptedQueryBuilderV3Impl( + tableName, + table, + encryptionClient, + supabaseClient, + ) as unknown as EncryptedQueryBuilderV3 + }, + } +} + export type { EncryptedQueryBuilder, + EncryptedQueryBuilderV3, EncryptedSupabaseConfig, EncryptedSupabaseError, EncryptedSupabaseInstance, EncryptedSupabaseResponse, + EncryptedSupabaseV3Config, + EncryptedSupabaseV3Instance, PendingOrCondition, SupabaseClientLike, + V3FilterableKeys, } from './types' 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..1793a2b2b --- /dev/null +++ b/packages/stack/src/supabase/query-builder-v3.ts @@ -0,0 +1,271 @@ +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, ScalarQueryTerm } from '@/types' +import { logger } from '@/utils/logger' +import { addJsonbCastsV3 } from './helpers' +import { + EncryptedQueryBuilderImpl, + EncryptionFailedError, +} from './query-builder' +import type { + FilterOp, + PendingOrCondition, + 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 +} + +/** + * 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. This is load-bearing: + * each `public.*` domain CHECK requires the storage keys (`v`/`i`/`c` plus + * the domain's index terms), and the SQL operator functions coerce their + * jsonb operand into the domain — so a narrowed `encryptQuery` term (which + * carries no `c`) fails the CHECK with 23514 for EVERY domain, not just + * `text_search`. The full envelope satisfies the CHECK by construction and + * the operators extract the term they need (`eq_term`/`ord_term`/ + * `match_term`). + * - **`like`/`ilike`** — the v3 domains define no LIKE operator; free-text + * match is `@>` on the bloom filter. Encrypted pattern filters are emitted + * as PostgREST `cs` instead. (Match is tokenized + downcased, so `like` and + * `ilike` behave identically. For substring patterns to match, the column's + * match index should set `include_original: false` — with the default + * `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.) + * + * 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 + /** 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, + ) { + 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, + ) + + this.v3Table = table + this.propToDb = table.buildColumnKeyMap() + this.columnSchemas = table.build().columns + + this.v3Columns = {} + 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 + } + + protected override filterColumnName(column: string): string { + return this.propToDb[column] ?? column + } + + protected override buildSelectString(): string | null { + if (this.selectColumns === null) return null + return addJsonbCastsV3(this.selectColumns, this.propToDb) + } + + /** v3 domains are plain jsonb — send the raw payload, keyed by DB name. */ + protected override transformEncryptedMutationModel( + model: Record, + ): Record { + const out: Record = {} + for (const [key, value] of Object.entries(model)) { + out[this.propToDb[key] ?? 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) + }), + ) + } + + /** Encrypted pattern filters go through the bloom-filter `@>` (`cs`). */ + protected override applyPatternFilter( + q: SupabaseQueryBuilder, + column: string, + op: 'like' | 'ilike', + value: unknown, + wasEncrypted: boolean, + ): SupabaseQueryBuilder { + if (wasEncrypted) { + return q.filter(column, 'cs', value) + } + return super.applyPatternFilter(q, column, op, value, wasEncrypted) + } + + protected override notFilterOperator( + op: FilterOp, + wasEncrypted: boolean, + ): string { + if (wasEncrypted && (op === 'like' || op === 'ilike')) { + return 'cs' + } + return op + } + + protected override transformOrConditions( + conditions: PendingOrCondition[], + encryptedIndexes: Set, + ): PendingOrCondition[] { + return conditions.map((cond, j) => { + const column = this.filterColumnName(cond.column) + const op = + encryptedIndexes.has(j) && (cond.op === 'like' || cond.op === 'ilike') + ? ('cs' as FilterOp) + : cond.op + return { ...cond, column, 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..5e520b0b4 100644 --- a/packages/stack/src/supabase/query-builder.ts +++ b/packages/stack/src/supabase/query-builder.ts @@ -8,7 +8,7 @@ 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, ScalarQueryTerm } from '@/types' import { logger } from '@/utils/logger' import { addJsonbCasts, @@ -46,30 +46,30 @@ 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[] // 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, @@ -340,7 +340,7 @@ export class EncryptedQueryBuilderImpl< // Core execution // --------------------------------------------------------------------------- - private async execute(): Promise> { + protected async execute(): Promise> { try { logger.debug(`Supabase encrypted query on table "${this.tableName}".`) @@ -391,7 +391,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 +420,7 @@ export class EncryptedQueryBuilderImpl< ) } - return bulkModelsToEncryptedPgComposites(result.data) + return this.transformEncryptedMutationModels(result.data) } // Single model @@ -442,14 +442,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(): string | null { if (this.selectColumns === null) return null return addJsonbCasts(this.selectColumns, this.encryptedColumnNames) } @@ -458,7 +477,7 @@ export class EncryptedQueryBuilderImpl< // Step 3: Encrypt filter values // --------------------------------------------------------------------------- - private async encryptFilterValues(): Promise { + protected async encryptFilterValues(): Promise { // Collect all terms that need encryption const terms: ScalarQueryTerm[] = [] const termMap: TermMapping[] = [] @@ -600,6 +619,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,14 +652,14 @@ export class EncryptedQueryBuilderImpl< ) } - return { encryptedValues: result.data, termMap } + return result.data } // --------------------------------------------------------------------------- // Step 4: Build and execute real Supabase query // --------------------------------------------------------------------------- - private async buildAndExecuteQuery( + protected async buildAndExecuteQuery( encryptedMutation: | Record | Record[] @@ -703,7 +736,7 @@ export class EncryptedQueryBuilderImpl< // Apply filters with encrypted values substituted // --------------------------------------------------------------------------- - private applyFilters( + protected applyFilters( query: SupabaseQueryBuilder, encryptedFilters: EncryptedFilterState, ): SupabaseQueryBuilder { @@ -772,36 +805,37 @@ export class EncryptedQueryBuilderImpl< }) } + const column = this.filterColumnName(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 '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 } } @@ -813,7 +847,7 @@ export class EncryptedQueryBuilderImpl< for (const [colName, originalValue] of Object.entries(mf.query)) { const key = `${i}:${colName}` - resolvedQuery[colName] = matchValueMap.has(key) + resolvedQuery[this.filterColumnName(colName)] = matchValueMap.has(key) ? matchValueMap.get(key) : originalValue } @@ -824,8 +858,13 @@ 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) + const wasEncrypted = notValueMap.has(i) + const value = wasEncrypted ? notValueMap.get(i) : nf.value + q = q.not( + this.filterColumnName(nf.column), + this.notFilterOperator(nf.op, wasEncrypted), + value, + ) } // Apply or filters @@ -834,34 +873,45 @@ export class EncryptedQueryBuilderImpl< if (of_.kind === 'string') { const parsed = parseOrString(of_.value) - let hasEncrypted = false + 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 + encryptedIndexes.add(j) } } - if (hasEncrypted) { - q = q.or(rebuildOrString(parsed), { - referencedTable: of_.referencedTable, - }) + if (encryptedIndexes.size > 0) { + q = q.or( + rebuildOrString( + this.transformOrConditions(parsed, encryptedIndexes), + ), + { + 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)) { + encryptedIndexes.add(j) return { ...cond, value: orStructuredConditionMap.get(key) } } return cond }) - q = q.or(rebuildOrString(conditions)) + q = q.or( + rebuildOrString( + this.transformOrConditions(conditions, encryptedIndexes), + ), + ) } } @@ -869,17 +919,80 @@ export class EncryptedQueryBuilderImpl< for (let i = 0; i < this.rawFilters.length; i++) { const rf = this.rawFilters[i] const value = rawValueMap.has(i) ? rawValueMap.get(i) : rf.value - q = q.filter(rf.column, rf.operator, value) + q = q.filter(this.filterColumnName(rf.column), rf.operator, value) } 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. + */ + protected filterColumnName(column: string): string { + return column + } + + /** + * 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: string, + op: 'like' | 'ilike', + value: unknown, + _wasEncrypted: boolean, + ): SupabaseQueryBuilder { + return op === 'like' + ? q.like(column, value as string) + : q.ilike(column, value as string) + } + + /** + * 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: PendingOrCondition[], + _encryptedIndexes: Set, + ): PendingOrCondition[] { + 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 +1071,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 +1112,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 +1126,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) { @@ -1054,7 +1171,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/types.ts b/packages/stack/src/supabase/types.ts index 92220e366..42266352a 100644 --- a/packages/stack/src/supabase/types.ts +++ b/packages/stack/src/supabase/types.ts @@ -1,5 +1,6 @@ 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' @@ -20,6 +21,66 @@ export interface EncryptedSupabaseInstance { ): EncryptedQueryBuilder } +// --------------------------------------------------------------------------- +// EQL v3 config & instance +// --------------------------------------------------------------------------- + +export type EncryptedSupabaseV3Config = { + encryptionClient: EncryptionClient + supabaseClient: SupabaseClientLike +} + +/** + * 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
> + +/** + * The v3 builder type: the shared {@link EncryptedQueryBuilder} surface with + * filter methods narrowed to {@link V3FilterableKeys}. + */ +export type EncryptedQueryBuilderV3< + Table extends AnyV3Table, + Row extends Record, +> = EncryptedQueryBuilder & StringKeyOf> + +export interface EncryptedSupabaseV3Instance { + from< + Table extends AnyV3Table, + Row extends Record = InferPlaintext
& + Record, + >(tableName: string, table: Table): EncryptedQueryBuilderV3 +} + // --------------------------------------------------------------------------- // Response // --------------------------------------------------------------------------- @@ -231,11 +292,12 @@ type StringKeyOf = Extract export interface EncryptedQueryBuilder< T extends Record = Record, + FK extends StringKeyOf = StringKeyOf, > extends PromiseLike> { select( columns: string, options?: { head?: boolean; count?: 'exact' | 'planned' | 'estimated' }, - ): EncryptedQueryBuilder + ): EncryptedQueryBuilder insert( data: Partial | Partial[], options?: { @@ -243,11 +305,11 @@ export interface EncryptedQueryBuilder< defaultToNull?: boolean onConflict?: string }, - ): EncryptedQueryBuilder + ): EncryptedQueryBuilder update( data: Partial, options?: { count?: 'exact' | 'planned' | 'estimated' }, - ): EncryptedQueryBuilder + ): EncryptedQueryBuilder upsert( data: Partial | Partial[], options?: { @@ -256,60 +318,42 @@ export interface EncryptedQueryBuilder< ignoreDuplicates?: boolean defaultToNull?: boolean }, - ): EncryptedQueryBuilder + ): 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>( + }): 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>( + ): EncryptedQueryBuilder + in(column: K, values: T[K][]): EncryptedQueryBuilder + filter( column: K, operator: string, value: T[K], - ): EncryptedQueryBuilder - not>( + ): EncryptedQueryBuilder + not( column: K, operator: string, value: T[K], - ): EncryptedQueryBuilder + ): EncryptedQueryBuilder or( filters: string, options?: { referencedTable?: string; foreignTable?: string }, - ): EncryptedQueryBuilder + ): EncryptedQueryBuilder or( conditions: PendingOrCondition[], options?: { referencedTable?: string; foreignTable?: string }, - ): EncryptedQueryBuilder - match(query: Partial): EncryptedQueryBuilder + ): EncryptedQueryBuilder + match(query: Partial): EncryptedQueryBuilder order>( column: K, options?: { @@ -318,22 +362,22 @@ export interface EncryptedQueryBuilder< referencedTable?: string foreignTable?: string }, - ): EncryptedQueryBuilder + ): EncryptedQueryBuilder limit( count: number, options?: { referencedTable?: string; foreignTable?: string }, - ): EncryptedQueryBuilder + ): EncryptedQueryBuilder range( from: number, to: number, options?: { referencedTable?: string; foreignTable?: string }, - ): EncryptedQueryBuilder - single(): EncryptedQueryBuilder - maybeSingle(): EncryptedQueryBuilder - csv(): EncryptedQueryBuilder - abortSignal(signal: AbortSignal): EncryptedQueryBuilder - throwOnError(): EncryptedQueryBuilder + ): EncryptedQueryBuilder + single(): EncryptedQueryBuilder + maybeSingle(): EncryptedQueryBuilder + csv(): EncryptedQueryBuilder + abortSignal(signal: AbortSignal): EncryptedQueryBuilder + throwOnError(): EncryptedQueryBuilder returns>(): EncryptedQueryBuilder - withLockContext(lockContext: LockContext): EncryptedQueryBuilder - audit(config: AuditConfig): EncryptedQueryBuilder + withLockContext(lockContext: LockContext): EncryptedQueryBuilder + audit(config: AuditConfig): EncryptedQueryBuilder } From 2079d747bd7030c8504dec7fe338141ddadc9ee2 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 14:54:20 +1000 Subject: [PATCH 02/27] feat(stack): add v3 DOMAIN_REGISTRY keyed by unqualified domain name --- .../__tests__/eql-v3-domain-registry.test.ts | 82 ++++++++++++++++ packages/stack/package.json | 1 + packages/stack/src/eql/v3/domain-registry.ts | 97 +++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 183 insertions(+) create mode 100644 packages/stack/__tests__/eql-v3-domain-registry.test.ts create mode 100644 packages/stack/src/eql/v3/domain-registry.ts 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/package.json b/packages/stack/package.json index edbcbc1a0..2e3778e55 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -244,6 +244,7 @@ "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", "postgres": "^3.4.8", 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/pnpm-lock.yaml b/pnpm-lock.yaml index 4d153e96e..3cfa30c2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -591,6 +591,9 @@ 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 From cb93504291486d6c471b7f72b563f5fb4a1f5548 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 14:55:27 +1000 Subject: [PATCH 03/27] feat(stack): expand select('*') from an introspected column list --- .../__tests__/supabase-v3-select-star.test.ts | 88 +++++++++++++++++++ .../stack/src/supabase/query-builder-v3.ts | 2 + packages/stack/src/supabase/query-builder.ts | 20 +++-- packages/stack/src/supabase/types.ts | 6 +- 4 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 packages/stack/__tests__/supabase-v3-select-star.test.ts 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..6b77da68c --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-select-star.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' +import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3' + +/** Minimal Supabase double that records only the select string. */ +function mockSupabase() { + const selects: string[] = [] + // 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: [], + error: null, + count: null, + status: 200, + statusText: 'OK', + }).then(onfulfilled, onrejected), + } + return { client: { from: () => qb }, selects } +} + +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'] + +describe("v3 select('*') expansion", () => { + it('expands * to the full column list and casts encrypted columns', async () => { + const supabase = mockSupabase() + const builder = new EncryptedQueryBuilderV3Impl( + 'users', + users, + {} as EncryptionClient, + supabase.client, + ALL_COLUMNS, + ) + + await builder.select('*') + + expect(supabase.selects[0]).toBe( + 'id, email::jsonb, amount::jsonb, created_at::jsonb, note', + ) + }) + + it('no-arg select() behaves exactly like select("*")', async () => { + const supabase = mockSupabase() + const builder = new EncryptedQueryBuilderV3Impl( + 'users', + users, + {} as EncryptionClient, + supabase.client, + ALL_COLUMNS, + ) + + await builder.select() + + expect(supabase.selects[0]).toBe( + 'id, email::jsonb, amount::jsonb, created_at::jsonb, note', + ) + }) + + it("still throws select('*') when no column list is available", async () => { + const supabase = mockSupabase() + const builder = new EncryptedQueryBuilderV3Impl( + 'users', + users, + {} as EncryptionClient, + supabase.client, + 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\('\*'\)/) + }) +}) diff --git a/packages/stack/src/supabase/query-builder-v3.ts b/packages/stack/src/supabase/query-builder-v3.ts index 1793a2b2b..4c222908d 100644 --- a/packages/stack/src/supabase/query-builder-v3.ts +++ b/packages/stack/src/supabase/query-builder-v3.ts @@ -89,6 +89,7 @@ export class EncryptedQueryBuilderV3Impl< table: AnyV3Table, encryptionClient: EncryptionClient, supabaseClient: SupabaseClientLike, + allColumns: string[] | null = null, ) { super( tableName, @@ -98,6 +99,7 @@ export class EncryptedQueryBuilderV3Impl< table as unknown as EncryptedTable, encryptionClient, supabaseClient, + allColumns, ) this.v3Table = table diff --git a/packages/stack/src/supabase/query-builder.ts b/packages/stack/src/supabase/query-builder.ts index 5e520b0b4..2485ce8c4 100644 --- a/packages/stack/src/supabase/query-builder.ts +++ b/packages/stack/src/supabase/query-builder.ts @@ -51,6 +51,10 @@ export class EncryptedQueryBuilderImpl< 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 protected mutation: MutationOp | null = null @@ -76,12 +80,14 @@ export class EncryptedQueryBuilderImpl< 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,15 +95,19 @@ 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.allColumns.join(', ') + } else { + this.selectColumns = columns } - this.selectColumns = columns this.selectOptions = options return this } diff --git a/packages/stack/src/supabase/types.ts b/packages/stack/src/supabase/types.ts index 42266352a..0a0952033 100644 --- a/packages/stack/src/supabase/types.ts +++ b/packages/stack/src/supabase/types.ts @@ -294,8 +294,12 @@ export interface EncryptedQueryBuilder< T extends Record = Record, FK extends StringKeyOf = StringKeyOf, > 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 insert( From 482bdde753f6747d173492d110df2c2059f6b0a7 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 14:57:00 +1000 Subject: [PATCH 04/27] feat(stack): introspect public columns + EQL v3 domains (by comment) over pg --- .../__tests__/supabase-introspect.test.ts | 101 ++++++++++++++ packages/stack/package.json | 8 +- packages/stack/src/supabase/introspect.ts | 126 ++++++++++++++++++ packages/stack/tsup.config.ts | 4 +- pnpm-lock.yaml | 6 + 5 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 packages/stack/__tests__/supabase-introspect.test.ts create mode 100644 packages/stack/src/supabase/introspect.ts diff --git a/packages/stack/__tests__/supabase-introspect.test.ts b/packages/stack/__tests__/supabase-introspect.test.ts new file mode 100644 index 000000000..60266fcb1 --- /dev/null +++ b/packages/stack/__tests__/supabase-introspect.test.ts @@ -0,0 +1,101 @@ +import fc from 'fast-check' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { groupIntrospectionRows } 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 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') + }) +}) diff --git a/packages/stack/package.json b/packages/stack/package.json index 2e3778e55..19249bd62 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -240,6 +240,7 @@ "@cipherstash/eql": "3.0.0-alpha.3", "@clack/prompts": "^1.4.0", "@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", @@ -247,6 +248,7 @@ "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", @@ -275,7 +277,8 @@ }, "peerDependencies": { "@supabase/supabase-js": ">=2", - "drizzle-orm": ">=0.33" + "drizzle-orm": ">=0.33", + "pg": ">=8" }, "peerDependenciesMeta": { "drizzle-orm": { @@ -283,6 +286,9 @@ }, "@supabase/supabase-js": { "optional": true + }, + "pg": { + "optional": true } }, "engines": { diff --git a/packages/stack/src/supabase/introspect.ts b/packages/stack/src/supabase/introspect.ts new file mode 100644 index 000000000..8537a6513 --- /dev/null +++ b/packages/stack/src/supabase/introspect.ts @@ -0,0 +1,126 @@ +/** 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 +} + +/** Tables + the set of `public` domains recognised as EQL v3 (modelled or not). */ +export interface IntrospectionData { + tables: IntrospectionResult + eqlDomains: Set +} + +/** + * 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. +const EQL_DOMAINS_QUERY = ` + SELECT tp.typname AS domain_name + FROM pg_type tp + JOIN pg_namespace ns ON ns.oid = tp.typnamespace + WHERE tp.typtype = 'd' + AND ns.nspname = 'public' + AND obj_description(tp.oid, 'pg_type') LIKE 'EQL%' +` + +/** + * Connect over `databaseUrl`, read every base table in the `public` schema with + * its EQL v3 domain (`domain_name`), and the set of `public` domains recognised + * as EQL v3 (by their `COMMENT`). `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 { default: pg } = await import('pg') + // 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, domains] = await Promise.all([ + client.query(COLUMNS_QUERY), + client.query<{ domain_name: string }>(EQL_DOMAINS_QUERY), + ]) + return { + tables: groupIntrospectionRows(columns.rows), + eqlDomains: new Set(domains.rows.map((r) => r.domain_name)), + } + } 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/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 3cfa30c2e..682ee060c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -579,6 +579,9 @@ importers: '@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 @@ -600,6 +603,9 @@ importers: 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 From bbe06744490783a33e85f380278959a76f7af62f Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 14:58:04 +1000 Subject: [PATCH 05/27] feat(stack): synthesize/merge v3 tables + guard unmodelled EQL domains --- .../__tests__/supabase-schema-builder.test.ts | 146 ++++++++++++++++++ packages/stack/src/supabase/schema-builder.ts | 115 ++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 packages/stack/__tests__/supabase-schema-builder.test.ts create mode 100644 packages/stack/src/supabase/schema-builder.ts 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..373dd2d00 --- /dev/null +++ b/packages/stack/__tests__/supabase-schema-builder.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import type { IntrospectionResult } from '@/supabase/introspect' +import { + assertModelledDomains, + 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']) + }) +}) + +describe('mergeDeclaredTables', () => { + it('lets a declared TextSearch column keep its tuned match options', () => { + const synth = synthesizeTables(introspection) + const declaredTable = encryptedTable('users', { + email: types + .TextSearch('email') + .freeTextSearch({ include_original: false }), + }) + const merged = mergeDeclaredTables(synth, { users: declaredTable }) + const built = merged.tables.get('users')!.build() + expect(built.columns.email.indexes.match?.include_original).toBe(false) + expect(built.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']) + }) +}) + +describe('assertModelledDomains', () => { + it('passes when every EQL domain in use is modelled', () => { + const eqlDomains = new Set(['text_search', 'integer_ord']) + expect(() => assertModelledDomains(introspection, eqlDomains)).not.toThrow() + }) + + it('throws naming the column + domain for a recognised-but-unmodelled domain', () => { + const withOpe: IntrospectionResult = [ + { + tableName: 'metrics', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'score', domainName: 'integer_ord_ope' }, + ], + }, + ] + // integer_ord_ope IS an EQL v3 domain, but has no types factory. + const eqlDomains = new Set(['integer_ord_ope']) + expect(() => assertModelledDomains(withOpe, eqlDomains)).toThrow( + /metrics\.score.*integer_ord_ope|integer_ord_ope.*metrics\.score/, + ) + }) + + it('does NOT throw for a user jsonb domain that is not an EQL domain', () => { + const withUserDomain: IntrospectionResult = [ + { + tableName: 'docs', + columns: [{ columnName: 'body', domainName: 'my_json' }], + }, + ] + // my_json is NOT in eqlDomains → plaintext passthrough, no throw. + expect(() => assertModelledDomains(withUserDomain, new Set())).not.toThrow() + }) +}) diff --git a/packages/stack/src/supabase/schema-builder.ts b/packages/stack/src/supabase/schema-builder.ts new file mode 100644 index 000000000..3cdb7671c --- /dev/null +++ b/packages/stack/src/supabase/schema-builder.ts @@ -0,0 +1,115 @@ +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 — call + * {@link assertModelledDomains} first; here such a column would silently become + * a plaintext passthrough. + */ +export function synthesizeTables( + introspection: IntrospectionResult, +): SynthesizedSchema { + const tables = new Map() + const allColumns = new Map() + + for (const table of introspection) { + const builders: Record = {} + 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 and any tuner options survive). 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 = {} + 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 } +} + +/** + * Throw if any introspected column uses an EQL v3 domain this SDK version does + * not model. Such a column would otherwise become a plaintext passthrough: + * inserts fail on the domain CHECK, but reads return raw ciphertext undecrypted + * (`decryptModel` skips columns absent from the config) — a silent data leak. + * A domain not in `eqlDomains` (a user's own jsonb domain) is fine — plaintext. + */ +export function assertModelledDomains( + introspection: IntrospectionResult, + eqlDomains: Set, +): void { + for (const table of introspection) { + for (const col of table.columns) { + const domain = col.domainName + if (domain === null) continue + if (!eqlDomains.has(domain)) continue // not an EQL domain → plaintext + if (factoryForDomain(domain)) continue // modelled → ok + throw new Error( + `[supabase v3]: column "${table.tableName}.${col.columnName}" uses EQL v3 domain "public.${domain}", which this @cipherstash/stack version does not model. Upgrade the package or drop the column — it cannot be used as a plaintext passthrough (reads would return ciphertext undecrypted).`, + ) + } + } +} From 722ca60ba5f1d6614c6299a6b9a19481d97ec3e1 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 14:58:49 +1000 Subject: [PATCH 06/27] feat(stack): verify declared v3 schemas against introspected domains --- .../stack/__tests__/supabase-verify.test.ts | 54 +++++++++++++++++++ packages/stack/src/supabase/verify.ts | 48 +++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 packages/stack/__tests__/supabase-verify.test.ts create mode 100644 packages/stack/src/supabase/verify.ts diff --git a/packages/stack/__tests__/supabase-verify.test.ts b/packages/stack/__tests__/supabase-verify.test.ts new file mode 100644 index 000000000..28c1bded9 --- /dev/null +++ b/packages/stack/__tests__/supabase-verify.test.ts @@ -0,0 +1,54 @@ +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/, + ) + }) +}) diff --git a/packages/stack/src/supabase/verify.ts b/packages/stack/src/supabase/verify.ts new file mode 100644 index 000000000..c7a5e6852 --- /dev/null +++ b/packages/stack/src/supabase/verify.ts @@ -0,0 +1,48 @@ +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`, + ) + } + 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}"`, + ) + } + } + } +} From 6769ca2528f62a5205aca064ff87cd6417e710c2 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 15:07:31 +1000 Subject: [PATCH 07/27] feat(stack): introspecting encryptedSupabaseV3 factory + optional schemas --- .changeset/eql-v3-supabase-adapter.md | 26 +-- .../__tests__/supabase-v3-builder.test.ts | 36 +++- .../__tests__/supabase-v3-factory.test.ts | 181 +++++++++++++++++ .../stack/__tests__/supabase-v3.test-d.ts | 121 ++++++----- packages/stack/src/supabase/index.ts | 190 ++++++++++++++---- packages/stack/src/supabase/types.ts | 68 ++++++- 6 files changed, 500 insertions(+), 122 deletions(-) create mode 100644 packages/stack/__tests__/supabase-v3-factory.test.ts diff --git a/.changeset/eql-v3-supabase-adapter.md b/.changeset/eql-v3-supabase-adapter.md index 018ba637f..8eebfc01e 100644 --- a/.changeset/eql-v3-supabase-adapter.md +++ b/.changeset/eql-v3-supabase-adapter.md @@ -2,16 +2,16 @@ '@cipherstash/stack': minor --- -Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter, for -tables authored with `@cipherstash/stack/eql/v3` (native `public.*` concrete -domains, `eql_v3` operators). The v2 query mechanism (direct EQL operators over -PostgREST) is unchanged: `EncryptedQueryBuilderImpl` gains narrow protected -seams whose defaults preserve v2 byte-for-byte, and a v3 subclass overrides them -for property↔DB-name resolution (`buildColumnKeyMap`, aliased `prop:db::jsonb` -select casts), raw jsonb mutation payloads (no `eql_v2` composite wrap), -full-envelope filter operands (every `public.*` domain CHECK needs the storage -keys, so narrowed query terms are not usable), `like`/`ilike` → PostgREST `cs` -(bloom `@>`), `Date` reconstruction from `cast_as`, and capability validation -(filtering a storage-only column or with an unsupported query type throws a -typed + runtime error). Filter keys are type-narrowed to exclude storage-only -columns. +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). 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. v2 (`encryptedSupabase`) is +unchanged. diff --git a/packages/stack/__tests__/supabase-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts index e13e66986..63c78a923 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' import { encryptedTable, types } from '@/eql/v3' import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' -import { encryptedSupabase, encryptedSupabaseV3 } from '@/supabase' +import { encryptedSupabase } from '@/supabase' +import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3' // --------------------------------------------------------------------------- // Mocks @@ -200,12 +201,37 @@ const usersV2 = encryptedTableV2('users', { 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', + '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 es = encryptedSupabaseV3({ - encryptionClient: createMockEncryptionClient(), - supabaseClient: supabase.client, - }) + 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 } } 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..ebd3be255 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-factory.test.ts @@ -0,0 +1,181 @@ +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 ?? [], eqlDomains: data.eqlDomains ?? new Set() } +} + +const usersIntrospection = introspectionOf({ + tables: [ + { + tableName: 'users', + columns: [ + { columnName: 'id', domainName: null }, + { columnName: 'email', domainName: 'text_search' }, + ], + }, + ], + eqlDomains: new Set(['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 }], + }, + ], + eqlDomains: new Set(['text_search']), + }), + ) + 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/) + }) + + it('throws on a recognized-but-unmodelled EQL domain', async () => { + introspectMock.mockResolvedValue( + introspectionOf({ + tables: [ + { + tableName: 'metrics', + columns: [{ columnName: 'score', domainName: 'integer_ord_ope' }], + }, + ], + eqlDomains: new Set(['integer_ord_ope']), + }), + ) + await expect( + encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }), + ).rejects.toThrow(/integer_ord_ope/) + expect(encryptionMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/stack/__tests__/supabase-v3.test-d.ts b/packages/stack/__tests__/supabase-v3.test-d.ts index 41b7c6d03..e84de4866 100644 --- a/packages/stack/__tests__/supabase-v3.test-d.ts +++ b/packages/stack/__tests__/supabase-v3.test-d.ts @@ -1,16 +1,12 @@ import { describe, expectTypeOf, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' -import { encryptedTable, types } from '@/eql/v3' +import { encryptedTable, type InferPlaintext, types } from '@/eql/v3' import { encryptedColumn, encryptedTable as v2EncryptedTable } from '@/schema' import { - type EncryptedQueryBuilder, type EncryptedSupabaseResponse, - encryptedSupabase, encryptedSupabaseV3, type SupabaseClientLike, } from '@/supabase' -declare const encryptionClient: EncryptionClient declare const supabaseClient: SupabaseClientLike const users = encryptedTable('users', { @@ -20,95 +16,110 @@ const users = encryptedTable('users', { active: types.Boolean('active'), }) -type UserRow = { - id: number - email: string - amount: number - createdAt: Date - active: boolean - note: string -} +type UserRow = InferPlaintext -const es = encryptedSupabaseV3({ encryptionClient, supabaseClient }) - -describe('encryptedSupabaseV3 typing', () => { - it('defaults rows to InferPlaintext of the table plus passthrough fields', async () => { - const builder = es.from('users', users) - const { data } = await builder.select('id, email, amount') - - // Schema columns carry their domain plaintext types +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 with an explicit row type', () => { - const builder = es.from('users', users) - + 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()) - builder.eq('id', 1) - - // Wrong value type for a column // @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', () => { - const builder = es.from('users', users) - - // active is public.boolean — storage-only, not filterable - // @ts-expect-error — storage-only column is excluded from filter keys + 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('accepts plaintext model values on insert', () => { - const builder = es.from('users', users) - + 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() }) - builder.insert([{ email: 'a@b.com' }, { note: 'plain' }]) - // @ts-expect-error — createdAt is a Date column builder.insert({ createdAt: 'not-a-date' }) }) - it('resolves responses to the row type', () => { - const builder = es.from('users', users) - expectTypeOf(builder.select('id, email')).resolves.toEqualTypeOf< - EncryptedSupabaseResponse - >() + 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('rejects a v2 schema', () => { - const v2Table = v2EncryptedTable('users', { - email: encryptedColumn('email').equality(), + it('keeps undeclared tables reachable on the untyped surface (the gradient)', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, }) - - // @ts-expect-error — encryptedSupabaseV3 only accepts v3 tables - es.from('users', v2Table) + // `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>() }) -}) -describe('encryptedSupabase (v2) typing is unchanged', () => { - it('keeps the single-generic builder shape', () => { - const esV2 = encryptedSupabase({ encryptionClient, supabaseClient }) + 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 }, + }) + }) +}) - type V2Row = { id: number; email: string } - const builder = esV2.from('users', v2Table) - expectTypeOf(builder).toEqualTypeOf>() +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('supports a no-arg select(), like supabase-js', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient) + supabase.from('users').select() + }) }) diff --git a/packages/stack/src/supabase/index.ts b/packages/stack/src/supabase/index.ts index 73f2ac9c0..84fa43477 100644 --- a/packages/stack/src/supabase/index.ts +++ b/packages/stack/src/supabase/index.ts @@ -1,14 +1,24 @@ -import type { AnyV3Table, InferPlaintext } from '@/eql/v3' +import { Encryption } from '@/encryption' import type { EncryptedTable, EncryptedTableColumn } from '@/schema' +import { introspect } from './introspect' import { EncryptedQueryBuilderImpl } from './query-builder' import { EncryptedQueryBuilderV3Impl } from './query-builder-v3' +import { + assertModelledDomains, + mergeDeclaredTables, + synthesizeTables, +} from './schema-builder' import type { - EncryptedQueryBuilderV3, + EncryptedQueryBuilder, EncryptedSupabaseConfig, EncryptedSupabaseInstance, - EncryptedSupabaseV3Config, EncryptedSupabaseV3Instance, + EncryptedSupabaseV3Options, + SupabaseClientLike, + TypedEncryptedSupabaseV3Instance, + V3Schemas, } from './types' +import { verifyDeclaredSchemas } from './verify' /** * Create an encrypted Supabase wrapper that transparently handles encryption @@ -64,56 +74,152 @@ export function encryptedSupabase( } /** - * Create an encrypted Supabase wrapper for **EQL v3** schemas — tables - * authored with `@cipherstash/stack/eql/v3` whose columns are native - * concrete-domain columns (`public.*` type domains, `eql_v3` operators). + * 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. * - * The public surface and call shape are identical to {@link encryptedSupabase} - * (`.eq/.neq/.in/.gt/.gte/.lt/.lte/.like/.ilike/.match/.or/.not/.filter`, - * `withLockContext`, `audit`); only the schema type and the wire encoding - * differ. The same Supabase caveats carry over: the `eql_v3` schema must be - * added to the project's **Exposed schemas** and granted to the Supabase - * roles for the operators to resolve, and encrypted `ORDER BY` is - * unsupported (range *filtering* works). + * Requires a Postgres connection (`options.databaseUrl` or `DATABASE_URL`) for + * introspection, so it cannot run in a Worker or the browser. * * @example * ```typescript - * import { Encryption } from '@cipherstash/stack' - * import { encryptedTable, types } from '@cipherstash/stack/eql/v3' - * import { encryptedSupabaseV3 } from '@cipherstash/stack/supabase' - * - * const users = encryptedTable('users', { - * email: types.TextSearch('email'), // public.text_search - * amount: types.IntegerOrd('amount'), // public.integer_ord - * }) - * - * 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 }) - * 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) + * 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 function encryptedSupabaseV3( - config: EncryptedSupabaseV3Config, -): EncryptedSupabaseV3Instance { - const { encryptionClient, supabaseClient } = config +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) ?? {} + } - return { - from< - Table extends AnyV3Table, - Row extends Record = InferPlaintext
& - Record, - >(tableName: string, table: Table) { - return new EncryptedQueryBuilderV3Impl( + // 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, then reject any recognized-but-unmodelled EQL domain BEFORE + // it can silently become a plaintext passthrough. + const { tables, eqlDomains } = await introspect(databaseUrl) + assertModelledDomains(tables, eqlDomains) + + // 4. Synthesize; if declared, guard record keys, verify, then merge. + 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`, + ) + } + } + 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`, + ) + } + const allColumns = synth.allColumns.get(tableName) ?? null + return new EncryptedQueryBuilderV3Impl( tableName, table, encryptionClient, supabaseClient, - ) as unknown as EncryptedQueryBuilderV3 + allColumns, + ) as unknown as EncryptedQueryBuilder> }, } + return instance as unknown as + | EncryptedSupabaseV3Instance + | TypedEncryptedSupabaseV3Instance } export type { @@ -123,9 +229,11 @@ export type { EncryptedSupabaseError, EncryptedSupabaseInstance, EncryptedSupabaseResponse, - EncryptedSupabaseV3Config, EncryptedSupabaseV3Instance, + EncryptedSupabaseV3Options, PendingOrCondition, SupabaseClientLike, + TypedEncryptedSupabaseV3Instance, V3FilterableKeys, + V3Schemas, } from './types' diff --git a/packages/stack/src/supabase/types.ts b/packages/stack/src/supabase/types.ts index 0a0952033..6e6b52b38 100644 --- a/packages/stack/src/supabase/types.ts +++ b/packages/stack/src/supabase/types.ts @@ -4,6 +4,8 @@ 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 @@ -25,9 +27,36 @@ export interface EncryptedSupabaseInstance { // EQL v3 config & instance // --------------------------------------------------------------------------- -export type EncryptedSupabaseV3Config = { - encryptionClient: EncryptionClient - supabaseClient: SupabaseClientLike +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`. + * + * ASYMMETRY: the `include_original: false` substring-`like` behaviour of a + * `text_search` column can only be honoured on a DECLARED column. A substring + * `like` against an UNDECLARED `text_search` column will not match, because + * the synthesized default `include_original: true` puts the whole pattern into + * the bloom filter as an extra token. + */ + schemas?: S } /** @@ -73,12 +102,35 @@ export type EncryptedQueryBuilderV3< Row extends Record, > = EncryptedQueryBuilder & StringKeyOf> +/** Untyped instance (no `schemas`): rows default to `Record` + * and `from` accepts any table name. */ export interface EncryptedSupabaseV3Instance { - from< - Table extends AnyV3Table, - Row extends Record = InferPlaintext
& - Record, - >(tableName: string, table: Table): EncryptedQueryBuilderV3 + from = Record>( + tableName: string, + ): EncryptedQueryBuilder +} + +/** 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, + ): EncryptedQueryBuilder } // --------------------------------------------------------------------------- From 2354af4471a81b7787f6b4643d401d78d1c565e6 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 15:08:29 +1000 Subject: [PATCH 08/27] test(stack): live Postgres introspection + unmodelled-domain partition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds describeLivePgOnly (DATABASE_URL only — introspection reads the schema, it does not encrypt) and asserts its LIVE_PG_ENABLED flag in the ungated live-coverage guard, so a missing DATABASE_URL fails CI loudly instead of silently skipping the suite on a green job. --- packages/stack/__tests__/helpers/live-gate.ts | 13 ++ .../__tests__/live-coverage-guard.test.ts | 17 +++ .../supabase-v3-introspect-pg.test.ts | 120 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 packages/stack/__tests__/supabase-v3-introspect-pg.test.ts diff --git a/packages/stack/__tests__/helpers/live-gate.ts b/packages/stack/__tests__/helpers/live-gate.ts index a674fbebb..216598b27 100644 --- a/packages/stack/__tests__/helpers/live-gate.ts +++ b/packages/stack/__tests__/helpers/live-gate.ts @@ -37,3 +37,16 @@ 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 diff --git a/packages/stack/__tests__/live-coverage-guard.test.ts b/packages/stack/__tests__/live-coverage-guard.test.ts index 6b6005ba6..3d1a76191 100644 --- a/packages/stack/__tests__/live-coverage-guard.test.ts +++ b/packages/stack/__tests__/live-coverage-guard.test.ts @@ -56,6 +56,7 @@ import { LIVE_CIPHERSTASH_ENABLED, LIVE_EQL_V3_PG_ENABLED, LIVE_LOCK_CONTEXT_ENABLED, + LIVE_PG_ENABLED, } from './helpers/live-gate' // GitHub Actions always sets CI=true; treat any truthy CI as "must run live". @@ -110,6 +111,22 @@ describe('live-coverage guard', () => { }, ) + 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) + }, + ) + // Local dev with no creds: nothing to assert. Keep at least one always-run // assertion so the file is never reported as fully empty/pending. it('is always collected (guard file runs outside every live gate)', () => { 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..8b0207823 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts @@ -0,0 +1,120 @@ +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 { + assertModelledDomains, + 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' + +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 + ) + `) + // 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 + ) + `) +}, 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.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, eqlDomains } = await introspect(databaseUrl as string) + // Sanity: the modelled domains are recognised as EQL domains (by COMMENT). + expect(eqlDomains.has('text_search')).toBe(true) + expect(eqlDomains.has('integer_ord')).toBe(true) + + 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) + + it('detects unmodelled EQL domains (by COMMENT) and the guard rejects them', async () => { + const { tables, eqlDomains } = await introspect(databaseUrl as string) + + // The COMMENT predicate recognises these as EQL domains... + expect(eqlDomains.has('integer_ord_ope')).toBe(true) + expect(eqlDomains.has('json')).toBe(true) + // ...and they have no types factory (genuinely unmodelled)... + expect(factoryForDomain('integer_ord_ope')).toBeUndefined() + expect(factoryForDomain('json')).toBeUndefined() + + // Scope the guard to THIS test's tables. `introspect` returns every table in + // `public`, and a developer's DATABASE_URL may already carry an unmodelled + // EQL column of its own — the assertion would then pass for the wrong + // reason, or fail naming a domain this test never created. + const modelledOnly = tables.filter((t) => t.tableName === MODELLED) + const scoped = tables.filter( + (t) => t.tableName === MODELLED || t.tableName === UNMODELLED, + ) + expect(modelledOnly).toHaveLength(1) + expect(scoped).toHaveLength(2) + + // The modelled table alone must NOT trip the guard. + expect(() => assertModelledDomains(modelledOnly, eqlDomains)).not.toThrow() + + // ...and the unmodelled one throws, naming the offending column/domain. + expect(() => assertModelledDomains(scoped, eqlDomains)).toThrow( + /integer_ord_ope|public\.json/, + ) + }, 30000) +}) From dcb374598c2d1c13a98eaab8738036dab18ba166 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 15:41:37 +1000 Subject: [PATCH 09/27] fix(stack): select('*') must alias renamed v3 columns back to their property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `select('*')` expanded the introspected column list, which holds DB column names. `addJsonbCastsV3` only emits the `prop:db_name::jsonb` PostgREST alias when it sees a *property* name; a raw DB name took the unaliased `dbNames.has(...)` branch. So on a declared table with a property→DB rename, 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. Synthesized columns were unaffected (property == DB name), which is why no existing test caught it: the select-star suite used only matching names, and the rename suite used only explicit selects. Adds an `expandAllColumns` hook on the base builder (identity for v2, which never supplies a column list) that the v3 dialect overrides to map DB names back through the inverse of `buildColumnKeyMap()`. Also fixes a pre-existing prototype-inheritance hazard surfaced by the new tests: `propToDb` was a plain object indexed by column names that originate in the database, so a plaintext column named `constructor` resolved to `Object.prototype.constructor` — truthy — and interpolated `function Object() { [native code] }` into the emitted select string. It was reachable from explicit selects too (`select('constructor')`), and equally from `filterColumnName` and the mutation-payload transform. `buildColumnKeyMap()` now returns a null-prototype map, and every read site is `Object.hasOwn`-guarded — the same belt-and-braces the DOMAIN_REGISTRY already uses. Regression tests assert the resulting ROW KEY, not just the select string: the Supabase double now simulates PostgREST's alias projection, since the alias is what performs the rename server-side. All five new assertions fail against the prior implementation. --- .changeset/eql-v3-supabase-adapter.md | 3 +- .../__tests__/supabase-v3-select-star.test.ts | 154 ++++++++++++++---- packages/stack/src/eql/v3/table.ts | 12 +- packages/stack/src/supabase/helpers.ts | 23 ++- .../stack/src/supabase/query-builder-v3.ts | 40 ++++- packages/stack/src/supabase/query-builder.ts | 13 +- 6 files changed, 209 insertions(+), 36 deletions(-) diff --git a/.changeset/eql-v3-supabase-adapter.md b/.changeset/eql-v3-supabase-adapter.md index 8eebfc01e..bb2c4a63c 100644 --- a/.changeset/eql-v3-supabase-adapter.md +++ b/.changeset/eql-v3-supabase-adapter.md @@ -8,7 +8,8 @@ now a connect-time-async factory: `await encryptedSupabaseV3(url, key)` (or 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). A column using an EQL v3 domain this SDK version does not model +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. diff --git a/packages/stack/__tests__/supabase-v3-select-star.test.ts b/packages/stack/__tests__/supabase-v3-select-star.test.ts index 6b77da68c..93f824ee9 100644 --- a/packages/stack/__tests__/supabase-v3-select-star.test.ts +++ b/packages/stack/__tests__/supabase-v3-select-star.test.ts @@ -3,9 +3,32 @@ import type { EncryptionClient } from '@/encryption' import { encryptedTable, types } from '@/eql/v3' import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3' -/** Minimal Supabase double that records only the select string. */ -function mockSupabase() { +/** + * 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) => { @@ -17,7 +40,7 @@ function mockSupabase() { onrejected?: ((r: unknown) => unknown) | null, ) => Promise.resolve({ - data: [], + data: project(selects[0] ?? ''), error: null, count: null, status: 200, @@ -27,6 +50,20 @@ function mockSupabase() { 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'), @@ -36,53 +73,112 @@ const users = encryptedTable('users', { // 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() - const builder = new EncryptedQueryBuilderV3Impl( - 'users', - users, - {} as EncryptionClient, - supabase.client, - ALL_COLUMNS, + 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', ) + }) - await builder.select('*') + 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, created_at::jsonb, note', + 'id, email::jsonb, amount::jsonb, createdAt:created_at::jsonb, note', ) }) - it('no-arg select() behaves exactly like select("*")', async () => { + it("still throws select('*') when no column list is available", async () => { const supabase = mockSupabase() - const builder = new EncryptedQueryBuilderV3Impl( - 'users', - users, - {} as EncryptionClient, - supabase.client, - ALL_COLUMNS, + 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\('\*'\)/) + }) +}) + +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') + }) - await builder.select() + it("select('*') and an explicit property select agree on row shape", async () => { + const star = mockSupabase([dbRow]) + const explicit = mockSupabase([dbRow]) - expect(supabase.selects[0]).toBe( - 'id, email::jsonb, amount::jsonb, created_at::jsonb, note', + 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("still throws select('*') when no column list is available", async () => { + 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( - 'users', - users, - {} as EncryptionClient, + 'weird', + encryptedTable('weird', { email: types.TextSearch('email') }), + mockEncryptionClient(), supabase.client, - null, + ['constructor', 'toString', 'email'], ) + await builder.select('*') - expect(() => builder.select('*')).toThrow(/select\('\*'\)/) - // v2 regression: a bare select() takes the same path and throws the same way. - expect(() => builder.select()).toThrow(/select\('\*'\)/) + expect(supabase.selects[0]).toBe('constructor, toString, email::jsonb') }) }) diff --git a/packages/stack/src/eql/v3/table.ts b/packages/stack/src/eql/v3/table.ts index 8ea144971..8507cc727 100644 --- a/packages/stack/src/eql/v3/table.ts +++ b/packages/stack/src/eql/v3/table.ts @@ -56,9 +56,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 423057b22..cf3717d50 100644 --- a/packages/stack/src/supabase/helpers.ts +++ b/packages/stack/src/supabase/helpers.ts @@ -68,6 +68,24 @@ export function addJsonbCasts( .join(',') } +/** + * 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 @@ -106,14 +124,15 @@ export function addJsonbCastsV3( ) if (aliasMatch) { const [, alias, name] = aliasMatch - const db = propToDb[name] ?? (dbNames.has(name) ? name : undefined) + const db = + lookupDbName(propToDb, name) ?? (dbNames.has(name) ? name : undefined) if (db !== undefined) { return `${leadingWhitespace}${alias}:${db}::jsonb` } return col } - const db = propToDb[trimmed] + const db = lookupDbName(propToDb, trimmed) if (db !== undefined) { return db === trimmed ? `${leadingWhitespace}${trimmed}::jsonb` diff --git a/packages/stack/src/supabase/query-builder-v3.ts b/packages/stack/src/supabase/query-builder-v3.ts index 4c222908d..c397de363 100644 --- a/packages/stack/src/supabase/query-builder-v3.ts +++ b/packages/stack/src/supabase/query-builder-v3.ts @@ -79,6 +79,11 @@ export class EncryptedQueryBuilderV3Impl< 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. */ @@ -106,6 +111,11 @@ export class EncryptedQueryBuilderV3Impl< 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 + } + this.v3Columns = {} for (const [property, builder] of Object.entries(table.columnBuilders)) { if (builder instanceof EncryptedV3Column) { @@ -129,8 +139,14 @@ export class EncryptedQueryBuilderV3Impl< 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): string { - return this.propToDb[column] ?? column + return this.dbNameFor(column) } protected override buildSelectString(): string | null { @@ -138,13 +154,33 @@ export class EncryptedQueryBuilderV3Impl< 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 = {} for (const [key, value] of Object.entries(model)) { - out[this.propToDb[key] ?? key] = value + out[this.dbNameFor(key)] = value } return out } diff --git a/packages/stack/src/supabase/query-builder.ts b/packages/stack/src/supabase/query-builder.ts index 2485ce8c4..82f835569 100644 --- a/packages/stack/src/supabase/query-builder.ts +++ b/packages/stack/src/supabase/query-builder.ts @@ -104,7 +104,7 @@ export class EncryptedQueryBuilderImpl< "encryptedSupabase does not support select('*'). Please list columns explicitly so that encrypted columns can be cast with ::jsonb.", ) } - this.selectColumns = this.allColumns.join(', ') + this.selectColumns = this.expandAllColumns(this.allColumns).join(', ') } else { this.selectColumns = columns } @@ -112,6 +112,17 @@ export class EncryptedQueryBuilderImpl< 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?: { From 98f657a0f6fc2c94ede94e0bbc2ffde922e6a240 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 16:32:04 +1000 Subject: [PATCH 10/27] fix(stack): adapt supabase merge test to the removed v3 freeTextSearch tuner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing onto feat/eql-v3-text-search-schema picks up 8123839c, which removed the vestigial chainable .freeTextSearch(opts) tuner from EncryptedTextSearchColumn — in v3 the concrete type defines capabilities, so match is always emitted with the default config. The mergeDeclaredTables test used tuned match options as the observable proof that a declared column wins over its synthesized twin. That observable is gone: declared and synthesized TextSearch columns now build byte-identically. Assert builder instance identity instead, which proves the same behaviour and is the only remaining signal. Drop the now-stale "tuner options" clause from the mergeDeclaredTables doc comment. --- .../__tests__/supabase-schema-builder.test.ts | 18 +++++++++++------- packages/stack/src/supabase/schema-builder.ts | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/stack/__tests__/supabase-schema-builder.test.ts b/packages/stack/__tests__/supabase-schema-builder.test.ts index 373dd2d00..2f8ee54f7 100644 --- a/packages/stack/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack/__tests__/supabase-schema-builder.test.ts @@ -73,17 +73,21 @@ describe('synthesizeTables', () => { }) describe('mergeDeclaredTables', () => { - it('lets a declared TextSearch column keep its tuned match options', () => { + it('keeps the declared builder instance over the synthesized one', () => { const synth = synthesizeTables(introspection) const declaredTable = encryptedTable('users', { - email: types - .TextSearch('email') - .freeTextSearch({ include_original: false }), + email: types.TextSearch('email'), }) const merged = mergeDeclaredTables(synth, { users: declaredTable }) - const built = merged.tables.get('users')!.build() - expect(built.columns.email.indexes.match?.include_original).toBe(false) - expect(built.columns.amount).toBeDefined() + 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'), ) diff --git a/packages/stack/src/supabase/schema-builder.ts b/packages/stack/src/supabase/schema-builder.ts index 3cdb7671c..50a978128 100644 --- a/packages/stack/src/supabase/schema-builder.ts +++ b/packages/stack/src/supabase/schema-builder.ts @@ -56,8 +56,8 @@ export function synthesizeTables( * 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 and any tuner options survive). Undeclared columns stay - * synthesized. `allColumns` is unchanged (DB-name based, from introspection). + * property→DB rename survives). Undeclared columns stay synthesized. + * `allColumns` is unchanged (DB-name based, from introspection). */ export function mergeDeclaredTables( synth: SynthesizedSchema, From f78533941fe5df4b282a8fbeb6291550255f1dc8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 17:37:37 +1000 Subject: [PATCH 11/27] fix(stack): resolve v3 column renames in order() and onConflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filterColumnName()` maps a v3 column's JS property name to its DB column name. Filters, `match`, `not`, raw `filter`, and `select` route through it; `order()` and the `onConflict` mutation option did not, and sent the property name to PostgREST. `.order('createdAt')` on a table declaring `createdAt: types.TimestampOrd('created_at')` therefore failed. `order()` predates v3 and was correct until renames existed, so nothing flagged it. Both now route through the seam, which is the identity in v2. Separately, `order()`'s column was typed `StringKeyOf` while every filter method narrows to `FK`, so `.order('active')` on a storage-only `types.Boolean` column compiled and sorted by raw ciphertext — wrong row order, no error. Narrowed to `FK` (a no-op for v2, where `FK` defaults to `StringKeyOf`) and backed by a new `validateTransforms()` seam, which the v3 dialect overrides to reject a column with no `orderAndRange` capability. It is called inside `execute()`'s try, so it surfaces as a `status: 500` result like the existing filter-path capability guard — and it is the only protection the untyped (no-`schemas`) surface has. `v3Columns` becomes null-prototype: `validateTransforms` indexes it without an own-key guard, so an inherited `constructor`/`toString` would otherwise resolve truthy for a plaintext column of that name. --- .../__tests__/supabase-v3-builder.test.ts | 107 ++++++++++++++++++ .../stack/__tests__/supabase-v3.test-d.ts | 11 ++ .../stack/src/supabase/query-builder-v3.ts | 28 ++++- packages/stack/src/supabase/query-builder.ts | 42 ++++++- packages/stack/src/supabase/types.ts | 5 +- 5 files changed, 188 insertions(+), 5 deletions(-) diff --git a/packages/stack/__tests__/supabase-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts index 63c78a923..fdfd473cf 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -418,6 +418,85 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(error?.message).toContain('does not support equality') }) + it('maps property names to DB names in order()', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id, createdAt').order('createdAt') + + const [order] = supabase.callsFor('order') + expect(order.args[0]).toBe('created_at') + }) + + 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 column with no orderAndRange capability', 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('does not support ordering') + }) + + 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('reconstructs Date values from cast_as on decrypted rows', async () => { const rows = [ { @@ -499,4 +578,32 @@ describe('encryptedSupabase (v2) wire encoding is unchanged by the dialect seams 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') + }) }) diff --git a/packages/stack/__tests__/supabase-v3.test-d.ts b/packages/stack/__tests__/supabase-v3.test-d.ts index e84de4866..51f9a211c 100644 --- a/packages/stack/__tests__/supabase-v3.test-d.ts +++ b/packages/stack/__tests__/supabase-v3.test-d.ts @@ -55,6 +55,17 @@ describe('encryptedSupabaseV3 typed surface (with schemas)', () => { builder.is('active', true) }) + it('rejects order() on storage-only columns at the type level', async () => { + const supabase = await encryptedSupabaseV3(supabaseClient, { + schemas: { users }, + }) + const builder = supabase.from('users') + builder.order('createdAt') + builder.order('amount', { ascending: false }) + // @ts-expect-error — active is public.boolean: no ORE index to order by + builder.order('active') + }) + it('accepts plaintext model values on insert', async () => { const supabase = await encryptedSupabaseV3(supabaseClient, { schemas: { users }, diff --git a/packages/stack/src/supabase/query-builder-v3.ts b/packages/stack/src/supabase/query-builder-v3.ts index c397de363..7515e5325 100644 --- a/packages/stack/src/supabase/query-builder-v3.ts +++ b/packages/stack/src/supabase/query-builder-v3.ts @@ -116,7 +116,10 @@ export class EncryptedQueryBuilderV3Impl< this.dbToProp[dbName] = property } - this.v3Columns = {} + // 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 @@ -149,6 +152,29 @@ export class EncryptedQueryBuilderV3Impl< return this.dbNameFor(column) } + /** + * Ordering by an encrypted column relies on the domain's ORE index. A + * storage-only domain (`public.boolean`, `public.text`, …) has none, so + * PostgREST would sort by the raw ciphertext envelope and return a + * plausible-looking but meaningless row order. Reject it, mirroring the + * capability guard {@link encryptCollectedTerms} applies to filters — that + * guard is the only protection the untyped (no-`schemas`) surface has. + * + * A column absent from {@link v3Columns} is a plaintext passthrough. + */ + protected override validateTransforms(): void { + for (const t of this.transforms) { + if (t.kind !== 'order') continue + const column = this.v3Columns[t.column] + if (!column) continue + if (!column.getQueryCapabilities().orderAndRange) { + throw new Error( + `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ordering — declare the column with a domain that carries the orderAndRange capability`, + ) + } + } + } + protected override buildSelectString(): string | null { if (this.selectColumns === null) return null return addJsonbCastsV3(this.selectColumns, this.propToDb) diff --git a/packages/stack/src/supabase/query-builder.ts b/packages/stack/src/supabase/query-builder.ts index 82f835569..cc927ab7d 100644 --- a/packages/stack/src/supabase/query-builder.ts +++ b/packages/stack/src/supabase/query-builder.ts @@ -688,19 +688,27 @@ export class EncryptedQueryBuilderImpl< selectString: string | null, encryptedFilters: EncryptedFilterState, ): Promise { + this.validateTransforms() + let query: SupabaseQueryBuilder = this.supabaseClient.from(this.tableName) // Apply mutation if (this.mutation) { switch (this.mutation.kind) { case 'insert': - query = query.insert(encryptedMutation!, this.mutation.options) + query = query.insert( + encryptedMutation!, + this.resolveMutationOptions(this.mutation.options), + ) break case 'update': query = query.update(encryptedMutation!, this.mutation.options) break case 'upsert': - query = query.upsert(encryptedMutation!, this.mutation.options) + query = query.upsert( + encryptedMutation!, + this.resolveMutationOptions(this.mutation.options), + ) break case 'delete': query = query.delete(this.mutation.options) @@ -723,7 +731,7 @@ export class EncryptedQueryBuilderImpl< for (const t of this.transforms) { switch (t.kind) { case 'order': - query = query.order(t.column, t.options) + query = query.order(this.filterColumnName(t.column), t.options) break case 'limit': query = query.limit(t.count, t.options) @@ -961,6 +969,34 @@ export class EncryptedQueryBuilderImpl< return column } + /** + * 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): O { + if (!options?.onConflict) return options + const mapped = options.onConflict + .split(',') + .map((column) => this.filterColumnName(column.trim())) + .join(',') + return mapped === options.onConflict + ? options + : { ...options, onConflict: mapped } + } + + /** + * 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 {} + /** * Apply a `like`/`ilike` filter. v2 relies on the `~~` operator defined on * `eql_v2_encrypted`; the v3 dialect overrides this for encrypted columns diff --git a/packages/stack/src/supabase/types.ts b/packages/stack/src/supabase/types.ts index 6e6b52b38..2b171590c 100644 --- a/packages/stack/src/supabase/types.ts +++ b/packages/stack/src/supabase/types.ts @@ -410,7 +410,10 @@ export interface EncryptedQueryBuilder< options?: { referencedTable?: string; foreignTable?: string }, ): EncryptedQueryBuilder match(query: Partial): EncryptedQueryBuilder - order>( + // `FK`, not `StringKeyOf`: ordering an encrypted column relies on its ORE + // index, which a storage-only domain lacks. `FK` defaults to `StringKeyOf`, + // so the v2 surface is unchanged; only the v3 typed instance narrows. + order( column: K, options?: { ascending?: boolean From 3048d88630f5f9e96750c95f4d51c20290c354e1 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 17:38:26 +1000 Subject: [PATCH 12/27] refactor(stack): translate supabase column names once, at a phase boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `order()`/`onConflict` bugs fixed in the previous commit were one design property expressed twice: property->DB translation was applied opportunistically at each call site, and `filterColumnName()` was a convention an author had to remember. A property name and a DB name are both `string`, so the compiler was indifferent to which reached PostgREST, and any new column-carrying method started out broken by default. Two changes make the bug class unrepresentable. Brand the seam. `DbName`, `DbSelect`, `DbFilterString`, and `DbConflictList` now type `SupabaseQueryBuilder`, and `filterColumnName()` is the only place a `DbName` is minted. Reverting the one-line `order()` fix now yields a compile error rather than a wrong query. The brands are erased at runtime and internal (never re-exported); a real `SupabaseClient` stays assignable to `SupabaseClientLike` via method-parameter bivariance, so this is not an API break. Translate once. `toDbSpace()` maps the whole recorded query into a branded DB-space IR in a single total pass, between `buildSelectString()` and `encryptFilterValues()`. `applyFilters` and `buildAndExecuteQuery` now consume only that IR and perform no translation; adding a column-carrying method means extending one switch the compiler forces you to complete. This is safe before encryption because `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. It also removes the one live fragility here: `parseOrString` was called twice on the same string — once to collect terms, once to apply them — and the two parses had to agree on condition indices for encrypted values to land on the right condition. It is now parsed once. The v3 `transformOrConditions` override sheds its name-mapping half and keeps only the like/ilike -> cs rewrite, which genuinely belongs at apply time because it depends on `wasEncrypted`. `validateTransforms()` deliberately stays in `buildAndExecuteQuery` rather than folding into `toDbSpace`: it runs after `encryptFilterValues`, so a filter capability error wins over an order capability error, and moving it earlier would silently flip that precedence. A test pins the ordering. Nine characterization tests for `match`, `not`, `in`, `is`, raw `filter`, a mixed encrypted/plaintext `or()`, and a combined query touching all six filter arrays were written and landed green against the pre-refactor code; this commit leaves them unedited. `match` is the one seam a brand cannot guard, since it cannot ride on an object key -- hence its test. --- .changeset/eql-v3-supabase-adapter.md | 14 +- .../__tests__/supabase-v3-builder.test.ts | 176 ++++++++++++ packages/stack/src/supabase/helpers.ts | 24 +- .../stack/src/supabase/query-builder-v3.ts | 30 +- packages/stack/src/supabase/query-builder.ts | 270 +++++++++++++----- packages/stack/src/supabase/types.ts | 146 ++++++++-- 6 files changed, 542 insertions(+), 118 deletions(-) diff --git a/.changeset/eql-v3-supabase-adapter.md b/.changeset/eql-v3-supabase-adapter.md index bb2c4a63c..309db781e 100644 --- a/.changeset/eql-v3-supabase-adapter.md +++ b/.changeset/eql-v3-supabase-adapter.md @@ -14,5 +14,15 @@ property→DB rename round-trips). A column using an EQL v3 domain this SDK vers 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. v2 (`encryptedSupabase`) is -unchanged. +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 a column whose domain has no +`orderAndRange` capability (e.g. a storage-only `public.boolean`) is rejected — +at compile time when `schemas` is supplied, and at runtime otherwise — instead +of silently sorting by the raw ciphertext envelope. + +v2 (`encryptedSupabase`) is unchanged. diff --git a/packages/stack/__tests__/supabase-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts index fdfd473cf..e986df98c 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -497,6 +497,66 @@ describe('encryptedSupabaseV3 wire encoding', () => { 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('createdAt') + .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() + expect(supabase.callsFor('order')[0].args[0]).toBe('created_at') + 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') + }) + it('reconstructs Date values from cast_as on decrypted rows', async () => { const rows = [ { @@ -606,4 +666,120 @@ describe('encryptedSupabase (v2) wire encoding is unchanged by the dialect seams 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) + }) }) diff --git a/packages/stack/src/supabase/helpers.ts b/packages/stack/src/supabase/helpers.ts index cf3717d50..f7d89271f 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,7 @@ export function addJsonbCasts( return col }) - .join(',') + .join(',') as DbSelect } /** @@ -104,7 +111,7 @@ function lookupDbName( export function addJsonbCastsV3( columns: string, propToDb: Record, -): string { +): DbSelect { const dbNames = new Set(Object.values(propToDb)) return columns @@ -145,7 +152,7 @@ export function addJsonbCastsV3( return col }) - .join(',') + .join(',') as DbSelect } /** @@ -211,13 +218,16 @@ 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}` }) - .join(',') + .join(',') as DbFilterString } // --------------------------------------------------------------------------- diff --git a/packages/stack/src/supabase/query-builder-v3.ts b/packages/stack/src/supabase/query-builder-v3.ts index 7515e5325..d9bd85598 100644 --- a/packages/stack/src/supabase/query-builder-v3.ts +++ b/packages/stack/src/supabase/query-builder-v3.ts @@ -14,8 +14,10 @@ import { EncryptionFailedError, } from './query-builder' import type { + DbName, + DbPendingOrCondition, + DbSelect, FilterOp, - PendingOrCondition, SupabaseClientLike, SupabaseQueryBuilder, } from './types' @@ -148,8 +150,8 @@ export class EncryptedQueryBuilderV3Impl< return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name } - protected override filterColumnName(column: string): string { - return this.dbNameFor(column) + protected override filterColumnName(column: string): DbName { + return this.dbNameFor(column) as DbName } /** @@ -175,7 +177,7 @@ export class EncryptedQueryBuilderV3Impl< } } - protected override buildSelectString(): string | null { + protected override buildSelectString(): DbSelect | null { if (this.selectColumns === null) return null return addJsonbCastsV3(this.selectColumns, this.propToDb) } @@ -204,7 +206,7 @@ export class EncryptedQueryBuilderV3Impl< protected override transformEncryptedMutationModel( model: Record, ): Record { - const out: Record = {} + const out: Record = Object.create(null) for (const [key, value] of Object.entries(model)) { out[this.dbNameFor(key)] = value } @@ -276,7 +278,7 @@ export class EncryptedQueryBuilderV3Impl< /** Encrypted pattern filters go through the bloom-filter `@>` (`cs`). */ protected override applyPatternFilter( q: SupabaseQueryBuilder, - column: string, + column: DbName, op: 'like' | 'ilike', value: unknown, wasEncrypted: boolean, @@ -297,17 +299,25 @@ export class EncryptedQueryBuilderV3Impl< return op } + /** + * Map `like`/`ilike` to `cs` on the conditions that were encrypted — the same + * bloom-filter containment rewrite {@link applyPatternFilter} performs for + * regular filters. 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 (`toDbSpace`), so this no longer + * translates them. + */ protected override transformOrConditions( - conditions: PendingOrCondition[], + conditions: DbPendingOrCondition[], encryptedIndexes: Set, - ): PendingOrCondition[] { + ): DbPendingOrCondition[] { return conditions.map((cond, j) => { - const column = this.filterColumnName(cond.column) const op = encryptedIndexes.has(j) && (cond.op === 'like' || cond.op === 'ilike') ? ('cs' as FilterOp) : cond.op - return { ...cond, column, op } + return op === cond.op ? cond : { ...cond, op } }) } diff --git a/packages/stack/src/supabase/query-builder.ts b/packages/stack/src/supabase/query-builder.ts index cc927ab7d..ff566cbb1 100644 --- a/packages/stack/src/supabase/query-builder.ts +++ b/packages/stack/src/supabase/query-builder.ts @@ -19,6 +19,16 @@ import { rebuildOrString, } from './helpers' import type { + DbConflictList, + DbFilterString, + DbMutationOp, + DbMutationOptions, + DbName, + DbPendingOrCondition, + DbPendingOrFilter, + DbQuerySpace, + DbSelect, + DbTransformOp, EncryptedSupabaseError, EncryptedSupabaseResponse, FilterOp, @@ -371,17 +381,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) @@ -489,7 +503,7 @@ export class EncryptedQueryBuilderImpl< // Step 2: Build select string with casts // --------------------------------------------------------------------------- - protected buildSelectString(): string | null { + protected buildSelectString(): DbSelect | null { if (this.selectColumns === null) return null return addJsonbCasts(this.selectColumns, this.encryptedColumnNames) } @@ -498,7 +512,9 @@ export class EncryptedQueryBuilderImpl< // Step 3: Encrypt filter values // --------------------------------------------------------------------------- - protected async encryptFilterValues(): Promise { + protected async encryptFilterValues( + dbSpace: DbQuerySpace, + ): Promise { // Collect all terms that need encryption const terms: ScalarQueryTerm[] = [] const termMap: TermMapping[] = [] @@ -506,8 +522,8 @@ 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] @@ -539,9 +555,9 @@ 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 const column = tableColumns[colName] if (!column) continue @@ -558,8 +574,8 @@ 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 const column = tableColumns[nf.column] if (!column) continue @@ -574,13 +590,12 @@ 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] + // Or filters — conditions were parsed once, in `toDbSpace`. + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] if (of_.kind === 'string') { - const parsed = parseOrString(of_.value) - for (let j = 0; j < parsed.length; j++) { - const cond = parsed[j] + 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] @@ -620,8 +635,8 @@ export class EncryptedQueryBuilderImpl< } // 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 const column = tableColumns[rf.column] if (!column) continue @@ -676,6 +691,109 @@ export class EncryptedQueryBuilderImpl< 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 // --------------------------------------------------------------------------- @@ -685,33 +803,28 @@ export class EncryptedQueryBuilderImpl< | 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.resolveMutationOptions(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.resolveMutationOptions(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 } } @@ -721,17 +834,17 @@ 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(this.filterColumnName(t.column), t.options) + query = query.order(t.column, t.options) break case 'limit': query = query.limit(t.count, t.options) @@ -768,6 +881,7 @@ export class EncryptedQueryBuilderImpl< protected applyFilters( query: SupabaseQueryBuilder, encryptedFilters: EncryptedFilterState, + dbSpace: DbQuerySpace, ): SupabaseQueryBuilder { let q = query @@ -820,8 +934,8 @@ export class EncryptedQueryBuilderImpl< } // 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)) { @@ -834,7 +948,7 @@ export class EncryptedQueryBuilderImpl< }) } - const column = this.filterColumnName(f.column) + const column = f.column const wasEncrypted = filterValueMap.has(i) switch (f.op) { @@ -870,13 +984,13 @@ export class EncryptedQueryBuilderImpl< } // 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[this.filterColumnName(colName)] = matchValueMap.has(key) + resolvedQuery[colName] = matchValueMap.has(key) ? matchValueMap.get(key) : originalValue } @@ -885,23 +999,20 @@ export class EncryptedQueryBuilderImpl< } // Apply 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] const wasEncrypted = notValueMap.has(i) const value = wasEncrypted ? notValueMap.get(i) : nf.value - q = q.not( - this.filterColumnName(nf.column), - this.notFilterOperator(nf.op, wasEncrypted), - 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) + // Already parsed (once) and translated by `toDbSpace`. + const parsed = [...of_.conditions] const encryptedIndexes = new Set() for (let j = 0; j < parsed.length; j++) { @@ -922,7 +1033,16 @@ export class EncryptedQueryBuilderImpl< }, ) } else { - q = q.or(of_.value, { referencedTable: of_.referencedTable }) + // No condition referenced an encrypted column. `getColumnMap()` is + // keyed by BOTH property and DB name, so an encrypted column always + // populates `encryptedIndexes` and takes the branch above, which maps + // names. Everything reaching here is therefore a plaintext column, + // whose property name IS its DB name — no mapping to do, and the + // caller's ORIGINAL string is forwarded byte-for-byte (v2 relies on + // this for nested `and()`/quoted values the parser cannot round-trip). + q = q.or(of_.original as DbFilterString, { + referencedTable: of_.referencedTable, + }) } } else { // Structured: convert to string @@ -945,10 +1065,10 @@ export class EncryptedQueryBuilderImpl< } // 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(this.filterColumnName(rf.column), rf.operator, value) + q = q.filter(rf.column, rf.operator, value) } return q @@ -964,9 +1084,13 @@ export class EncryptedQueryBuilderImpl< * 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): string { - return column + protected filterColumnName(column: string): DbName { + return column as DbName } /** @@ -978,15 +1102,17 @@ export class EncryptedQueryBuilderImpl< */ protected resolveMutationOptions< O extends { onConflict?: string } | undefined, - >(options: O): O { - if (!options?.onConflict) return options + >(options: O): DbMutationOptions | undefined { + if (!options?.onConflict) return options as DbMutationOptions | undefined const mapped = options.onConflict .split(',') .map((column) => this.filterColumnName(column.trim())) - .join(',') - return mapped === options.onConflict - ? options - : { ...options, onConflict: mapped } + .join(',') as DbConflictList + return ( + mapped === options.onConflict + ? options + : { ...options, onConflict: mapped } + ) as DbMutationOptions } /** @@ -1005,7 +1131,7 @@ export class EncryptedQueryBuilderImpl< */ protected applyPatternFilter( q: SupabaseQueryBuilder, - column: string, + column: DbName, op: 'like' | 'ilike', value: unknown, _wasEncrypted: boolean, @@ -1029,9 +1155,9 @@ export class EncryptedQueryBuilderImpl< * conditions to `cs`. */ protected transformOrConditions( - conditions: PendingOrCondition[], + conditions: DbPendingOrCondition[], _encryptedIndexes: Set, - ): PendingOrCondition[] { + ): DbPendingOrCondition[] { return conditions } diff --git a/packages/stack/src/supabase/types.ts b/packages/stack/src/supabase/types.ts index 2b171590c..1cfbd5d30 100644 --- a/packages/stack/src/supabase/types.ts +++ b/packages/stack/src/supabase/types.ts @@ -259,46 +259,138 @@ 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 + 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, From 34622f1612e1a44d682bb5e6af1def8439fbb604 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 17:44:48 +1000 Subject: [PATCH 13/27] fix(stack): actionable error when the optional `pg` peer is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `encryptedSupabaseV3` introspects over a direct Postgres connection, so a project without the optional `pg` peer installed hit a bare module-resolution error at construction. `loadPg` remaps it to a message naming the install and the Worker/browser constraint, preserving the original as `cause`. Guards on `err.code` rather than message text: CJS throws `MODULE_NOT_FOUND`, ESM throws `ERR_MODULE_NOT_FOUND`. Any other failure propagates untouched, so a genuine `pg` load error is not swallowed. `importPg` is injectable because `vi.mock` cannot reproduce a module that fails to resolve — it replaces the factory rejection with its own error, discarding the `code` the branch depends on. --- .../__tests__/supabase-introspect.test.ts | 25 ++++++++++++- packages/stack/src/supabase/introspect.ts | 37 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/stack/__tests__/supabase-introspect.test.ts b/packages/stack/__tests__/supabase-introspect.test.ts index 60266fcb1..8832dab81 100644 --- a/packages/stack/__tests__/supabase-introspect.test.ts +++ b/packages/stack/__tests__/supabase-introspect.test.ts @@ -1,6 +1,6 @@ import fc from 'fast-check' import { afterEach, describe, expect, it, vi } from 'vitest' -import { groupIntrospectionRows } from '@/supabase/introspect' +import { groupIntrospectionRows, loadPg } from '@/supabase/introspect' describe('groupIntrospectionRows', () => { it('groups rows by table, preserving row order as column order', () => { @@ -99,3 +99,26 @@ describe('introspect connection error handling', () => { 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/src/supabase/introspect.ts b/packages/stack/src/supabase/introspect.ts index 8537a6513..52f3e2d4b 100644 --- a/packages/stack/src/supabase/introspect.ts +++ b/packages/stack/src/supabase/introspect.ts @@ -88,6 +88,41 @@ const EQL_DOMAINS_QUERY = ` AND obj_description(tp.oid, 'pg_type') LIKE 'EQL%' ` +/** `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`), and the set of `public` domains recognised @@ -100,7 +135,7 @@ const EQL_DOMAINS_QUERY = ` export async function introspect( databaseUrl: string, ): Promise { - const { default: pg } = await import('pg') + 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({ From 3fe3c0c650927d306df320330d9c48e702d904d5 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 17:44:48 +1000 Subject: [PATCH 14/27] fix(stack): null-prototype the synthesized/merged v3 column maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `synthesizeTables` and `mergeDeclaredTables` key their column-builder records by DB column name, which is attacker- or migration-controlled. On a normal object literal a column literally named `__proto__` reparents the object instead of landing as an own key, silently dropping the column — it would then be treated as a plaintext passthrough and its ciphertext returned undecrypted. --- packages/stack/src/supabase/schema-builder.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/supabase/schema-builder.ts b/packages/stack/src/supabase/schema-builder.ts index 50a978128..d32448e4c 100644 --- a/packages/stack/src/supabase/schema-builder.ts +++ b/packages/stack/src/supabase/schema-builder.ts @@ -32,7 +32,9 @@ export function synthesizeTables( const allColumns = new Map() for (const table of introspection) { - const builders: Record = {} + // 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) @@ -69,7 +71,7 @@ export function mergeDeclaredTables( const tableName = declared.tableName const synthesized = tables.get(tableName) - const merged: Record = {} + const merged: Record = Object.create(null) if (synthesized) { for (const [prop, builder] of Object.entries( synthesized.columnBuilders, From 5fdfd87c0ac57597c4207b1d2ff288ec6ee2e959 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 17:44:48 +1000 Subject: [PATCH 15/27] docs(stack): show the SQL a v3 column is detected from; add introspection design spec The `encryptedSupabaseV3` jsdoc said columns are detected "by their Postgres domain" without showing one. Add the CREATE TABLE it corresponds to, including a plaintext column, so the passthrough behaviour is visible alongside the encrypted ones. --- ...-07-09-supabase-v3-introspection-design.md | 388 ++++++++++++++++++ packages/stack/src/supabase/index.ts | 13 + 2 files changed, 401 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-supabase-v3-introspection-design.md 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/packages/stack/src/supabase/index.ts b/packages/stack/src/supabase/index.ts index 84fa43477..3699c5e42 100644 --- a/packages/stack/src/supabase/index.ts +++ b/packages/stack/src/supabase/index.ts @@ -83,6 +83,19 @@ export function encryptedSupabase( * 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) From 3ecece0ca21ac535e6043d6268229762eae963db Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 17:51:10 +1000 Subject: [PATCH 16/27] fix(stack): null-prototype the built encrypt config; cover the __proto__ column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the test the previous commit's hardening was missing — and it caught that the hardening was incomplete. `synthesizeTables`/`mergeDeclaredTables` now keep a DB column literally named `__proto__` as an own key, but `EncryptedTable.build()` re-keys the columns by DB name into a plain object literal, so `builtColumns['__proto__'] = schema` reparented the object instead of adding an own key. The column never reached the encrypt config, `decryptModel` skips columns absent from the config, and its ciphertext would be returned undecrypted — exactly the leak the hardening was meant to prevent, one layer further down. `build()` now uses a null prototype, matching `buildColumnKeyMap()` directly above it. Two tests: one pins the builder-key hardening (passed before this change), one pins the encrypt-config registration (failed before it). `schema-v3.test.ts`'s `build()` assertion spreads `columns` to a plain object before `toStrictEqual`, which compares prototypes. The contract under test is the column configs; the prototype is now pinned directly by the new tests. --- packages/stack/__tests__/schema-v3.test.ts | 6 +++- .../__tests__/supabase-schema-builder.test.ts | 35 +++++++++++++++++++ packages/stack/src/eql/v3/table.ts | 9 ++++- 3 files changed, 48 insertions(+), 2 deletions(-) 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-schema-builder.test.ts b/packages/stack/__tests__/supabase-schema-builder.test.ts index 2f8ee54f7..93e70c868 100644 --- a/packages/stack/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack/__tests__/supabase-schema-builder.test.ts @@ -70,6 +70,41 @@ describe('synthesizeTables', () => { 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', () => { diff --git a/packages/stack/src/eql/v3/table.ts b/packages/stack/src/eql/v3/table.ts index 8507cc727..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()`, From fd33aadf4d31c95196fb116662051f8852c3c409 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 18:00:47 +1000 Subject: [PATCH 17/27] fix(stack): never encrypt `is` or null filter operands in the supabase adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is` is a SQL predicate — PostgREST accepts only null/true/false after it — and a null operand is SQL NULL, not a value to search for. 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. Only the direct `.is()` filter skipped encryption, via an empty branch in the regular-filter collector. Every other collector encrypted whatever it was handed: not('age','is',null) -> age.not.is.("null") filter('age','is',null) -> age.is.("null") or('age.is.null') -> age.is."("null")" match({email: null}) -> email=("null") eq('email', null) -> email=("null") in('email',['a',null]) -> email=in.(("a"),("null")) All of these are operands PostgREST rejects, so nothing can depend on the current output. This is wrong in v2 today, not only v3; it surfaced when a v2 regression test written for the toDbSpace refactor failed against unmodified code. A single `isEncryptableTerm(operator, value)` predicate now guards all six collectors. The operator must be checked independently of the value, because `is(col, false)` carries a non-null operand. The apply side needs no change: it already falls back to the original value when the term map lacks an index, and `wasEncrypted` is correctly false for a skipped term, so `notFilterOperator` leaves `is` alone. On v3 this also removes a spurious capability error: `is` maps to the `equality` query type, so an `is` term reached the column-capability guard and `or('active.is.null')` on a storage-only `public.boolean` threw "does not support equality queries" instead of querying. One consequence needed fixing with it. The `or()` raw-string applier chose rebuild-vs-verbatim on "was any value encrypted". An `is` on an encrypted column now encrypts nothing, which would send it down the verbatim path and forward the caller's JS property name to a database that only knows the DB name. It now rebuilds whenever a condition REFERENCES an encrypted column. The comment asserting the old invariant, added by the toDbSpace refactor, is corrected. --- .changeset/supabase-is-null-operands.md | 27 ++++ .../__tests__/supabase-v3-builder.test.ts | 136 ++++++++++++++++++ packages/stack/src/supabase/helpers.ts | 24 ++++ packages/stack/src/supabase/query-builder.ts | 37 +++-- 4 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 .changeset/supabase-is-null-operands.md 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/packages/stack/__tests__/supabase-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts index e986df98c..c3202e842 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -557,6 +557,90 @@ describe('encryptedSupabaseV3 wire encoding', () => { 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 = [ { @@ -782,4 +866,56 @@ describe('encryptedSupabase (v2) wire encoding is unchanged by the dialect seams 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]) + }) + }) }) diff --git a/packages/stack/src/supabase/helpers.ts b/packages/stack/src/supabase/helpers.ts index f7d89271f..7157ea4b4 100644 --- a/packages/stack/src/supabase/helpers.ts +++ b/packages/stack/src/supabase/helpers.ts @@ -155,6 +155,30 @@ export function addJsonbCastsV3( .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 +} + /** * Map a Supabase filter operation to a CipherStash query type. */ diff --git a/packages/stack/src/supabase/query-builder.ts b/packages/stack/src/supabase/query-builder.ts index ff566cbb1..9e9f89e28 100644 --- a/packages/stack/src/supabase/query-builder.ts +++ b/packages/stack/src/supabase/query-builder.ts @@ -13,6 +13,7 @@ import { logger } from '@/utils/logger' import { addJsonbCasts, getEncryptedColumnNames, + isEncryptableTerm, isEncryptedColumn, mapFilterOpToQueryType, parseOrString, @@ -530,8 +531,10 @@ export class EncryptedQueryBuilderImpl< 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, @@ -541,7 +544,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, @@ -559,6 +563,8 @@ export class EncryptedQueryBuilderImpl< 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 @@ -577,6 +583,7 @@ export class EncryptedQueryBuilderImpl< 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 @@ -598,6 +605,7 @@ export class EncryptedQueryBuilderImpl< const cond = of_.conditions[j] if (!isEncryptedColumn(cond.column, this.encryptedColumnNames)) continue + if (!isEncryptableTerm(cond.op, cond.value)) continue const column = tableColumns[cond.column] if (!column) continue @@ -615,6 +623,7 @@ export class EncryptedQueryBuilderImpl< const cond = of_.conditions[j] if (!isEncryptedColumn(cond.column, this.encryptedColumnNames)) continue + if (!isEncryptableTerm(cond.op, cond.value)) continue const column = tableColumns[cond.column] if (!column) continue @@ -638,6 +647,7 @@ export class EncryptedQueryBuilderImpl< 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 @@ -1023,7 +1033,17 @@ export class EncryptedQueryBuilderImpl< } } - if (encryptedIndexes.size > 0) { + // 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), @@ -1033,13 +1053,10 @@ export class EncryptedQueryBuilderImpl< }, ) } else { - // No condition referenced an encrypted column. `getColumnMap()` is - // keyed by BOTH property and DB name, so an encrypted column always - // populates `encryptedIndexes` and takes the branch above, which maps - // names. Everything reaching here is therefore a plaintext column, - // whose property name IS its DB name — no mapping to do, and the - // caller's ORIGINAL string is forwarded byte-for-byte (v2 relies on - // this for nested `and()`/quoted values the parser cannot round-trip). + // 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, }) From 24784e249f3b04a2e3b31a122df292eca474bbf7 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 18:44:24 +1000 Subject: [PATCH 18/27] test(stack): cover addJsonbCastsV3, single() dates, forced eqlVersion, encrypt failure/threading Characterization suite for the v3 supabase adapter's untested seams, from review feedback on #588. Each assertion was verified to fail under mutation of the code it covers: - addJsonbCastsV3 had no direct test; its six branches and the Object.hasOwn guard in lookupDbName were only observed indirectly via two select strings. - postprocessDecryptedRow is reached from the array AND single-row paths; only the array path was exercised. - encryptCollectedTerms reimplements the failure / lockContext / audit threading rather than inheriting v2's. The existing 500-status tests all reach 500 via the capability guard, so they pass even if that block is deleted wholesale. - eqlVersion is forced to 3 over options.config; nothing asserted it. - verify.ts's 'actual ?? (none)' arm covers declaring an encrypted column over a plaintext DB column, and had no test. --- .../stack/__tests__/supabase-helpers.test.ts | 65 ++ .../__tests__/supabase-v3-builder.test.ts | 607 ++++++++++++------ .../__tests__/supabase-v3-factory.test.ts | 23 + .../stack/__tests__/supabase-verify.test.ts | 10 + 4 files changed, 524 insertions(+), 181 deletions(-) create mode 100644 packages/stack/__tests__/supabase-helpers.test.ts diff --git a/packages/stack/__tests__/supabase-helpers.test.ts b/packages/stack/__tests__/supabase-helpers.test.ts new file mode 100644 index 000000000..55ec01f01 --- /dev/null +++ b/packages/stack/__tests__/supabase-helpers.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { addJsonbCastsV3 } from '@/supabase/helpers' + +// `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') + }) + + it('maps each token of a multi-column select independently', () => { + expect(addJsonbCastsV3('id, email, createdAt', propToDb)).toBe( + 'id, email::jsonb, createdAt:created_at::jsonb', + ) + }) +}) diff --git a/packages/stack/__tests__/supabase-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts index c3202e842..81b6764de 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -1,188 +1,14 @@ -import { describe, expect, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' +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' - -// --------------------------------------------------------------------------- -// Mocks -// -// 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. -// --------------------------------------------------------------------------- - -type FakeEnvelope = { - v: 2 - i: { t: string; c: string } - c: string - hm: string - pt: unknown -} - -function fakeEnvelope(value: unknown, column: string): FakeEnvelope { - const pt = value instanceof Date ? value.toISOString() : value - return { - v: 2, - i: { t: 'tbl', c: column }, - c: `ct:${String(pt)}`, - hm: `hm:${String(pt)}`, - pt, - } -} - -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. */ -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 -} - -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 -} - -type RecordedCall = { method: string; args: unknown[] } - -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', - '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 } -} +import { + createMockEncryptionClient, + createMockSupabase, + fakeEnvelope, + isFakeEnvelope, +} from './helpers/supabase-mock' // --------------------------------------------------------------------------- // Schemas @@ -666,6 +492,302 @@ describe('encryptedSupabaseV3 wire encoding', () => { '2026-01-02T03:04:05.000Z', ) }) + + // `transformOrConditions`' sole remaining job after `toDbSpace` took over + // column translation is the `like`/`ilike` → `cs` rewrite. The three or() + // string tests above use `gte`/`eq`/`is`, so that branch never ran. + describe('or() with encrypted pattern and structured conditions', () => { + it('rewrites an encrypted ilike inside an or() string to cs', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').or('email.ilike.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\."/) + const quoted = emitted.slice('email.cs."'.length, -1) + expect(JSON.parse(quoted).c).toBeDefined() + }) + + it('rewrites an encrypted like inside an or() string to cs', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').or('email.like.ada') + + expect(supabase.callsFor('or')[0].args[0] as string).toMatch( + /^email\.cs\."/, + ) + }) + + // 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\."/) + const quoted = emitted.slice('created_at.gte."'.length, -1) + expect(JSON.parse(quoted).c).toBeDefined() + }) + + it('rewrites an encrypted ilike in a structured or() to cs', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or([{ column: 'email', op: 'ilike', 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) + expect(data!.email).toBe('a@b.com') + expect(data!.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', 'like', '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('maps not(ilike) on an encrypted column to not(cs)', async () => { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').not('email', 'ilike', 'a@b') + + const [not] = supabase.callsFor('not') + expect(not.args[1]).toBe('cs') + expect(JSON.parse(not.args[2] as string).c).toBeDefined() + }) + + 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') + }) + }) }) // --------------------------------------------------------------------------- @@ -919,3 +1041,126 @@ describe('encryptedSupabase (v2) wire encoding is unchanged by the dialect seams }) }) }) + +// --------------------------------------------------------------------------- +// 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) + }) +}) diff --git a/packages/stack/__tests__/supabase-v3-factory.test.ts b/packages/stack/__tests__/supabase-v3-factory.test.ts index ebd3be255..327faa761 100644 --- a/packages/stack/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack/__tests__/supabase-v3-factory.test.ts @@ -178,4 +178,27 @@ describe('encryptedSupabaseV3 factory', () => { ).rejects.toThrow(/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-verify.test.ts b/packages/stack/__tests__/supabase-verify.test.ts index 28c1bded9..39c3b3b42 100644 --- a/packages/stack/__tests__/supabase-verify.test.ts +++ b/packages/stack/__tests__/supabase-verify.test.ts @@ -51,4 +51,14 @@ describe('verifyDeclaredSchemas', () => { /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/, + ) + }) }) From d02717c14cb1b1966149f5a5c7880eec3c38ecfe Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 19:09:16 +1000 Subject: [PATCH 19/27] fix(stack): scope the unmodelled-domain guard to the tables the caller queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertModelledDomains scanned every base table in `public` at construction, so a single EQL v3 column this SDK cannot model — e.g. `audit_log.payload public.json`, a shipped domain with no types factory — made `encryptedSupabaseV3()` throw for a caller that only ever touches `users`. The remedy it printed was unavailable: no package version models public.json, and the table may belong to another team. The guard is necessary: an unmodelled column never enters the encrypt config but stays in `allColumns`, so `select('*')` selects it and `decryptModel` skips it, returning raw ciphertext typed as data. Only its scope was wrong. - Invert the catalog query. UNMODELLED_COLUMNS_QUERY takes the registry as $1 and returns only offending columns, deleting EQL_DOMAINS_QUERY, the `eqlDomains` plumbing and assertModelledDomains. The registry IS the query parameter, so the two cannot drift. - Index offenders by table; assert at from(), where the caller names one. Declared `schemas` are still validated eagerly, preserving 'fails at construction' where the caller asked for compile-time types. The from() guard is unconditional: synthesizeTables silently drops unmodelled columns, so it is the only thing between a caller and the leak. A live-PG test pins that precondition, and both SQL predicates were mutation-verified against a real catalog. --- .../__tests__/supabase-schema-builder.test.ts | 64 +++++++------ .../__tests__/supabase-v3-builder.test.ts | 7 +- .../__tests__/supabase-v3-factory.test.ts | 95 +++++++++++++++---- .../supabase-v3-introspect-pg.test.ts | 73 ++++++++------ packages/stack/src/supabase/index.ts | 48 ++++++++-- packages/stack/src/supabase/introspect.ts | 81 +++++++++++++--- packages/stack/src/supabase/schema-builder.ts | 31 +----- 7 files changed, 269 insertions(+), 130 deletions(-) diff --git a/packages/stack/__tests__/supabase-schema-builder.test.ts b/packages/stack/__tests__/supabase-schema-builder.test.ts index 93e70c868..980518c03 100644 --- a/packages/stack/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack/__tests__/supabase-schema-builder.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { encryptedTable, types } from '@/eql/v3' import type { IntrospectionResult } from '@/supabase/introspect' +import { groupUnmodelledRows } from '@/supabase/introspect' import { - assertModelledDomains, mergeDeclaredTables, synthesizeTables, } from '@/supabase/schema-builder' @@ -149,37 +149,43 @@ describe('mergeDeclaredTables', () => { }) }) -describe('assertModelledDomains', () => { - it('passes when every EQL domain in use is modelled', () => { - const eqlDomains = new Set(['text_search', 'integer_ord']) - expect(() => assertModelledDomains(introspection, eqlDomains)).not.toThrow() - }) - - it('throws naming the column + domain for a recognised-but-unmodelled domain', () => { - const withOpe: IntrospectionResult = [ - { - tableName: 'metrics', - columns: [ - { columnName: 'id', domainName: null }, - { columnName: 'score', domainName: 'integer_ord_ope' }, +// 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' }, + ], ], - }, - ] - // integer_ord_ope IS an EQL v3 domain, but has no types factory. - const eqlDomains = new Set(['integer_ord_ope']) - expect(() => assertModelledDomains(withOpe, eqlDomains)).toThrow( - /metrics\.score.*integer_ord_ope|integer_ord_ope.*metrics\.score/, + ['audit', [{ columnName: 'payload', domainName: 'json' }]], + ]), ) }) - it('does NOT throw for a user jsonb domain that is not an EQL domain', () => { - const withUserDomain: IntrospectionResult = [ - { - tableName: 'docs', - columns: [{ columnName: 'body', domainName: 'my_json' }], - }, - ] - // my_json is NOT in eqlDomains → plaintext passthrough, no throw. - expect(() => assertModelledDomains(withUserDomain, new Set())).not.toThrow() + 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 index 81b6764de..16d125809 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -609,8 +609,11 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(error).toBeNull() expect(supabase.callsFor('single')).toHaveLength(1) expect(Array.isArray(data)).toBe(false) - expect(data!.email).toBe('a@b.com') - expect(data!.createdAt).toBeInstanceOf(Date) + // `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 () => { diff --git a/packages/stack/__tests__/supabase-v3-factory.test.ts b/packages/stack/__tests__/supabase-v3-factory.test.ts index 327faa761..4ea19a0d3 100644 --- a/packages/stack/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack/__tests__/supabase-v3-factory.test.ts @@ -32,7 +32,10 @@ vi.mock('@supabase/supabase-js', () => ({ createClient: createClientMock })) const fakeClient = { from: () => ({}) } as unknown as SupabaseClientLike function introspectionOf(data: Partial): IntrospectionData { - return { tables: data.tables ?? [], eqlDomains: data.eqlDomains ?? new Set() } + return { + tables: data.tables ?? [], + unmodelled: data.unmodelled ?? new Map(), + } } const usersIntrospection = introspectionOf({ @@ -45,7 +48,6 @@ const usersIntrospection = introspectionOf({ ], }, ], - eqlDomains: new Set(['text_search']), }) beforeEach(() => { @@ -110,7 +112,6 @@ describe('encryptedSupabaseV3 factory', () => { columns: [{ columnName: 'line', domainName: null }], }, ], - eqlDomains: new Set(['text_search']), }), ) await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }) @@ -161,22 +162,78 @@ describe('encryptedSupabaseV3 factory', () => { ).rejects.toThrow(/orders.*users|record key/) }) - it('throws on a recognized-but-unmodelled EQL domain', async () => { - introspectMock.mockResolvedValue( - introspectionOf({ - tables: [ - { - tableName: 'metrics', - columns: [{ columnName: 'score', domainName: 'integer_ord_ope' }], - }, - ], - eqlDomains: new Set(['integer_ord_ope']), - }), - ) - await expect( - encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }), - ).rejects.toThrow(/integer_ord_ope/) - expect(encryptionMock).not.toHaveBeenCalled() + // 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` diff --git a/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts b/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts index 8b0207823..3d108bab7 100644 --- a/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts +++ b/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts @@ -3,10 +3,7 @@ import postgres from 'postgres' import { afterAll, beforeAll, expect, it } from 'vitest' import { factoryForDomain } from '@/eql/v3/domain-registry' import { introspect } from '@/supabase/introspect' -import { - assertModelledDomains, - synthesizeTables, -} from '@/supabase/schema-builder' +import { synthesizeTables } from '@/supabase/schema-builder' import { installEqlV3IfNeeded } from './helpers/eql-v3' import { describeLivePgOnly, LIVE_PG_ENABLED } from './helpers/live-gate' @@ -17,6 +14,7 @@ const sql = LIVE_PG_ENABLED 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 @@ -32,12 +30,17 @@ beforeAll(async () => { 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 + doc public.json, + own public.${USER_DOMAIN} ) `) }, 30000) @@ -46,6 +49,7 @@ 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) @@ -66,10 +70,7 @@ describeLivePgOnly('eql_v3 supabase introspection', () => { }, 30000) it('round-trips the domain → builder mapping via synthesizeTables', async () => { - const { tables, eqlDomains } = await introspect(databaseUrl as string) - // Sanity: the modelled domains are recognised as EQL domains (by COMMENT). - expect(eqlDomains.has('text_search')).toBe(true) - expect(eqlDomains.has('integer_ord')).toBe(true) + const { tables } = await introspect(databaseUrl as string) const { tables: synth, allColumns } = synthesizeTables(tables) const table = synth.get(MODELLED) @@ -88,33 +89,45 @@ describeLivePgOnly('eql_v3 supabase introspection', () => { ]) }, 30000) - it('detects unmodelled EQL domains (by COMMENT) and the guard rejects them', async () => { - const { tables, eqlDomains } = await introspect(databaseUrl as string) + // 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) - // The COMMENT predicate recognises these as EQL domains... - expect(eqlDomains.has('integer_ord_ope')).toBe(true) - expect(eqlDomains.has('json')).toBe(true) - // ...and they have no types factory (genuinely unmodelled)... + // Sanity: these really are EQL domains with no types factory. expect(factoryForDomain('integer_ord_ope')).toBeUndefined() expect(factoryForDomain('json')).toBeUndefined() - // Scope the guard to THIS test's tables. `introspect` returns every table in - // `public`, and a developer's DATABASE_URL may already carry an unmodelled - // EQL column of its own — the assertion would then pass for the wrong - // reason, or fail naming a domain this test never created. - const modelledOnly = tables.filter((t) => t.tableName === MODELLED) - const scoped = tables.filter( - (t) => t.tableName === MODELLED || t.tableName === UNMODELLED, + 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', ) - expect(modelledOnly).toHaveLength(1) - expect(scoped).toHaveLength(2) + }, 30000) - // The modelled table alone must NOT trip the guard. - expect(() => assertModelledDomains(modelledOnly, eqlDomains)).not.toThrow() + it('does not report a fully-modelled table', async () => { + const { unmodelled } = await introspect(databaseUrl as string) + expect(unmodelled.has(MODELLED)).toBe(false) + }, 30000) - // ...and the unmodelled one throws, naming the offending column/domain. - expect(() => assertModelledDomains(scoped, eqlDomains)).toThrow( - /integer_ord_ope|public\.json/, - ) + 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/src/supabase/index.ts b/packages/stack/src/supabase/index.ts index 3699c5e42..2e7b4b8ca 100644 --- a/packages/stack/src/supabase/index.ts +++ b/packages/stack/src/supabase/index.ts @@ -1,13 +1,10 @@ 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 { - assertModelledDomains, - mergeDeclaredTables, - synthesizeTables, -} from './schema-builder' +import { mergeDeclaredTables, synthesizeTables } from './schema-builder' import type { EncryptedQueryBuilder, EncryptedSupabaseConfig, @@ -73,6 +70,33 @@ 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 @@ -157,12 +181,13 @@ export async function encryptedSupabaseV3( ) } - // 3. Introspect, then reject any recognized-but-unmodelled EQL domain BEFORE - // it can silently become a plaintext passthrough. - const { tables, eqlDomains } = await introspect(databaseUrl) - assertModelledDomains(tables, eqlDomains) + // 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)) { @@ -171,6 +196,7 @@ export async function encryptedSupabaseV3( `[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) @@ -220,6 +246,10 @@ export async function encryptedSupabaseV3( `[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, diff --git a/packages/stack/src/supabase/introspect.ts b/packages/stack/src/supabase/introspect.ts index 52f3e2d4b..4b58ea919 100644 --- a/packages/stack/src/supabase/introspect.ts +++ b/packages/stack/src/supabase/introspect.ts @@ -1,3 +1,5 @@ +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 @@ -24,10 +26,46 @@ export interface IntrospectionRow { domain_name: string | null } -/** Tables + the set of `public` domains recognised as EQL v3 (modelled or not). */ +/** 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 - eqlDomains: Set + 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 } /** @@ -79,13 +117,26 @@ const COLUMNS_QUERY = ` // 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. -const EQL_DOMAINS_QUERY = ` - SELECT tp.typname AS domain_name - FROM pg_type tp - JOIN pg_namespace ns ON ns.oid = tp.typnamespace - WHERE tp.typtype = 'd' - AND ns.nspname = 'public' +// +// 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. */ @@ -125,9 +176,9 @@ export async function loadPg( /** * Connect over `databaseUrl`, read every base table in the `public` schema with - * its EQL v3 domain (`domain_name`), and the set of `public` domains recognised - * as EQL v3 (by their `COMMENT`). `pg` is loaded with a dynamic import so - * bundlers do not pull it in unless introspection runs. + * 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). @@ -144,13 +195,15 @@ export async function introspect( }) await client.connect() try { - const [columns, domains] = await Promise.all([ + const [columns, unmodelled] = await Promise.all([ client.query(COLUMNS_QUERY), - client.query<{ domain_name: string }>(EQL_DOMAINS_QUERY), + client.query(UNMODELLED_COLUMNS_QUERY, [ + Object.keys(DOMAIN_REGISTRY), + ]), ]) return { tables: groupIntrospectionRows(columns.rows), - eqlDomains: new Set(domains.rows.map((r) => r.domain_name)), + unmodelled: groupUnmodelledRows(unmodelled.rows), } } finally { // `end()` runs only after a successful connect; swallow its own failure so it diff --git a/packages/stack/src/supabase/schema-builder.ts b/packages/stack/src/supabase/schema-builder.ts index d32448e4c..ba5f22f9f 100644 --- a/packages/stack/src/supabase/schema-builder.ts +++ b/packages/stack/src/supabase/schema-builder.ts @@ -21,9 +21,10 @@ export interface SynthesizedSchema { * 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 — call - * {@link assertModelledDomains} first; here such a column would silently become - * a plaintext passthrough. + * 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, @@ -91,27 +92,3 @@ export function mergeDeclaredTables( return { tables, allColumns: synth.allColumns } } - -/** - * Throw if any introspected column uses an EQL v3 domain this SDK version does - * not model. Such a column would otherwise become a plaintext passthrough: - * inserts fail on the domain CHECK, but reads return raw ciphertext undecrypted - * (`decryptModel` skips columns absent from the config) — a silent data leak. - * A domain not in `eqlDomains` (a user's own jsonb domain) is fine — plaintext. - */ -export function assertModelledDomains( - introspection: IntrospectionResult, - eqlDomains: Set, -): void { - for (const table of introspection) { - for (const col of table.columns) { - const domain = col.domainName - if (domain === null) continue - if (!eqlDomains.has(domain)) continue // not an EQL domain → plaintext - if (factoryForDomain(domain)) continue // modelled → ok - throw new Error( - `[supabase v3]: column "${table.tableName}.${col.columnName}" uses EQL v3 domain "public.${domain}", which this @cipherstash/stack version does not model. Upgrade the package or drop the column — it cannot be used as a plaintext passthrough (reads would return ciphertext undecrypted).`, - ) - } - } -} From 50494d0f8b1d9c1dbc09aff68feeb087c944a936 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 19:12:34 +1000 Subject: [PATCH 20/27] fix(stack): escape double quotes and backslashes in .or() filter operands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatOrValue wrapped a value in double quotes when it contained one of `,().` but never escaped the quotes already inside it. Every v3 encrypted operand is JSON.stringify(envelope) — dense with quotes — so PostgREST terminated the value at the first inner quote and the filter silently matched nothing. Pre-existing in v2, whose composite literal also carries quotes; v3 makes it fire on every encrypted .or(). The three functions are only correct as a set: - formatOrValue escapes \ then " before wrapping, and now quotes a value containing " or \ even without a comma. - parseOrValue unescapes symmetrically; without this a parse -> rebuild round-trip doubles every backslash. - splitOrString no longer toggles inQuotes on an escaped ", which would otherwise end the token and split the operand at its next comma. formatOrValue also now formats array elements recursively, so a quoted element inside an in-list is escaped rather than concatenated raw. --- .../stack/__tests__/supabase-helpers.test.ts | 71 ++++++++++++++++++- .../__tests__/supabase-v3-builder.test.ts | 22 ++++-- packages/stack/src/supabase/helpers.ts | 45 +++++++++--- 3 files changed, 125 insertions(+), 13 deletions(-) diff --git a/packages/stack/__tests__/supabase-helpers.test.ts b/packages/stack/__tests__/supabase-helpers.test.ts index 55ec01f01..2770108ba 100644 --- a/packages/stack/__tests__/supabase-helpers.test.ts +++ b/packages/stack/__tests__/supabase-helpers.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { addJsonbCastsV3 } from '@/supabase/helpers' +import { + addJsonbCastsV3, + parseOrString, + rebuildOrString, +} from '@/supabase/helpers' // `createdAt` is a renamed property (DB column `created_at`); `email` is a // property whose name already equals its DB column. @@ -63,3 +67,68 @@ describe('addJsonbCastsV3', () => { ) }) }) + +// --------------------------------------------------------------------------- +// .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"}' + +describe('rebuildOrString quoting', () => { + it('escapes the double quotes inside a quoted operand', () => { + const out = rebuildOrString([ + { column: 'email', op: 'eq', value: 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([{ column: 'a', op: 'eq', value: 'x\\y,z' }])).toBe( + 'a.eq."x\\\\y,z"', + ) + }) + + it('quotes a value containing a bare double quote even without a comma', () => { + expect(rebuildOrString([{ column: 'a', op: 'eq', value: 'he"llo' }])).toBe( + 'a.eq."he\\"llo"', + ) + }) + + it('leaves a value with no reserved characters unquoted', () => { + expect(rebuildOrString([{ column: 'a', op: 'eq', value: 'plain' }])).toBe( + 'a.eq.plain', + ) + }) +}) + +describe('parseOrString / rebuildOrString round-trip', () => { + it('round-trips an encrypted JSON envelope operand', () => { + const conditions = [{ column: 'email', op: 'eq' as const, value: ENVELOPE }] + expect(parseOrString(rebuildOrString(conditions))).toEqual(conditions) + }) + + it('round-trips a value carrying backslashes and quotes', () => { + const conditions = [{ column: 'a', op: 'eq' as const, value: 'x\\"y,z' }] + expect(parseOrString(rebuildOrString(conditions))).toEqual(conditions) + }) + + it('does not split on a comma inside a quoted operand', () => { + const s = rebuildOrString([ + { column: 'email', op: 'eq', value: ENVELOPE }, + { column: 'id', op: 'eq', value: '7' }, + ]) + const parsed = parseOrString(s) + expect(parsed).toHaveLength(2) + expect(parsed[0].value).toBe(ENVELOPE) + expect(parsed[1].value).toBe('7') + }) +}) diff --git a/packages/stack/__tests__/supabase-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts index 16d125809..4fb50282d 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -61,6 +61,22 @@ function v3Instance(resultData: unknown = []) { 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 // --------------------------------------------------------------------------- @@ -505,8 +521,7 @@ describe('encryptedSupabaseV3 wire encoding', () => { 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\."/) - const quoted = emitted.slice('email.cs."'.length, -1) - expect(JSON.parse(quoted).c).toBeDefined() + expect(JSON.parse(orOperand(emitted, 'email.cs.')).c).toBeDefined() }) it('rewrites an encrypted like inside an or() string to cs', async () => { @@ -538,8 +553,7 @@ describe('encryptedSupabaseV3 wire encoding', () => { const emitted = supabase.callsFor('or')[0].args[0] as string expect(emitted).toMatch(/^created_at\.gte\."/) - const quoted = emitted.slice('created_at.gte."'.length, -1) - expect(JSON.parse(quoted).c).toBeDefined() + expect(JSON.parse(orOperand(emitted, 'created_at.gte.')).c).toBeDefined() }) it('rewrites an encrypted ilike in a structured or() to cs', async () => { diff --git a/packages/stack/src/supabase/helpers.ts b/packages/stack/src/supabase/helpers.ts index 7157ea4b4..c651b186e 100644 --- a/packages/stack/src/supabase/helpers.ts +++ b/packages/stack/src/supabase/helpers.ts @@ -264,8 +264,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) { @@ -290,9 +301,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) @@ -314,14 +327,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' @@ -333,7 +362,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 From 81d2eb28b6faf8e72a92edc6c0943b6e7d2b8642 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 19:18:21 +1000 Subject: [PATCH 21/27] fix(stack): encrypt each element of an in() list inside .or() 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. `JsPlaintext` admits arrays, so nothing rejected it; after substitution the value was a single string, the `(a,b)` list form was lost, and the filter could never match. Silent, fails closed, pre-existing in v2. - Collapse the string/structured or-term loops, which differed only in their `source` tag, so the in-split lands once for both. - Widen the or TermMapping key with an optional `inIndex`, so a per-element value never collides with a whole-condition value. - `substituteOrValue` rebuilds the array element-by-element, letting `formatOrValue` re-emit the `(a,b)` list form. --- .../stack/__tests__/supabase-helpers.test.ts | 42 +++--- .../__tests__/supabase-v3-builder.test.ts | 35 +++++ packages/stack/src/supabase/query-builder.ts | 135 ++++++++++++------ 3 files changed, 147 insertions(+), 65 deletions(-) diff --git a/packages/stack/__tests__/supabase-helpers.test.ts b/packages/stack/__tests__/supabase-helpers.test.ts index 2770108ba..02fd5117c 100644 --- a/packages/stack/__tests__/supabase-helpers.test.ts +++ b/packages/stack/__tests__/supabase-helpers.test.ts @@ -4,6 +4,7 @@ import { 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. @@ -80,11 +81,14 @@ describe('addJsonbCastsV3', () => { 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) { + return { column, op, value } as unknown as DbPendingOrCondition +} + describe('rebuildOrString quoting', () => { it('escapes the double quotes inside a quoted operand', () => { - const out = rebuildOrString([ - { column: 'email', op: 'eq', value: ENVELOPE }, - ]) + 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\\"}"`, @@ -92,39 +96,41 @@ describe('rebuildOrString quoting', () => { }) it('escapes a backslash before escaping quotes', () => { - expect(rebuildOrString([{ column: 'a', op: 'eq', value: 'x\\y,z' }])).toBe( - 'a.eq."x\\\\y,z"', - ) + 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([{ column: 'a', op: 'eq', value: 'he"llo' }])).toBe( - 'a.eq."he\\"llo"', - ) + expect(rebuildOrString([cond('a', 'eq', 'he"llo')])).toBe('a.eq."he\\"llo"') }) it('leaves a value with no reserved characters unquoted', () => { - expect(rebuildOrString([{ column: 'a', op: 'eq', value: 'plain' }])).toBe( - 'a.eq.plain', - ) + 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' as const, value: ENVELOPE }] - expect(parseOrString(rebuildOrString(conditions))).toEqual(conditions) + const conditions = [{ column: 'email', op: 'eq', 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' as const, value: 'x\\"y,z' }] - expect(parseOrString(rebuildOrString(conditions))).toEqual(conditions) + const conditions = [{ column: 'a', op: 'eq', 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([ - { column: 'email', op: 'eq', value: ENVELOPE }, - { column: 'id', op: 'eq', value: '7' }, + cond('email', 'eq', ENVELOPE), + cond('id', 'eq', '7'), ]) const parsed = parseOrString(s) expect(parsed).toHaveLength(2) diff --git a/packages/stack/__tests__/supabase-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts index 4fb50282d..a2835424b 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -556,6 +556,41 @@ describe('encryptedSupabaseV3 wire encoding', () => { 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 ilike in a structured or() to cs', async () => { const { es, supabase } = v3Instance() diff --git a/packages/stack/src/supabase/query-builder.ts b/packages/stack/src/supabase/query-builder.ts index 9e9f89e28..d77007016 100644 --- a/packages/stack/src/supabase/query-builder.ts +++ b/packages/stack/src/supabase/query-builder.ts @@ -597,49 +597,43 @@ export class EncryptedQueryBuilderImpl< termMap.push({ source: 'not', notIndex: i }) } - // Or filters — conditions were parsed once, in `toDbSpace`. + // 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] - if (of_.kind === 'string') { - for (let j = 0; j < of_.conditions.length; j++) { - const cond = of_.conditions[j] - if (!isEncryptedColumn(cond.column, this.encryptedColumnNames)) - continue - if (!isEncryptableTerm(cond.op, cond.value)) continue - const column = tableColumns[cond.column] - if (!column) continue + 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), 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 - if (!isEncryptableTerm(cond.op, cond.value)) 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) } } @@ -928,17 +922,13 @@ 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 } } @@ -1026,9 +1016,9 @@ export class EncryptedQueryBuilderImpl< 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) } + const sub = substituteOrValue(orStringConditionMap, i, j, parsed[j]) + if (sub) { + parsed[j] = { ...parsed[j], value: sub.value } encryptedIndexes.add(j) } } @@ -1065,10 +1055,10 @@ export class EncryptedQueryBuilderImpl< // Structured: convert to string const encryptedIndexes = new Set() const conditions = of_.conditions.map((cond, j) => { - const key = `${i}:${j}` - if (orStructuredConditionMap.has(key)) { + const sub = substituteOrValue(orStructuredConditionMap, i, j, cond) + if (sub) { encryptedIndexes.add(j) - return { ...cond, value: orStructuredConditionMap.get(key) } + return { ...cond, value: sub.value } } return cond }) @@ -1350,14 +1340,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: { From 9c2093597d1cca51d43f4638531d9906d4d2b03c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 19:19:51 +1000 Subject: [PATCH 22/27] test(stack): drive all 39 v3 domains through the supabase adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-written supabase v3 wire tests exercised only 5 of the catalog's 39 domains; the whole numeric family (smallint/numeric/real/double/date/ bigint) never reached `EncryptedQueryBuilderV3Impl` at all, and nothing guarded that number. `supabase-v3-matrix.test.ts` reuses the compile-enforced `V3_MATRIX`, so adding a domain to the SDK yields a Supabase wire assertion for free — or fails to compile until it has a catalog row. Tiers are derived from `indexes`, exactly as `drizzle-v3/operators-live-pg.test.ts` derives them, rather than from `capabilities` (which is what the adapter's guard reads): deriving from the other field turns a future capability/index divergence into a visible tier mismatch instead of a test that agrees with itself. The tier counts are asserted, so a domain silently dropping out of a tier turns a test red rather than quietly shrinking an `it.each`. The storage-only tier asserts the capability-guard MESSAGE, not merely the 500 status, and takes `samples[0]` explicitly: after fd33aadf a null operand skips encryption entirely and never reaches the guard, so a status-only assertion on a future null sample would pass for the wrong reason. Also extracts the mock encryption/supabase clients into `__tests__/helpers/supabase-mock.ts` so both suites drive the adapter through the same doubles. 24784e24 committed a `supabase-v3-builder.test.ts` that already imports this module without adding it; HEAD does not currently resolve that import. `fakeEnvelope` now normalizes `bigint` the way it already normalized `Date`: the v3 filter path carries `pt` through `JSON.stringify`, which throws on a BigInt. A real envelope only ever holds strings, so this is a mock artifact, not a product bug — but the four `public.bigint*` domains' samples hit it. Finally, the "35 domains" comments in the v3 matrix live suites were stale; the four bigint domains brought the catalog to 39. --- .../stack/__tests__/helpers/supabase-mock.ts | 195 ++++++++++++++++ .../__tests__/supabase-v3-matrix.test.ts | 210 ++++++++++++++++++ .../v3-matrix/matrix-live-pg.test.ts | 4 +- .../__tests__/v3-matrix/matrix-live.test.ts | 6 +- 4 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 packages/stack/__tests__/helpers/supabase-mock.ts create mode 100644 packages/stack/__tests__/supabase-v3-matrix.test.ts diff --git a/packages/stack/__tests__/helpers/supabase-mock.ts b/packages/stack/__tests__/helpers/supabase-mock.ts new file mode 100644 index 000000000..4e26733c1 --- /dev/null +++ b/packages/stack/__tests__/helpers/supabase-mock.ts @@ -0,0 +1,195 @@ +/** + * 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', + '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__/supabase-v3-matrix.test.ts b/packages/stack/__tests__/supabase-v3-matrix.test.ts new file mode 100644 index 000000000..23823d3a8 --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-matrix.test.ts @@ -0,0 +1,210 @@ +/** + * 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() + }) + + it('accepts order()', async () => { + const { q, supabase, name } = instanceFor(eqlType, spec) + + const { error } = await q.select(`id, ${name}`).order(name) + + expect(error).toBeNull() + expect(supabase.callsFor('order')[0].args[0]).toBe(name) + }) + }) + + describe.each(matchDomains)('%s (freeTextSearch)', (eqlType, spec) => { + it('rewrites like() to a cs containment filter', async () => { + const { q, supabase, name } = instanceFor(eqlType, spec) + + await q.select(`id, ${name}`).like(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) + }) + }) + + 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 ordering capability 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('does not support ordering') + }) + }) +}) 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). From a54bd5b7b725b2e649a267d0986ec8836e9845a7 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 19:20:08 +1000 Subject: [PATCH 23/27] fix(cli): grant eql_v3_internal to the supabase roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the shipped grants, EVERY encrypted filter the supabase v3 adapter emits fails for `anon` and `authenticated` with permission denied for schema eql_v3_internal `supabasePermissionsSql` granted exactly one schema, `eql_v3`. But the public entry points the query path calls — `eql_v3.eq_term` (26 of 27 overloads), `ord_term` (38/38) and `match_term` (4/4) — 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 the call never reaches the function. `=` routes through eq_term, `>=` through ord_term, `@>`/`cs` through match_term: there is no encrypted operation that survives. The default PUBLIC EXECUTE on functions means USAGE is the only real barrier; EXECUTE is granted anyway so an install into a database that revoked it from PUBLIC still works. `eql_v2` has no internal schema and is unaffected. Fixes a second defect found on the way: `grantSupabasePermissions` rebuilt the block from `supabasePermissionsSql(schemaNameFor(eqlVersion))` rather than using the exported constant, so this fix would have reached the generated Supabase migration and NOT the database `stash db install` writes to. Both now go through `supabaseGrantsFor(eqlVersion)`, and the installer test pins the executed string to the exported constant. The grants SQL moves to an import-free `installer/grants.ts` so the live proof in @cipherstash/stack can assert against the EXACT shipped strings; `stash` already depends on @cipherstash/stack, so a dependency the other way would be a cycle, and a local copy would drift. `supabase-v3-grants-pg.test.ts` runs on the existing DATABASE_URL-only gate. It needs no CipherStash credentials: `eql_v3_internal.ore_block_256('{}')` fails on PERMISSION for a caller that cannot resolve the schema and on DATA for one that can, and that distinction is the whole test. It revokes before applying, because GRANTs persist and a reused database would otherwise let the suite pass on leftover state without the SQL under test granting anything — and it asserts the grants are load-bearing by stripping them and watching anon break. --- packages/cli/src/__tests__/installer.test.ts | 25 ++- packages/cli/src/installer/grants.ts | 82 ++++++++ packages/cli/src/installer/index.ts | 64 +++---- .../__tests__/supabase-v3-grants-pg.test.ts | 179 ++++++++++++++++++ 4 files changed, 316 insertions(+), 34 deletions(-) create mode 100644 packages/cli/src/installer/grants.ts create mode 100644 packages/stack/__tests__/supabase-v3-grants-pg.test.ts 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__/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) + } + }) +}) From 3d9f3d57fb2a3ad83de4b3b46573b0ed597ab950 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 19:22:40 +1000 Subject: [PATCH 24/27] fix(stack): reject property/DB name collisions at construction; correct the query-term doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two construction-time guards, and one factually wrong comment. select('*') collision: a column renamed `created_at -> createdAt` alongside a distinct plaintext column literally named `createdAt` makes expandAllColumns emit the token `createdAt` twice, so addJsonbCastsV3 aliases both to `createdAt:created_at::jsonb`. PostgREST returns the encrypted column under that key and the plaintext one is never selected. EncryptedTable.build()'s duplicate check only fires when two BUILDERS share a getName(), so nothing caught it. Refuse to construct the builder. Duplicate declared rename: two properties on the same DB column each verify fine, then collide in mergeDeclaredTables and throw from EncryptedTable.build() naming neither the properties nor the schemas entry. Detect it in verify.ts, where both are known. The class doc claimed a narrowed encryptQuery term 'fails the CHECK with 23514 for EVERY domain'. The opposite is true: the bundle defines 39 public.*_query domains whose CHECK requires NOT (VALUE ? 'c'). The real reason full envelopes are used is that PostgREST cannot cast a filter value, so those overloads are unreachable and an uncast literal is ambiguous (42725) — and protect-ffi 0.28 cannot produce a v3 scalar query term at all. Also records that encrypted like/ilike is known-broken for real substrings, with the same include_original cause as v3 Drizzle's contains. --- .../__tests__/supabase-v3-builder.test.ts | 53 ++++++++++++++ .../stack/__tests__/supabase-verify.test.ts | 24 +++++++ .../stack/src/supabase/query-builder-v3.ts | 72 +++++++++++++++---- packages/stack/src/supabase/verify.ts | 16 +++++ 4 files changed, 153 insertions(+), 12 deletions(-) diff --git a/packages/stack/__tests__/supabase-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts index a2835424b..38156d920 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -1094,6 +1094,59 @@ describe('encryptedSupabase (v2) wire encoding is unchanged by the dialect seams }) }) +// --------------------------------------------------------------------------- +// 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 // diff --git a/packages/stack/__tests__/supabase-verify.test.ts b/packages/stack/__tests__/supabase-verify.test.ts index 39c3b3b42..f4b0aef48 100644 --- a/packages/stack/__tests__/supabase-verify.test.ts +++ b/packages/stack/__tests__/supabase-verify.test.ts @@ -62,3 +62,27 @@ describe('verifyDeclaredSchemas', () => { ) }) }) + +// 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/src/supabase/query-builder-v3.ts b/packages/stack/src/supabase/query-builder-v3.ts index d9bd85598..af3cf6f60 100644 --- a/packages/stack/src/supabase/query-builder-v3.ts +++ b/packages/stack/src/supabase/query-builder-v3.ts @@ -42,6 +42,38 @@ type V3ColumnLike = { 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 @@ -56,21 +88,35 @@ type V3ColumnLike = { * `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. This is load-bearing: - * each `public.*` domain CHECK requires the storage keys (`v`/`i`/`c` plus - * the domain's index terms), and the SQL operator functions coerce their - * jsonb operand into the domain — so a narrowed `encryptQuery` term (which - * carries no `c`) fails the CHECK with 23514 for EVERY domain, not just - * `text_search`. The full envelope satisfies the CHECK by construction and + * 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`). * - **`like`/`ilike`** — the v3 domains define no LIKE operator; free-text - * match is `@>` on the bloom filter. Encrypted pattern filters are emitted - * as PostgREST `cs` instead. (Match is tokenized + downcased, so `like` and - * `ilike` behave identically. For substring patterns to match, the column's - * match index should set `include_original: false` — with the default - * `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.) + * match is `@>` on the bloom filter, so encrypted pattern filters are emitted + * as PostgREST `cs`. Match is tokenized + downcased, so `like` and `ilike` + * behave identically, and `%` wildcards are NOT interpreted — they are + * tokenized like any other character. + * + * 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. @@ -118,6 +164,8 @@ export class EncryptedQueryBuilderV3Impl< 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. diff --git a/packages/stack/src/supabase/verify.ts b/packages/stack/src/supabase/verify.ts index c7a5e6852..b20226fd8 100644 --- a/packages/stack/src/supabase/verify.ts +++ b/packages/stack/src/supabase/verify.ts @@ -29,6 +29,22 @@ export function verifyDeclaredSchemas( `[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)) { From 1666655b1cb5c0becdd5ffecc5a1c7071e503cc1 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 19:41:07 +1000 Subject: [PATCH 25/27] test(stack): run the supabase v3 adapter against a real PostgREST as anon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No supabase v3 code had ever reached a server. Every suite drove `createMockSupabase`, an argument recorder with no SQL behind it: it can prove the adapter EMITS `email.cs."{…}"`, never that PostgREST parses it, that `cs` maps to `@>` on a domain column, that a full storage envelope clears the `public.*` domain CHECK as a filter operand, or that `anon` has the grants those operators need. `supabase-v3-pgrest-live.test.ts` puts the real adapter, real `@supabase/postgrest-js`, real PostgREST, real domains and the real `eql_v3` operators in the path. Only ZeroKMS is stubbed: the domain CHECKs are STRUCTURAL (`v`/`i`/`c` plus the domain's index terms, `v = '3'`), so `helpers/v3-envelope.ts` builds valid envelopes directly and the suite runs on the DATABASE_URL gate with no credentials. Cryptographic round-tripping and ORE ordering stay `drizzle-v3/operators-live-pg`'s job — synthetic ORE terms compare equal to themselves and carry no order semantics, so this suite must never assert ordering. It runs as `anon`, not as the owner: PostgREST connects as `authenticator`, a non-superuser member of `anon`, so the Supabase grants are actually exercised rather than bypassed by ambient superuser rights. Verified to have teeth — emitting narrowed `encryptQuery` operands turns 7 tests red, dropping the `like`→`cs` rewrite turns 3 red, and reverting the `eql_v3_internal` grants turns 7 red with `permission denied for schema eql_v3_internal` at the HTTP layer (401). Two things this found that no unit test could: `reloadSchemaCache` must poll the ROOT OpenAPI, not a GET against the new table. PostgREST 12 self-heals a cache miss by scheduling a reload and retrying that one request, so a probe GET starts succeeding while the reload is still in flight — cold, the probe returned 200, the very next INSERT returned 404, and a later GET returned 200 again. Root output is rendered from the loaded cache, so a table appearing there means every verb sees it. A pattern of exactly 3 characters is a degenerate `include_original` case: its whole-value token IS one of the stored value's trigrams, so `like('email','ada')` DOES match. The class doc's "a substring pattern only matches when it equals the stored value" holds only for patterns longer than the tokenizer window. Both behaviours are now pinned. PostgREST starts as a CI STEP rather than a `services:` entry: service containers start before checkout, and `authenticator` does not exist in the postgres image until `local/postgrest-roles.sql` has run. `PGRST_URL` joins the live-coverage guard so a missing service cannot silently skip the suite while CI stays green. --- .github/workflows/tests.yml | 29 ++ local/docker-compose.yml | 23 ++ local/postgrest-roles.sql | 35 ++ packages/stack/__tests__/helpers/live-gate.ts | 22 + packages/stack/__tests__/helpers/pgrest.ts | 73 ++++ .../stack/__tests__/helpers/v3-envelope.ts | 90 ++++ .../__tests__/live-coverage-guard.test.ts | 48 ++- .../__tests__/supabase-v3-pgrest-live.test.ts | 388 ++++++++++++++++++ packages/stack/package.json | 1 + pnpm-lock.yaml | 3 + 10 files changed, 697 insertions(+), 15 deletions(-) create mode 100644 local/postgrest-roles.sql create mode 100644 packages/stack/__tests__/helpers/pgrest.ts create mode 100644 packages/stack/__tests__/helpers/v3-envelope.ts create mode 100644 packages/stack/__tests__/supabase-v3-pgrest-live.test.ts 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/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/stack/__tests__/helpers/live-gate.ts b/packages/stack/__tests__/helpers/live-gate.ts index 216598b27..854bfb4dc 100644 --- a/packages/stack/__tests__/helpers/live-gate.ts +++ b/packages/stack/__tests__/helpers/live-gate.ts @@ -50,3 +50,25 @@ export const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip 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/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 3d1a76191..1d44370dd 100644 --- a/packages/stack/__tests__/live-coverage-guard.test.ts +++ b/packages/stack/__tests__/live-coverage-guard.test.ts @@ -57,6 +57,7 @@ import { 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". @@ -95,21 +96,18 @@ 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', - () => { - 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.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', @@ -127,6 +125,26 @@ describe('live-coverage guard', () => { }, ) + it.runIf(IN_CI)( + 'CI must have PGRST_URL so the live supabase PostgREST suite does not silently skip', + () => { + expect( + 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) + }, + ) + // Local dev with no creds: nothing to assert. Keep at least one always-run // assertion so the file is never reported as fully empty/pending. it('is always collected (guard file runs outside every live gate)', () => { 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..6549c964d --- /dev/null +++ b/packages/stack/__tests__/supabase-v3-pgrest-live.test.ts @@ -0,0 +1,388 @@ +/** + * 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 bloom filter. With the default `include_original: true` + // the pattern's bloom carries the WHOLE pattern as an extra token, so only an + // exact-value pattern matches. Asserts the DOCUMENTED semantics, not the + // intuitive ones. + it('resolves like() through cs containment for an exact-value pattern', async () => { + const { data, error } = await from() + .select('row_key') + .like('email', 'ada@example.com') + + expect(error).toBeNull() + expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) + }) + + // A pattern LONGER than the tokenizer's 3-gram window contributes an + // `include_original` token no stored trigram can supply, so containment + // fails. (A pattern of exactly 3 characters is the degenerate case: its + // whole-value token IS a trigram, so `like('email','ada')` DOES match. The + // class doc's "only an exact-value pattern matches" is a simplification.) + it('does not match a longer substring like() pattern under include_original', async () => { + const { data, error } = await from() + .select('row_key') + .like('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').like('email', 'ada') + + expect(error).toBeNull() + expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) + }) + + // 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']) + }) + + // `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/package.json b/packages/stack/package.json index 19249bd62..d6b6fcb4b 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -239,6 +239,7 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 682ee060c..0fe174740 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -576,6 +576,9 @@ 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 From cbf2da4f098864b8eb494cf31c113fcbc6e0b615 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 21:00:26 +1000 Subject: [PATCH 26/27] test(stack): close the audited v3 coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three branches the PR review flagged as untested, each verified to kill its mutant before landing: - `select('*')` guards on `allColumns === null || .length === 0`, but only the null arm had a caller. Pins the empty-array arm so a future `?? []` cannot turn an unusable `*` into a silent zero-column select. - `mergeDeclaredTables`' `if (synthesized)` false arm. Unreachable through the factory (verifyDeclaredSchemas throws first) but the function is exported. - `introspect()`'s happy path: both queries issued, the registry pushed as the query parameter, and `client.end()` called — on the success path and when a query throws. A leaked connection is invisible to every other assertion. Two further proposed tests were dropped rather than landed: the `addJsonbCastsV3` empty-token guard is a fast-path that falls through to the same `return col`, so no test can kill it, and its leading-whitespace capture is already pinned by an existing test. --- .../__tests__/supabase-introspect.test.ts | 92 +++++++++++++++++++ .../__tests__/supabase-schema-builder.test.ts | 23 +++++ .../__tests__/supabase-v3-select-star.test.ts | 11 +++ 3 files changed, 126 insertions(+) diff --git a/packages/stack/__tests__/supabase-introspect.test.ts b/packages/stack/__tests__/supabase-introspect.test.ts index 8832dab81..a39bb60ac 100644 --- a/packages/stack/__tests__/supabase-introspect.test.ts +++ b/packages/stack/__tests__/supabase-introspect.test.ts @@ -72,6 +72,98 @@ describe('groupIntrospectionRows', () => { }) }) +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()) diff --git a/packages/stack/__tests__/supabase-schema-builder.test.ts b/packages/stack/__tests__/supabase-schema-builder.test.ts index 980518c03..f9d336b53 100644 --- a/packages/stack/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack/__tests__/supabase-schema-builder.test.ts @@ -147,6 +147,29 @@ describe('mergeDeclaredTables', () => { 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 diff --git a/packages/stack/__tests__/supabase-v3-select-star.test.ts b/packages/stack/__tests__/supabase-v3-select-star.test.ts index 93f824ee9..3e1b3522d 100644 --- a/packages/stack/__tests__/supabase-v3-select-star.test.ts +++ b/packages/stack/__tests__/supabase-v3-select-star.test.ts @@ -115,6 +115,17 @@ describe("v3 select('*') expansion", () => { // 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", () => { From 60fd9603d1c0db7595afa545724cb87a54ea554c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Jul 2026 21:00:54 +1000 Subject: [PATCH 27/27] feat(stack)!: replace like/ilike with contains on the v3 supabase surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Drizzle integration already omits `like`/`ilike` in favour of `contains`; the supabase adapter now matches. This is a surface rename, not a re-encoding. The bundle declares CREATE OPERATOR @> (FUNCTION = eql_v3.contains, LEFTARG = public.text_search, RIGHTARG = jsonb) so PostgREST's `cs` already resolved through `@>` into `eql_v3.contains` → `match_term(a) @> match_term(b)` → smallint[] bloom containment. It was never the built-in `jsonb @> jsonb`. The emitted operator and the full-envelope operand are unchanged; no ciphertext moves. The live PostgREST suite proves the resolution: a 3-character needle matches while a 7-character one does not, and neither shares the stored `c`. `contains` is narrowed at compile time to freeTextSearch-capable domains (text_match, text_search). The shared builder interface is now parameterised by its own return type, so `like` is absent at every chain depth rather than only on the first call; the runtime still throws for untyped JS callers, and passes `like` through on a genuine plaintext column. Three silent-failure bugs fixed alongside. order() on ANY encrypted v3 column now throws, including the ORE-capable ones — the non-obvious half. The `*_ord` domains are `DOMAIN … AS jsonb` and the bundle declares no btree operator class on any domain (it lints against one), so `ORDER BY col` resolved through jsonb's default `jsonb_cmp` and sorted by the envelope's byte structure, first key `bf`. Stable, plausible-looking, and meaningless, with no error. Confirmed against the live database: integer_ord has zero operator classes and a jsonb base type. Correct ordering is `ORDER BY eql_v3.ord_term(col)`, which PostgREST's `order=` cannot express. The old guard rejected exactly the columns where the wrongness was obvious and admitted the ones where it was silent. gte/lte remain correct: the comparison operators ARE declared on the ord domains; only sorting resolves through the missing operator class. or() now understands PostgREST's `column.not..` negation. The parser split on the first two dots, read `not` as the operator, and so `or('nickname.not.in.(ada,grace)')` on an encrypted column encrypted the literal string `in.(ada,grace)` as one plaintext — a filter that silently matched nothing. A malformed `col.not.` is passed through for PostgREST to reject rather than dropped, which would quietly widen the result set. Raw filter() derives its query type from the operator on v3 instead of always encrypting an equality term, so `.filter('bio','cs',…)` on a text_match column (equality: false, freeTextSearch: true) answers the query it was given rather than being rejected. Unknown operators throw. The v2 base still returns 'equality' verbatim: there `queryType` selects the encryptQuery narrowing, so correcting it moves v2 ciphertext. Tracked separately. Every new test was mutation-checked against the implementation it protects. The include_original substring defect is untouched and still pinned by a test asserting the broken behaviour: the operand is a storage envelope whose bloom carries the whole needle as an extra token, so a substring only matches when it equals the stored value or is exactly the tokenizer's 3-character window. Shared with v3 Drizzle's contains and tracked upstream in EQL. BREAKING CHANGE: `like`/`ilike` are removed from the EQL v3 supabase builder — use `contains`. `order()` on an encrypted v3 column now throws; order by a plaintext column, expose `eql_v3.ord_term()` as a generated column or view, or use the EQL v3 Drizzle integration. v2 (`encryptedSupabase`) is unchanged. --- .changeset/eql-v3-supabase-adapter.md | 47 ++- .../stack/__tests__/helpers/supabase-mock.ts | 1 + .../stack/__tests__/supabase-helpers.test.ts | 96 +++++- .../__tests__/supabase-v3-builder.test.ts | 276 +++++++++++++++--- .../__tests__/supabase-v3-matrix.test.ts | 31 +- .../__tests__/supabase-v3-pgrest-live.test.ts | 85 +++++- .../stack/__tests__/supabase-v3.test-d.ts | 82 +++++- packages/stack/src/supabase/helpers.ts | 32 +- packages/stack/src/supabase/index.ts | 3 + .../stack/src/supabase/query-builder-v3.ts | 156 ++++++++-- packages/stack/src/supabase/query-builder.ts | 57 +++- packages/stack/src/supabase/types.ts | 216 ++++++++++---- 12 files changed, 913 insertions(+), 169 deletions(-) diff --git a/.changeset/eql-v3-supabase-adapter.md b/.changeset/eql-v3-supabase-adapter.md index 309db781e..43e5a4a67 100644 --- a/.changeset/eql-v3-supabase-adapter.md +++ b/.changeset/eql-v3-supabase-adapter.md @@ -20,9 +20,46 @@ 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 a column whose domain has no -`orderAndRange` capability (e.g. a storage-only `public.boolean`) is rejected — -at compile time when `schemas` is supplied, and at runtime otherwise — instead -of silently sorting by the raw ciphertext envelope. +remembered to translate. -v2 (`encryptedSupabase`) is unchanged. +`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/packages/stack/__tests__/helpers/supabase-mock.ts b/packages/stack/__tests__/helpers/supabase-mock.ts index 4e26733c1..9c8acb474 100644 --- a/packages/stack/__tests__/helpers/supabase-mock.ts +++ b/packages/stack/__tests__/helpers/supabase-mock.ts @@ -155,6 +155,7 @@ export function createMockSupabase(resultData: unknown = []) { 'lte', 'like', 'ilike', + 'contains', 'is', 'in', 'filter', diff --git a/packages/stack/__tests__/supabase-helpers.test.ts b/packages/stack/__tests__/supabase-helpers.test.ts index 02fd5117c..e7ce2604b 100644 --- a/packages/stack/__tests__/supabase-helpers.test.ts +++ b/packages/stack/__tests__/supabase-helpers.test.ts @@ -62,6 +62,7 @@ describe('addJsonbCastsV3', () => { 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', @@ -82,8 +83,8 @@ describe('addJsonbCastsV3', () => { 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) { - return { column, op, value } as unknown as DbPendingOrCondition +function cond(column: string, op: string, value: unknown, negate?: boolean) { + return { column, op, value, negate } as unknown as DbPendingOrCondition } describe('rebuildOrString quoting', () => { @@ -110,7 +111,9 @@ describe('rebuildOrString quoting', () => { describe('parseOrString / rebuildOrString round-trip', () => { it('round-trips an encrypted JSON envelope operand', () => { - const conditions = [{ column: 'email', op: 'eq', value: ENVELOPE }] + const conditions = [ + { column: 'email', op: 'eq', negate: false, value: ENVELOPE }, + ] expect( parseOrString( rebuildOrString(conditions.map((c) => cond(c.column, c.op, c.value))), @@ -119,7 +122,9 @@ describe('parseOrString / rebuildOrString round-trip', () => { }) it('round-trips a value carrying backslashes and quotes', () => { - const conditions = [{ column: 'a', op: 'eq', value: 'x\\"y,z' }] + 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))), @@ -138,3 +143,86 @@ describe('parseOrString / rebuildOrString round-trip', () => { 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-v3-builder.test.ts b/packages/stack/__tests__/supabase-v3-builder.test.ts index 38156d920..dc784bace 100644 --- a/packages/stack/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack/__tests__/supabase-v3-builder.test.ts @@ -20,6 +20,9 @@ const users = encryptedTable('users', { 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', { @@ -35,6 +38,7 @@ const USERS_ALL_COLUMNS = [ 'amount', 'created_at', 'active', + 'bio', 'note', ] @@ -163,22 +167,41 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(JSON.parse(gte.args[1] as string).c).toBeDefined() }) - it('emits encrypted like/ilike as PostgREST cs (bloom-filter containment)', async () => { + it('emits encrypted contains as PostgREST cs (bloom-filter containment)', async () => { const { es, supabase } = v3Instance() - await es.from('users', users).select('id, email').like('email', 'a@b') - await es.from('users', users).select('id, email').ilike('email', 'a@b') + await es.from('users', users).select('id, email').contains('email', 'a@b') const filterCalls = supabase.callsFor('filter') - expect(filterCalls).toHaveLength(2) - for (const call of filterCalls) { - expect(call.args[0]).toBe('email') - expect(call.args[1]).toBe('cs') - expect(JSON.parse(call.args[2] as string).c).toBeDefined() - } - // No bare like/ilike reached PostgREST for the encrypted column - expect(supabase.callsFor('like')).toHaveLength(0) - expect(supabase.callsFor('ilike')).toHaveLength(0) + 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 () => { @@ -190,13 +213,13 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(supabase.callsFor('filter')).toHaveLength(0) }) - it('maps not(like) on encrypted columns to not(cs)', async () => { + 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', 'like', 'a@b') + .not('email', 'contains', 'a@b') const [not] = supabase.callsFor('not') expect(not.args[0]).toBe('email') @@ -260,13 +283,41 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(error?.message).toContain('does not support equality') }) - it('maps property names to DB names in order()', async () => { - const { es, supabase } = v3Instance() + // 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() - await es.from('users', users).select('id, createdAt').order('createdAt') + const { error, status } = await es + .from('users', users) + .select('id, createdAt') + .order('createdAt') - const [order] = supabase.callsFor('order') - expect(order.args[0]).toBe('created_at') + 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 () => { @@ -278,7 +329,7 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(order.args[0]).toBe('note') }) - it('rejects order() on a column with no orderAndRange capability', async () => { + 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 @@ -288,7 +339,7 @@ describe('encryptedSupabaseV3 wire encoding', () => { .order('active') expect(status).toBe(500) - expect(error?.message).toContain('does not support ordering') + expect(error?.message).toContain('cannot order by encrypted column') }) it('maps property names to DB names in the onConflict option', async () => { @@ -365,7 +416,7 @@ describe('encryptedSupabaseV3 wire encoding', () => { .not('nickname', 'eq', 'ada') .or('createdAt.gte.2026-01-01,note.eq.x') .match({ nickname: 'grace' }) - .order('createdAt') + .order('note') .limit(5) expect(supabase.callsFor('eq')[0].args[0]).toBe('email') @@ -377,7 +428,9 @@ describe('encryptedSupabaseV3 wire encoding', () => { (supabase.callsFor('match')[0].args[0] as Record) .nickname, ).toBeDefined() - expect(supabase.callsFor('order')[0].args[0]).toBe('created_at') + // `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) }) @@ -509,14 +562,14 @@ describe('encryptedSupabaseV3 wire encoding', () => { ) }) - // `transformOrConditions`' sole remaining job after `toDbSpace` took over - // column translation is the `like`/`ilike` → `cs` rewrite. The three or() - // string tests above use `gte`/`eq`/`is`, so that branch never ran. + // `.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('rewrites an encrypted ilike inside an or() string to cs', async () => { + 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.ilike.ada') + 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. @@ -524,14 +577,76 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(JSON.parse(orOperand(emitted, 'email.cs.')).c).toBeDefined() }) - it('rewrites an encrypted like inside an or() string to cs', async () => { + 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('email.like.ada') + await es + .from('users', users) + .select('id') + .or('nickname.not.in.(ada,grace)') - expect(supabase.callsFor('or')[0].args[0] as string).toMatch( - /^email\.cs\."/, + 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 @@ -591,13 +706,13 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(plain).not.toContain('"pt":["ada","grace"]') }) - it('rewrites an encrypted ilike in a structured or() to cs', async () => { + 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: 'ilike', value: 'ada' }]) + .or([{ column: 'email', op: 'contains', value: 'ada' }]) expect(supabase.callsFor('or')[0].args[0] as string).toMatch( /^email\.cs\."/, @@ -742,7 +857,7 @@ describe('encryptedSupabaseV3 wire encoding', () => { it('sends a full envelope as the not(cs) operand', async () => { const { es, supabase } = v3Instance() - await es.from('users', users).select('id').not('email', 'like', 'a@b') + await es.from('users', users).select('id').not('email', 'contains', 'a@b') const [not] = supabase.callsFor('not') expect(not.args[0]).toBe('email') @@ -750,14 +865,15 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(JSON.parse(not.args[2] as string).c).toBeDefined() }) - it('maps not(ilike) on an encrypted column to not(cs)', async () => { + it('leaves not(like) on an encrypted column as like — no silent cs rewrite', async () => { const { es, supabase } = v3Instance() - await es.from('users', users).select('id').not('email', 'ilike', 'a@b') + // `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') - const [not] = supabase.callsFor('not') - expect(not.args[1]).toBe('cs') - expect(JSON.parse(not.args[2] as string).c).toBeDefined() + expect(supabase.callsFor('not')[0].args[1]).toBe('like') }) it('leaves not(like) on a plaintext column as like with a plain operand', async () => { @@ -1269,3 +1385,83 @@ describe('v3 single() decrypt path', () => { 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-matrix.test.ts b/packages/stack/__tests__/supabase-v3-matrix.test.ts index 23823d3a8..f48caf097 100644 --- a/packages/stack/__tests__/supabase-v3-matrix.test.ts +++ b/packages/stack/__tests__/supabase-v3-matrix.test.ts @@ -152,21 +152,28 @@ describe('supabase v3 wire encoding, every domain', () => { expect(JSON.parse(gte.args[1] as string).c).toBeDefined() }) - it('accepts order()', async () => { + // `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 } = await q.select(`id, ${name}`).order(name) + const { error, status } = await q.select(`id, ${name}`).order(name) - expect(error).toBeNull() - expect(supabase.callsFor('order')[0].args[0]).toBe(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('rewrites like() to a cs containment filter', async () => { + it('emits contains() as a cs containment filter', async () => { const { q, supabase, name } = instanceFor(eqlType, spec) - await q.select(`id, ${name}`).like(name, firstSample(spec)) + await q.select(`id, ${name}`).contains(name, firstSample(spec)) const [filter] = supabase.callsFor('filter') expect(filter.args[0]).toBe(name) @@ -175,6 +182,14 @@ describe('supabase v3 wire encoding, every domain', () => { // 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) => { @@ -198,13 +213,13 @@ describe('supabase v3 wire encoding, every domain', () => { expect(error?.message).toContain('does not support orderAndRange') }) - it('rejects order() with the ordering capability message', async () => { + 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('does not support ordering') + 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 index 6549c964d..a6fc1c9f2 100644 --- a/packages/stack/__tests__/supabase-v3-pgrest-live.test.ts +++ b/packages/stack/__tests__/supabase-v3-pgrest-live.test.ts @@ -316,40 +316,62 @@ describeLiveSupabasePgrest('supabase v3 adapter over real PostgREST', () => { expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) }) - // `cs` → `@>` on the bloom filter. With the default `include_original: true` - // the pattern's bloom carries the WHOLE pattern as an extra token, so only an - // exact-value pattern matches. Asserts the DOCUMENTED semantics, not the - // intuitive ones. - it('resolves like() through cs containment for an exact-value pattern', async () => { + // `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') - .like('email', 'ada@example.com') + .contains('email', 'ada@example.com') expect(error).toBeNull() expect(data.map((r: { row_key: string }) => r.row_key)).toEqual(['ada']) }) - // A pattern LONGER than the tokenizer's 3-gram window contributes an + // A needle LONGER than the tokenizer's 3-gram window contributes an // `include_original` token no stored trigram can supply, so containment - // fails. (A pattern of exactly 3 characters is the degenerate case: its - // whole-value token IS a trigram, so `like('email','ada')` DOES match. The - // class doc's "only an exact-value pattern matches" is a simplification.) - it('does not match a longer substring like() pattern under include_original', async () => { + // 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') - .like('email', 'example') + .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').like('email', 'ada') + 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. @@ -362,6 +384,43 @@ describeLiveSupabasePgrest('supabase v3 adapter over real PostgREST', () => { 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 () => { diff --git a/packages/stack/__tests__/supabase-v3.test-d.ts b/packages/stack/__tests__/supabase-v3.test-d.ts index 51f9a211c..647743821 100644 --- a/packages/stack/__tests__/supabase-v3.test-d.ts +++ b/packages/stack/__tests__/supabase-v3.test-d.ts @@ -3,6 +3,7 @@ import { encryptedTable, type InferPlaintext, types } from '@/eql/v3' import { encryptedColumn, encryptedTable as v2EncryptedTable } from '@/schema' import { type EncryptedSupabaseResponse, + encryptedSupabase, encryptedSupabaseV3, type SupabaseClientLike, } from '@/supabase' @@ -14,6 +15,8 @@ const users = encryptedTable('users', { amount: types.IntegerOrd('amount'), createdAt: types.TimestampOrd('created_at'), active: types.Boolean('active'), + nickname: types.TextEq('nickname'), + bio: types.TextMatch('bio'), }) type UserRow = InferPlaintext @@ -55,17 +58,64 @@ describe('encryptedSupabaseV3 typed surface (with schemas)', () => { builder.is('active', true) }) - it('rejects order() on storage-only columns at the type level', async () => { + 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: no ORE index to order by + // @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 }, @@ -129,6 +179,34 @@ describe('encryptedSupabaseV3 untyped surface (no schemas)', () => { 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/src/supabase/helpers.ts b/packages/stack/src/supabase/helpers.ts index c651b186e..281f32851 100644 --- a/packages/stack/src/supabase/helpers.ts +++ b/packages/stack/src/supabase/helpers.ts @@ -191,6 +191,7 @@ export function mapFilterOpToQueryType(op: FilterOp): QueryTypeName { return 'equality' case 'like': case 'ilike': + case 'contains': return 'freeTextSearch' case 'gt': case 'gte': @@ -206,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[] = [] @@ -217,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 @@ -233,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 @@ -249,7 +270,8 @@ export function rebuildOrString( 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(',') as DbFilterString } diff --git a/packages/stack/src/supabase/index.ts b/packages/stack/src/supabase/index.ts index 2e7b4b8ca..28e6ae715 100644 --- a/packages/stack/src/supabase/index.ts +++ b/packages/stack/src/supabase/index.ts @@ -267,7 +267,9 @@ export async function encryptedSupabaseV3( export type { EncryptedQueryBuilder, + EncryptedQueryBuilderCore, EncryptedQueryBuilderV3, + EncryptedQueryBuilderV3Untyped, EncryptedSupabaseConfig, EncryptedSupabaseError, EncryptedSupabaseInstance, @@ -278,5 +280,6 @@ export type { SupabaseClientLike, TypedEncryptedSupabaseV3Instance, V3FilterableKeys, + V3FreeTextSearchableKeys, V3Schemas, } from './types' diff --git a/packages/stack/src/supabase/query-builder-v3.ts b/packages/stack/src/supabase/query-builder-v3.ts index af3cf6f60..e48cc536c 100644 --- a/packages/stack/src/supabase/query-builder-v3.ts +++ b/packages/stack/src/supabase/query-builder-v3.ts @@ -6,7 +6,11 @@ import type { EncryptedTable, EncryptedTableColumn, } from '@/schema' -import type { BuildableQueryColumn, ScalarQueryTerm } from '@/types' +import type { + BuildableQueryColumn, + QueryTypeName, + ScalarQueryTerm, +} from '@/types' import { logger } from '@/utils/logger' import { addJsonbCastsV3 } from './helpers' import { @@ -105,11 +109,17 @@ function assertNoPropertyDbNameCollision( * The full envelope satisfies the storage-domain CHECK by construction, and * the operators extract the term they need (`eq_term`/`ord_term`/ * `match_term`). - * - **`like`/`ilike`** — the v3 domains define no LIKE operator; free-text - * match is `@>` on the bloom filter, so encrypted pattern filters are emitted - * as PostgREST `cs`. Match is tokenized + downcased, so `like` and `ilike` - * behave identically, and `%` wildcards are NOT interpreted — they are - * tokenized like any other character. + * - **`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 @@ -203,25 +213,71 @@ export class EncryptedQueryBuilderV3Impl< } /** - * Ordering by an encrypted column relies on the domain's ORE index. A - * storage-only domain (`public.boolean`, `public.text`, …) has none, so - * PostgREST would sort by the raw ciphertext envelope and return a - * plausible-looking but meaningless row order. Reject it, mirroring the - * capability guard {@link encryptCollectedTerms} applies to filters — that - * guard is the only protection the untyped (no-`schemas`) surface has. + * 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. * - * A column absent from {@link v3Columns} is a plaintext passthrough. + * 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 - if (!column.getQueryCapabilities().orderAndRange) { + 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]: column "${column.getName()}" (${column.getEqlType()}) does not support ordering — declare the column with a domain that carries the orderAndRange capability`, + `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, ) - } } } @@ -323,38 +379,82 @@ export class EncryptedQueryBuilderV3Impl< ) } - /** Encrypted pattern filters go through the bloom-filter `@>` (`cs`). */ - protected override applyPatternFilter( + /** + * `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, - op: 'like' | 'ilike', value: unknown, wasEncrypted: boolean, ): SupabaseQueryBuilder { if (wasEncrypted) { return q.filter(column, 'cs', value) } - return super.applyPatternFilter(q, column, op, value, wasEncrypted) + return super.applyContainsFilter(q, column, value, wasEncrypted) } protected override notFilterOperator( op: FilterOp, wasEncrypted: boolean, ): string { - if (wasEncrypted && (op === 'like' || op === 'ilike')) { + if (wasEncrypted && op === 'contains') { return 'cs' } return op } /** - * Map `like`/`ilike` to `cs` on the conditions that were encrypted — the same - * bloom-filter containment rewrite {@link applyPatternFilter} performs for - * regular filters. Operator shaping stays here rather than in `toDbSpace` - * because it depends on `wasEncrypted`, which is only known after encryption. + * `.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. * - * Column names arrive already in DB-space (`toDbSpace`), so this no longer - * translates them. + * 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[], @@ -362,7 +462,7 @@ export class EncryptedQueryBuilderV3Impl< ): DbPendingOrCondition[] { return conditions.map((cond, j) => { const op = - encryptedIndexes.has(j) && (cond.op === 'like' || cond.op === 'ilike') + encryptedIndexes.has(j) && cond.op === 'contains' ? ('cs' as FilterOp) : cond.op return op === cond.op ? cond : { ...cond, op } diff --git a/packages/stack/src/supabase/query-builder.ts b/packages/stack/src/supabase/query-builder.ts index d77007016..334bfbf17 100644 --- a/packages/stack/src/supabase/query-builder.ts +++ b/packages/stack/src/supabase/query-builder.ts @@ -8,7 +8,11 @@ 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 { BuildableQueryColumn, ScalarQueryTerm } from '@/types' +import type { + BuildableQueryColumn, + QueryTypeName, + ScalarQueryTerm, +} from '@/types' import { logger } from '@/utils/logger' import { addJsonbCasts, @@ -228,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 @@ -615,7 +624,7 @@ export class EncryptedQueryBuilderImpl< value, column, table: this.schema, - queryType: mapFilterOpToQueryType(cond.op), + queryType: this.queryTypeForOrOp(cond.op), returnType: 'composite-literal', }) termMap.push({ source, orIndex: i, conditionIndex: j, inIndex }) @@ -649,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 }) @@ -974,6 +983,9 @@ export class EncryptedQueryBuilderImpl< case 'ilike': 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(column, value) break @@ -1130,6 +1142,21 @@ export class EncryptedQueryBuilderImpl< */ 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 @@ -1148,6 +1175,30 @@ export class EncryptedQueryBuilderImpl< : 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). diff --git a/packages/stack/src/supabase/types.ts b/packages/stack/src/supabase/types.ts index 1cfbd5d30..70ea544fc 100644 --- a/packages/stack/src/supabase/types.ts +++ b/packages/stack/src/supabase/types.ts @@ -50,11 +50,12 @@ export type EncryptedSupabaseV3Options< * and startup verification; undeclared tables behave exactly as with no * `schemas`. * - * ASYMMETRY: the `include_original: false` substring-`like` behaviour of a - * `text_search` column can only be honoured on a DECLARED column. A substring - * `like` against an UNDECLARED `text_search` column will not match, because - * the synthesized default `include_original: true` puts the whole pattern into - * the bloom filter as an extra token. + * 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 } @@ -94,20 +95,97 @@ export type V3FilterableKeys< > = Exclude, NonQueryableV3Keys
> /** - * The v3 builder type: the shared {@link EncryptedQueryBuilder} surface with - * filter methods narrowed to {@link V3FilterableKeys}. + * 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. */ -export type EncryptedQueryBuilderV3< +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, -> = EncryptedQueryBuilder & StringKeyOf> +> = 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, - ): EncryptedQueryBuilder + ): EncryptedQueryBuilderV3Untyped } /** Typed instance (with `schemas: S`): a declared table name resolves to the @@ -130,7 +208,7 @@ export interface TypedEncryptedSupabaseV3Instance { ): EncryptedQueryBuilderV3> from = Record>( table: string, - ): EncryptedQueryBuilder + ): EncryptedQueryBuilderV3Untyped } // --------------------------------------------------------------------------- @@ -162,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' @@ -182,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 } @@ -381,6 +465,7 @@ export interface 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 @@ -434,9 +519,23 @@ export type { /** Helper to extract string keys from T */ type StringKeyOf = Extract -export interface EncryptedQueryBuilder< - T extends Record = Record, - FK extends StringKeyOf = StringKeyOf, +/** + * 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 @@ -445,7 +544,7 @@ export interface EncryptedQueryBuilder< select( columns?: string, options?: { head?: boolean; count?: 'exact' | 'planned' | 'estimated' }, - ): EncryptedQueryBuilder + ): Self insert( data: Partial | Partial[], options?: { @@ -453,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?: { @@ -466,46 +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 - // `FK`, not `StringKeyOf`: ordering an encrypted column relies on its ORE - // index, which a storage-only domain lacks. `FK` defaults to `StringKeyOf`, - // so the v2 surface is unchanged; only the v3 typed instance narrows. - 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 @@ -513,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 }