diff --git a/.changeset/v3-supabase-needle-lockcontext-errors.md b/.changeset/v3-supabase-needle-lockcontext-errors.md new file mode 100644 index 000000000..f4f53c427 --- /dev/null +++ b/.changeset/v3-supabase-needle-lockcontext-errors.md @@ -0,0 +1,21 @@ +--- +'@cipherstash/stack-supabase': patch +'@cipherstash/stack': patch +--- + +Three correctness fixes surfaced while documenting the v3 surface: + +- **Supabase `matches()` now rejects a short free-text needle.** A needle + below the tokenizer's `token_length` blooms to zero tokens, so `bloom @> {}` + matched (and the caller decrypted) every row — a fail-open exposure. The + guard (`matchNeedleError`) was wired into the Drizzle adapter only; the + Supabase adapter now applies it at the same term-resolution choke point, so + both first-party surfaces reject identically. (Authoritative FFI-level backstop + for the `encryptQuery` paths tracked in cipherstash/protectjs-ffi#138.) +- **Supabase `.withLockContext()` accepts the plain `{ identityClaim }` form**, + not only a `LockContext` instance — matching the stack-level operations and + the documented identity-aware example (widened to `LockContextInput`). +- **`EncryptionErrorTypes` is now `as const`**, so the `StackError` union + actually discriminates: `switch (error.type)` narrows and `error.code` is + reachable on the relevant branches. Without it every `type` was `string` and + the documented exhaustive error handler did not compile. diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index b12d2554f..6ae8b4f31 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -203,6 +203,18 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(JSON.parse(gte.args[1] as string).c).toBeDefined() }) + it('rejects a short matches needle up front (fail-open guard, mirrors Drizzle)', async () => { + const { es } = v3Instance() + // A 1-2 char needle blooms to no tokens; `bf @> '{}'` matches every row, so + // the query would silently return (and decrypt) the whole table. + const { error, status } = await es + .from('users', users) + .select('id') + .matches('email', 'ab') + expect(status).toBe(500) + expect(error?.message).toMatch(/at least 3 characters|match every row/) + }) + it('emits encrypted matches as PostgREST cs (bloom-filter containment)', async () => { const { es, supabase } = v3Instance() diff --git a/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts b/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts index df1e0f90e..f6cf4e977 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts @@ -98,6 +98,18 @@ function firstSample(spec: DomainSpec): unknown { return sample } +/** A free-text needle that clears the tokenizer's token_length floor. The text + * catalog's `samples[0]` is the empty string (a stored-value edge case), which + * the matches() short-needle guard now correctly REJECTS — so the wire tests + * for matches()/like() need a real, tokenizable needle instead. */ +function matchNeedle(spec: DomainSpec): string { + const needle = spec.samples.find( + (s): s is string => typeof s === 'string' && [...s].length >= 3, + ) + expect(needle).toBeDefined() + return needle as string +} + 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. @@ -195,7 +207,7 @@ describe('supabase v3 wire encoding, every domain', () => { it('emits matches() as a cs containment filter', async () => { const { q, supabase, name } = instanceFor(eqlType, spec) - await q.select(`id, ${name}`).matches(name, firstSample(spec)) + await q.select(`id, ${name}`).matches(name, matchNeedle(spec)) const [filter] = supabase.callsFor('filter') expect(filter.args[0]).toBe(name) @@ -219,7 +231,7 @@ describe('supabase v3 wire encoding, every domain', () => { it('delegates like() to matches, emitting the same cs filter', async () => { const { q, supabase, name } = instanceFor(eqlType, spec) - await q.select(`id, ${name}`).like(name, firstSample(spec) as string) + await q.select(`id, ${name}`).like(name, matchNeedle(spec)) const [filter] = supabase.callsFor('filter') expect(filter.args[0]).toBe(name) diff --git a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts index 51239b448..2bd1a5724 100644 --- a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts +++ b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts @@ -431,3 +431,17 @@ describe('encrypted JSON keys and operands (#650)', () => { docsBuilder.contains('note', 'x') }) }) + +describe('withLockContext accepts the plain { identityClaim } form (not only LockContext)', () => { + it('accepts a plain identity-claim object — the canonical documented form', () => { + // The identity-aware example chains this exact shape; it must typecheck on + // the builder, not only on the stack-level operations. + mixedBuilder.withLockContext({ identityClaim: ['sub'] }) + mixedBuilder.withLockContext({ identityClaim: ['sub', 'org_id'] }) + }) + + it('rejects an object with an unknown key', () => { + // @ts-expect-error — `claim` is not `identityClaim` + mixedBuilder.withLockContext({ claim: ['sub'] }) + }) +}) diff --git a/packages/stack-supabase/src/query-builder-v3.ts b/packages/stack-supabase/src/query-builder-v3.ts index 781ae50b4..1b9b100d4 100644 --- a/packages/stack-supabase/src/query-builder-v3.ts +++ b/packages/stack-supabase/src/query-builder-v3.ts @@ -2,6 +2,7 @@ import { DATE_LIKE_CASTS, EncryptedV3Column, logger, + matchNeedleError, parseSelectorSegments, reconstructSelectorDocument, unsupportedLeafReason, @@ -494,6 +495,22 @@ export class EncryptedQueryBuilderV3Impl< ) } + // Free-text (bloom) needle floor. A needle shorter than the tokenizer's + // token_length produces NO tokens, so `bf @> '{}'` holds for every row and + // the query would silently return (and the caller decrypt) the whole table + // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 + // adapter (matchNeedleError) so both first-party surfaces guard identically. + // JSON containment terms (searchableJson) are validated separately above. + if (queryType === 'freeTextSearch') { + const match = column.build().indexes?.match + const reason = match ? matchNeedleError(term.value, match) : undefined + if (reason) { + throw new Error( + `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, + ) + } + } + return column } diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 715877787..1914a3df4 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -7,7 +7,7 @@ import { } 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 { LockContextInput } from '@cipherstash/stack/identity' import type { EncryptedTable, EncryptedTableColumn, @@ -94,7 +94,7 @@ export class EncryptedQueryBuilderImpl< protected shouldThrowOnError = false // Encryption-specific state - protected lockContext: LockContext | null = null + protected lockContext: LockContextInput | null = null protected auditConfig: AuditConfig | null = null constructor( @@ -370,7 +370,7 @@ export class EncryptedQueryBuilderImpl< // Encryption-specific methods // --------------------------------------------------------------------------- - withLockContext(lockContext: LockContext): this { + withLockContext(lockContext: LockContextInput): this { this.lockContext = lockContext return this } diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index c29165cd8..d8af397a2 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -7,7 +7,7 @@ import type { QueryTypesForColumn, } from '@cipherstash/stack/eql/v3' import type { EncryptionError } from '@cipherstash/stack/errors' -import type { LockContext } from '@cipherstash/stack/identity' +import type { LockContext, LockContextInput } from '@cipherstash/stack/identity' import type { EncryptedTable, EncryptedTableColumn, @@ -792,7 +792,10 @@ export interface SupabaseClientLike { export type { AuditConfig } from '@cipherstash/stack/adapter-kit' export type { EncryptionClient } from '@cipherstash/stack/encryption' -export type { LockContext } from '@cipherstash/stack/identity' +export type { + LockContext, + LockContextInput, +} from '@cipherstash/stack/identity' export type { EncryptedColumn, EncryptedTable, @@ -927,7 +930,9 @@ export interface EncryptedQueryBuilderCore< throwOnError(): Self /** Escape hatch: re-types the rows and drops back to the v2 builder surface. */ returns>(): EncryptedQueryBuilder - withLockContext(lockContext: LockContext): Self + /** Bind identity-aware encryption. Accepts either a plain + * `{ identityClaim }` (the common form) or a `LockContext` instance. */ + withLockContext(lockContext: LockContextInput): Self audit(config: AuditConfig): Self } diff --git a/packages/stack/__tests__/error-discriminated-union.test-d.ts b/packages/stack/__tests__/error-discriminated-union.test-d.ts new file mode 100644 index 000000000..7bb162986 --- /dev/null +++ b/packages/stack/__tests__/error-discriminated-union.test-d.ts @@ -0,0 +1,52 @@ +import { + type ClientInitError, + type EncryptionError, + EncryptionErrorTypes, + type StackError, +} from '@cipherstash/stack/errors' +import { describe, expectTypeOf, it } from 'vitest' + +/** + * Regression guard for the `as const` on EncryptionErrorTypes: without it every + * member's value widens to `string`, so the StackError `type` fields stop + * discriminating and the documented exhaustive `switch (error.type)` — including + * `error.code` access on the narrowed branch — fails to compile. This suite is + * that documented pattern; if `as const` is dropped it goes red. + */ +describe('StackError discriminated union (errors as const)', () => { + it('literal member types, not string', () => { + expectTypeOf( + EncryptionErrorTypes.EncryptionError, + ).toEqualTypeOf<'EncryptionError'>() + expectTypeOf().toEqualTypeOf< + | 'ClientInitError' + | 'EncryptionError' + | 'DecryptionError' + | 'LockContextError' + | 'CtsTokenError' + >() + }) + + it('narrows on `type` and exhausts', () => { + const handle = (error: StackError): string => { + switch (error.type) { + case EncryptionErrorTypes.ClientInitError: { + expectTypeOf(error).toEqualTypeOf() + return error.message + } + case EncryptionErrorTypes.EncryptionError: + case EncryptionErrorTypes.DecryptionError: + // `code` exists only on these branches — proves narrowing works. + return error.code ?? error.message + case EncryptionErrorTypes.LockContextError: + case EncryptionErrorTypes.CtsTokenError: + return error.message + default: { + const _exhaustive: never = error + return _exhaustive + } + } + } + expectTypeOf(handle).toBeFunction() + }) +}) diff --git a/packages/stack/src/errors/index.ts b/packages/stack/src/errors/index.ts index c3bd280b1..b046d0a46 100644 --- a/packages/stack/src/errors/index.ts +++ b/packages/stack/src/errors/index.ts @@ -1,12 +1,16 @@ import type { ProtectErrorCode } from '@cipherstash/protect-ffi' +// `as const` is load-bearing: without it every member's type widens to `string`, +// so the `type` fields on the StackError union members below all become `string` +// and the union stops discriminating — `switch (error.type)` cannot narrow, and +// the documented exhaustive error-handling pattern fails to compile. export const EncryptionErrorTypes = { ClientInitError: 'ClientInitError', EncryptionError: 'EncryptionError', DecryptionError: 'DecryptionError', LockContextError: 'LockContextError', CtsTokenError: 'CtsTokenError', -} +} as const /** * Base error interface returned by all encryption operations. diff --git a/packages/stack/tsconfig.json b/packages/stack/tsconfig.json index d0a2140d5..ddc4ca84c 100644 --- a/packages/stack/tsconfig.json +++ b/packages/stack/tsconfig.json @@ -52,7 +52,11 @@ "@cipherstash/stack": ["./src/index.ts"], "@cipherstash/stack/eql/v3": ["./src/eql/v3/index.ts"], "@cipherstash/stack/schema": ["./src/schema/index.ts"], - "@cipherstash/stack/v3": ["./src/encryption/v3.ts"] + "@cipherstash/stack/v3": ["./src/encryption/v3.ts"], + // Map to source so `.test-d.ts` files typecheck against current source, + // not a possibly-stale built `dist/errors` (turbo's `test:types` does not + // depend on `build`, so CI can run type tests against an older dist). + "@cipherstash/stack/errors": ["./src/errors/index.ts"] } } }