From 2fd49850e70731acc403ba27995dcf4d0f1a5abf Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 13 Jul 2026 13:11:57 +1000 Subject: [PATCH 1/3] fix(supabase): populate EncryptedSupabaseError.encryptionError (#626) The query builder's catch block hardcoded `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError` field was always empty and callers could only detect encryption failures indirectly (status/statusText or `.throwOnError()`). - Thread the wrapped `EncryptionError` through when the caught error is an `EncryptionFailedError`; leave it unset for plain PostgREST/API errors. - Tighten `EncryptionFailedError.encryptionError` from `unknown` to `EncryptionError` (all five throw sites already pass a `result.failure`). - v3 shares the base `execute()` catch; its `encryptionFailure` helper now synthesizes an `EncryptionError` for the contract-violation cases (bulk length mismatch, null envelope) that have no underlying failure object. - Add unit coverage for both dialects: populated on a genuine encryption failure, absent on a plain error. Closes #626 Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/supabase-encryption-error.md | 11 ++ .../supabase-encryption-error.test.ts | 122 ++++++++++++++++++ .../stack-supabase/src/query-builder-v3.ts | 10 +- packages/stack-supabase/src/query-builder.ts | 14 +- 4 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 .changeset/supabase-encryption-error.md create mode 100644 packages/stack-supabase/__tests__/supabase-encryption-error.test.ts diff --git a/.changeset/supabase-encryption-error.md b/.changeset/supabase-encryption-error.md new file mode 100644 index 00000000..d83143e0 --- /dev/null +++ b/.changeset/supabase-encryption-error.md @@ -0,0 +1,11 @@ +--- +'@cipherstash/stack-supabase': patch +--- + +Populate `EncryptedSupabaseError.encryptionError` on encryption failures (#626). +The query builder's catch block previously hardcoded `encryptionError: undefined`, +so the typed field was always empty and callers had to detect encryption failures +indirectly (via `status`/`statusText` or `.throwOnError()`). It now threads the +underlying `EncryptionError` through — for both the v2 and v3 dialects — when the +failure originates in an encrypt/decrypt step, and leaves it unset for plain +PostgREST/API errors. diff --git a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts new file mode 100644 index 00000000..204a285f --- /dev/null +++ b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts @@ -0,0 +1,122 @@ +import type { EncryptionClient } from '@cipherstash/stack/encryption' +import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +import { EncryptionErrorTypes } from '@cipherstash/stack/errors' +import { + encryptedColumn, + encryptedTable as encryptedTableV2, +} from '@cipherstash/stack/schema' +import { describe, expect, it } from 'vitest' +import { EncryptedQueryBuilderImpl } from '../src/query-builder' +import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { + createMockEncryptionClient, + createMockSupabase, +} from './helpers/supabase-mock' + +/** + * Regression coverage for #626: the query builder's catch block used to hardcode + * `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError` + * field was dead. These tests pin that a genuine encryption failure now threads its + * `EncryptionError` through, while a plain (non-encryption) throw leaves it unset — + * for both the v2 and the v3 dialect, which share the base `execute()` catch. + */ + +const usersV2 = encryptedTableV2('users', { + email: encryptedColumn('email').freeTextSearch().equality(), +}) + +const usersV3 = encryptedTable('users', { + email: types.TextEq('email'), +}) + +/** A chainable op that resolves to `{ failure }`, like a real failed operation. */ +function failingOperation(failure: { type: string; message: string }) { + const op = { + withLockContext: () => op, + audit: () => op, + then: ( + onfulfilled?: ((value: { failure: typeof failure }) => unknown) | null, + onrejected?: ((reason: unknown) => unknown) | null, + ) => Promise.resolve({ failure }).then(onfulfilled, onrejected), + } + return op +} + +describe('EncryptedSupabaseError.encryptionError (#626)', () => { + it('v2: threads the EncryptionError through on an encryption failure', async () => { + const failure = { + type: EncryptionErrorTypes.EncryptionError, + message: 'zerokms unreachable', + } + const encryptionClient = createMockEncryptionClient() as unknown as Record< + string, + unknown + > + encryptionClient['encryptModel'] = () => failingOperation(failure) + + const { client: supabase } = createMockSupabase() + const builder = new EncryptedQueryBuilderImpl( + 'users', + usersV2, + encryptionClient as unknown as EncryptionClient, + supabase, + ) + + const { data, error } = await builder.insert({ email: 'ada@example.com' }) + + expect(data).toBeNull() + expect(error).not.toBeNull() + expect(error?.encryptionError).toEqual(failure) + expect(error?.encryptionError?.type).toBe( + EncryptionErrorTypes.EncryptionError, + ) + }) + + it('v2: leaves encryptionError unset on a plain (non-encryption) error', async () => { + const encryptionClient = createMockEncryptionClient() + const { client: supabase } = createMockSupabase() + // Make the underlying supabase call throw a non-encryption error: an insert + // with no encrypted columns skips encryption and goes straight to the wire. + supabase.from = () => { + throw new Error('PostgREST is down') + } + + const builder = new EncryptedQueryBuilderImpl( + 'users', + usersV2, + encryptionClient, + supabase, + ) + + const { data, error } = await builder.insert({ id: 1 } as never) + + expect(data).toBeNull() + expect(error?.message).toContain('PostgREST is down') + expect(error?.encryptionError).toBeUndefined() + }) + + it('v3: threads the EncryptionError through on an encryption failure', async () => { + const failure = { + type: EncryptionErrorTypes.EncryptionError, + message: 'bad operand', + } + const encryptionClient = createMockEncryptionClient() as unknown as Record< + string, + unknown + > + encryptionClient['encryptModel'] = () => failingOperation(failure) + + const { client: supabase } = createMockSupabase() + const builder = new EncryptedQueryBuilderV3Impl( + 'users', + usersV3, + encryptionClient as unknown as EncryptionClient, + supabase, + ['id', 'email'], + ) + + const { error } = await builder.insert({ email: 'ada@example.com' }) + + expect(error?.encryptionError).toEqual(failure) + }) +}) diff --git a/packages/stack-supabase/src/query-builder-v3.ts b/packages/stack-supabase/src/query-builder-v3.ts index b1bb131d..00190cc3 100644 --- a/packages/stack-supabase/src/query-builder-v3.ts +++ b/packages/stack-supabase/src/query-builder-v3.ts @@ -5,6 +5,8 @@ import { } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import type { EncryptionError } from '@cipherstash/stack/errors' +import { EncryptionErrorTypes } from '@cipherstash/stack/errors' import type { ColumnSchema, EncryptedTable, @@ -417,13 +419,17 @@ export class EncryptedQueryBuilderV3Impl< return column } - private encryptionFailure(message: string, cause?: unknown): never { + private encryptionFailure(message: string, cause?: EncryptionError): never { logger.error( `Supabase: failed to encrypt query terms for table "${this.tableName}"`, ) + // Most callers pass the operation's own `EncryptionError`; the contract- + // violation cases (bulk length mismatch, null envelope) have none, so + // synthesize one — a broken query encryption is still an encryption failure, + // and callers branch on `error.encryptionError` regardless. throw new EncryptionFailedError( `Failed to encrypt query terms: ${message}`, - cause, + cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, ) } diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 29b80903..8633dd3b 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -6,6 +6,7 @@ import { modelToEncryptedPgComposites, } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' +import type { EncryptionError } from '@cipherstash/stack/errors' import type { LockContext } from '@cipherstash/stack/identity' import type { EncryptedTable, @@ -419,9 +420,16 @@ export class EncryptedQueryBuilderImpl< `Supabase encrypted query failed on table "${this.tableName}": ${message}`, ) + // A failure inside any of the encrypt/decrypt steps above is thrown as an + // `EncryptionFailedError` wrapping the operation's `EncryptionError`. Thread + // that through so callers can branch on `error.encryptionError`; a plain + // PostgREST/API error is not an `EncryptionFailedError` and leaves it unset. const error: EncryptedSupabaseError = { message, - encryptionError: undefined, + encryptionError: + err instanceof EncryptionFailedError + ? err.encryptionError + : undefined, } if (this.shouldThrowOnError) { @@ -1599,9 +1607,9 @@ type RawSupabaseResult = { } export class EncryptionFailedError extends Error { - public encryptionError: unknown + public encryptionError: EncryptionError - constructor(message: string, encryptionError: unknown) { + constructor(message: string, encryptionError: EncryptionError) { super(message) this.name = 'EncryptionFailedError' this.encryptionError = encryptionError From 0990df7c6935a217cb349de7d0e5d69634af58c8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 13 Jul 2026 13:26:12 +1000 Subject: [PATCH 2/3] test(supabase): cover the v3 synthesized-EncryptionError branch (#626 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address self-review findings on the #626 fix: - Test coverage: the v3 test routed through the shared base `encryptModel` path, so the v3-specific `encryptionFailure` synthesize branch this PR adds was unasserted (a regression to `cause` would not have failed any test). Replace it with a test that drives that branch directly via a bulk length-mismatch and asserts `error.encryptionError` is populated. Verified by mutation: reverting the branch turns the test red. - Comment: hedge the base catch-block comment — the wrapped value is the operation's `EncryptionError` OR, in v3, a synthesized one. - Style: collapse the two-line same-module `errors` import into the repo's combined `import { type X, Y }` idiom. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../supabase-encryption-error.test.ts | 44 ++++++++++++------- .../stack-supabase/src/query-builder-v3.ts | 6 ++- packages/stack-supabase/src/query-builder.ts | 5 ++- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts index 204a285f..adefdfb0 100644 --- a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts +++ b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts @@ -11,14 +11,18 @@ import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' import { createMockEncryptionClient, createMockSupabase, + fakeEnvelope, + operation, } from './helpers/supabase-mock' /** * Regression coverage for #626: the query builder's catch block used to hardcode * `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError` - * field was dead. These tests pin that a genuine encryption failure now threads its - * `EncryptionError` through, while a plain (non-encryption) throw leaves it unset — - * for both the v2 and the v3 dialect, which share the base `execute()` catch. + * field was dead. The v2 tests pin that a genuine encryption failure now threads its + * `EncryptionError` through the shared base `execute()` catch, while a plain + * (non-encryption) throw leaves it unset. The v3 test covers the dialect's own + * `encryptionFailure` path, which synthesizes an `EncryptionError` for a + * query-term contract violation (no operation failure to wrap). */ const usersV2 = encryptedTableV2('users', { @@ -95,28 +99,36 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { expect(error?.encryptionError).toBeUndefined() }) - it('v3: threads the EncryptionError through on an encryption failure', async () => { - const failure = { - type: EncryptionErrorTypes.EncryptionError, - message: 'bad operand', + it('v3: synthesizes an EncryptionError for a query-term contract violation', async () => { + // The v3 dialect's own `encryptionFailure` path has no operation failure to + // wrap for its contract-violation cases, so it synthesizes an EncryptionError. + // Drive it directly: a two-element `in` list whose bulkEncrypt returns one + // term trips the length-mismatch check. (The base `execute()` threading is + // already covered by the v2 test above; overriding `encryptModel` here would + // only re-run that shared path, not this v3-specific branch.) + const encryptionClient = createMockEncryptionClient() as unknown as { + bulkEncrypt: (...args: unknown[]) => unknown } - const encryptionClient = createMockEncryptionClient() as unknown as Record< - string, - unknown - > - encryptionClient['encryptModel'] = () => failingOperation(failure) + encryptionClient.bulkEncrypt = () => + operation([{ data: fakeEnvelope('ada', 'email') }]) const { client: supabase } = createMockSupabase() - const builder = new EncryptedQueryBuilderV3Impl( + const { error, status } = await new EncryptedQueryBuilderV3Impl( 'users', usersV3, encryptionClient as unknown as EncryptionClient, supabase, ['id', 'email'], ) + .select('id') + .in('email', ['ada@example.com', 'grace@example.com']) - const { error } = await builder.insert({ email: 'ada@example.com' }) - - expect(error?.encryptionError).toEqual(failure) + expect(status).toBe(500) + expect(error?.encryptionError?.type).toBe( + EncryptionErrorTypes.EncryptionError, + ) + expect(error?.encryptionError?.message).toMatch( + /1 term(s)? for 2 value(s)?/, + ) }) }) diff --git a/packages/stack-supabase/src/query-builder-v3.ts b/packages/stack-supabase/src/query-builder-v3.ts index 00190cc3..a84de692 100644 --- a/packages/stack-supabase/src/query-builder-v3.ts +++ b/packages/stack-supabase/src/query-builder-v3.ts @@ -5,8 +5,10 @@ import { } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { AnyV3Table } from '@cipherstash/stack/eql/v3' -import type { EncryptionError } from '@cipherstash/stack/errors' -import { EncryptionErrorTypes } from '@cipherstash/stack/errors' +import { + type EncryptionError, + EncryptionErrorTypes, +} from '@cipherstash/stack/errors' import type { ColumnSchema, EncryptedTable, diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 8633dd3b..57d0fc03 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -421,8 +421,9 @@ export class EncryptedQueryBuilderImpl< ) // A failure inside any of the encrypt/decrypt steps above is thrown as an - // `EncryptionFailedError` wrapping the operation's `EncryptionError`. Thread - // that through so callers can branch on `error.encryptionError`; a plain + // `EncryptionFailedError` wrapping the operation's `EncryptionError` (or, in + // the v3 dialect, a synthesized one for its contract-violation cases). + // Thread it through so callers can branch on `error.encryptionError`; a plain // PostgREST/API error is not an `EncryptionFailedError` and leaves it unset. const error: EncryptedSupabaseError = { message, From 663cfe923440cfd826e2a234ba97eb9469b26d76 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 13 Jul 2026 15:26:41 +1000 Subject: [PATCH 3/3] test(supabase): explicitly cover the null-envelope no-cause path (#634 review) Copilot flagged that the v3 synthesize branch had two no-cause call sites (bulk length mismatch and null envelope) but only the failure-with-cause case was covered. The length-mismatch path is now tested; add the null-envelope path too so both no-cause call sites explicitly pin `error.encryptionError`. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../supabase-encryption-error.test.ts | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts index adefdfb0..5154b8c1 100644 --- a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts +++ b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts @@ -20,9 +20,10 @@ import { * `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError` * field was dead. The v2 tests pin that a genuine encryption failure now threads its * `EncryptionError` through the shared base `execute()` catch, while a plain - * (non-encryption) throw leaves it unset. The v3 test covers the dialect's own - * `encryptionFailure` path, which synthesizes an `EncryptionError` for a - * query-term contract violation (no operation failure to wrap). + * (non-encryption) throw leaves it unset. The v3 tests cover the dialect's own + * `encryptionFailure` path, which synthesizes an `EncryptionError` for its two + * query-term contract-violation cases (length mismatch, null envelope) that have + * no operation failure to wrap. */ const usersV2 = encryptedTableV2('users', { @@ -131,4 +132,34 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { /1 term(s)? for 2 value(s)?/, ) }) + + it('v3: synthesizes an EncryptionError for a null-envelope contract violation', async () => { + // The other no-cause `encryptionFailure` path: a length-matched bulk response + // whose position 0 is a null envelope. Same synthesized-EncryptionError branch + // as the length-mismatch case above, reached from a different call site. + const encryptionClient = createMockEncryptionClient() as unknown as { + bulkEncrypt: (...args: unknown[]) => unknown + } + encryptionClient.bulkEncrypt = () => + operation([{ data: null }, { data: fakeEnvelope('grace', 'email') }]) + + const { client: supabase } = createMockSupabase() + const { error, status } = await new EncryptedQueryBuilderV3Impl( + 'users', + usersV3, + encryptionClient as unknown as EncryptionClient, + supabase, + ['id', 'email'], + ) + .select('id') + .in('email', ['ada@example.com', 'grace@example.com']) + + expect(status).toBe(500) + expect(error?.encryptionError?.type).toBe( + EncryptionErrorTypes.EncryptionError, + ) + expect(error?.encryptionError?.message).toMatch( + /null envelope at position 0/, + ) + }) })