Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/v3-supabase-needle-lockcontext-errors.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/stack-supabase/__tests__/supabase-v3-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
16 changes: 14 additions & 2 deletions packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions packages/stack-supabase/__tests__/supabase-v3.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] })
})
})
17 changes: 17 additions & 0 deletions packages/stack-supabase/src/query-builder-v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
DATE_LIKE_CASTS,
EncryptedV3Column,
logger,
matchNeedleError,
parseSelectorSegments,
reconstructSelectorDocument,
unsupportedLeafReason,
Expand Down Expand Up @@ -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
}

Expand Down
6 changes: 3 additions & 3 deletions packages/stack-supabase/src/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -370,7 +370,7 @@ export class EncryptedQueryBuilderImpl<
// Encryption-specific methods
// ---------------------------------------------------------------------------

withLockContext(lockContext: LockContext): this {
withLockContext(lockContext: LockContextInput): this {
this.lockContext = lockContext
return this
}
Expand Down
11 changes: 8 additions & 3 deletions packages/stack-supabase/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -927,7 +930,9 @@ export interface EncryptedQueryBuilderCore<
throwOnError(): Self
/** Escape hatch: re-types the rows and drops back to the v2 builder surface. */
returns<U extends Record<string, unknown>>(): EncryptedQueryBuilder<U>
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
}

Expand Down
52 changes: 52 additions & 0 deletions packages/stack/__tests__/error-discriminated-union.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<EncryptionError['type']>().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<ClientInitError>()
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()
})
})
6 changes: 5 additions & 1 deletion packages/stack/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
6 changes: 5 additions & 1 deletion packages/stack/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}
Loading