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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/stack-logger-edge-safe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cipherstash/stack': patch
---

`@cipherstash/stack/adapter-kit` is now importable in a runtime with no `process` global.

The shared logger read `process.env.STASH_STACK_LOG` unguarded while initialising at module scope, so importing adapter-kit — which re-exports that logger — threw `ReferenceError: process is not defined` before any user code ran. The environment read is now guarded.

This is not a single-adapter fix. `@cipherstash/stack-supabase`, `@cipherstash/stack-drizzle` and `@cipherstash/prisma-next` all value-import `@cipherstash/stack/adapter-kit`, so edge users of all three hit the same import-time throw, and all three are fixed by this release.

**No behaviour change on Node.** `STASH_STACK_LOG`, its accepted values (`debug` / `info` / `error`), its `error` default, and the point at which the logger is configured are all unchanged.
11 changes: 11 additions & 0 deletions .changeset/supabase-structural-v3-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cipherstash/stack-supabase': patch
---

Fix: a table authored with `encryptedTable`/`types` imported from `@cipherstash/stack/wasm-inline` was treated as having **no encrypted columns**, so filter operands were sent to PostgREST as plaintext.

`ColumnMap` gated on `builder instanceof EncryptedV3Column`, and the published bundles contain two separately-emitted copies of that class (`dist/adapter-kit.js` and `dist/wasm-inline.js` are separate esbuild runs). The check is now structural, so both copies are recognised. Tables authored from `@cipherstash/stack/eql/v3` were never affected — they resolve to the same copy the adapter imports.

The failure was silent: `::jsonb` casts and result decryption go through a different path and kept working.

The recognition now also fails closed: a column builder that does not present the v3 surface makes `encryptedSupabase` throw at construction rather than silently omitting the column — an omitted column would send its filter operands to PostgREST as plaintext.
133 changes: 133 additions & 0 deletions packages/stack-supabase/__tests__/column-map-predicate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { encryptedColumn } from '@cipherstash/stack/schema'
import { describe, expect, it } from 'vitest'
import { isV3ColumnLike } from '../src/column-map'

/**
* Unit coverage for the structural v3 gate.
*
* `ColumnMap`'s tests pin the CONSEQUENCE — construction refuses, and no query
* string reaches PostgREST. They do not pin the predicate's shape: every fixture
* that reaches it either satisfies all four probes or (the one negative case)
* misses two at once, so no test there distinguishes a four-probe gate from a
* three- or two-probe one. Deleting any single probe, or weakening any single
* `typeof` to `true`, left the whole package suite green.
*
* These cases are written to be one-probe-apart from a passing builder, so each
* fails if and only if its own probe is removed.
*/

type Probe = 'getName' | 'getEqlType' | 'getQueryCapabilities' | 'build'

const PROBES: Probe[] = [
'getName',
'getEqlType',
'getQueryCapabilities',
'build',
]

/** A builder presenting exactly the surface the predicate probes for. */
const conforming = (): Record<Probe, unknown> => ({
getName: () => 'email',
getEqlType: () => 'public.eql_v3_text_search',
getQueryCapabilities: () => ({
equality: true,
orderAndRange: false,
freeTextSearch: true,
}),
build: () => ({}),
})

const withoutProbe = (probe: Probe): Record<string, unknown> => {
const builder = conforming()
delete builder[probe]
return builder
}

const withNonFunctionProbe = (probe: Probe): Record<string, unknown> => ({
...conforming(),
[probe]: 'not a function',
})

describe('isV3ColumnLike', () => {
it('accepts a builder presenting all four members', () => {
expect(isV3ColumnLike(conforming())).toBe(true)
})

// The mutation-killing core. Each fixture is one member away from the
// conforming builder above, so it can only be rejected by that member's own
// probe. A fixture missing two members (the shape the ColumnMap tests use)
// would be rejected by either, and so kills neither.
it.each(PROBES)('rejects a builder missing only %s()', (probe) => {
expect(isV3ColumnLike(withoutProbe(probe))).toBe(false)
})

// `'x' in builder` alone is satisfied by any present key. These pin the
// `typeof … === 'function'` half of each conjunct, which the `in` half cannot
// stand in for.
it.each(PROBES)('rejects a builder whose %s is not a function', (probe) => {
expect(isV3ColumnLike(withNonFunctionProbe(probe))).toBe(false)
})

// `typeof null === 'object'`, so `null` slips past the first half of the
// guard and only the explicit `builder === null` half rejects it. That half
// is load-bearing on its own: without it `'getName' in null` throws a raw
// TypeError instead of returning false, and ColumnMap's fail-closed error is
// replaced by an unrelated crash.
it('rejects null', () => {
expect(isV3ColumnLike(null)).toBe(false)
})

// These four go the other way — each is rejected by the `typeof builder !==
// 'object'` half. Split from `null` above so a mutant that drops only one
// half of the guard names which half it dropped.
it.each([
['undefined', undefined],
['a number', 42],
['a string', 'email'],
['a boolean', true],
])('rejects %s', (_label, builder) => {
expect(isV3ColumnLike(builder)).toBe(false)
})

// The real `Encrypted*Column` classes carry all four methods on the
// PROTOTYPE — their own properties are just `columnName`/`definition`. So the
// probes must use `in`, which walks the chain, and must not become
// `Object.hasOwn`, which does not.
it('accepts a builder whose members live on the prototype', () => {
class PrototypeColumn {
getName() {
return 'email'
}
getEqlType() {
return 'public.eql_v3_text_search'
}
getQueryCapabilities() {
return { equality: true, orderAndRange: false, freeTextSearch: true }
}
build() {
return {}
}
}

const builder = new PrototypeColumn()

expect(Object.hasOwn(builder, 'getName')).toBe(false)
expect(isV3ColumnLike(builder)).toBe(true)
})

// The case the four-probe design exists for, stated with the real class
// rather than a hand-rolled stub: a v2 column builder genuinely has `build()`
// and `getName()` and genuinely lacks the other two. A two-probe gate would
// accept it and its filter operands would go to PostgREST in the clear.
it('rejects a real EQL v2 column builder', () => {
const v2 = encryptedColumn('email').equality()

// Spelled out so that if `EncryptedColumn` ever grows one of these, the
// failure names the cause rather than just reporting `true !== false`.
expect(typeof v2.getName).toBe('function')
expect(typeof v2.build).toBe('function')
expect('getEqlType' in v2).toBe(false)
expect('getQueryCapabilities' in v2).toBe(false)
expect(isV3ColumnLike(v2)).toBe(false)
})
})
40 changes: 40 additions & 0 deletions packages/stack-supabase/__tests__/helpers/supabase-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,43 @@ export function createMockSupabase(resultData: unknown = []) {

return { client, calls, callsFor }
}

/**
* A table whose column builders are structurally EQL v3 but are NOT instances
* of the `EncryptedV3Column` this package imports — which is exactly how a
* table authored from `@cipherstash/stack/wasm-inline` presents, because tsup
* emits that class twice (see `isV3ColumnLike` in `src/column-map.ts`).
*
* Object literals, not the real classes: reproducing the split with the real
* ones needs a built `dist/`, and `vitest.shared.ts:4-14` keeps `pnpm test`
* free of that. The dist-level version lives in the portable-entry plan.
*/
export function wasmAuthoredV3Table(tableName: string, columnNames: string[]) {
const columnBuilders = Object.fromEntries(
columnNames.map((name) => [
name,
{
getName: () => name,
getEqlType: () => 'public.eql_v3_text_eq',
getQueryCapabilities: () => ({
equality: true,
orderAndRange: false,
freeTextSearch: false,
}),
build: () => ({ cast_as: 'text', indexes: {} }),
},
]),
)
return {
tableName,
columnBuilders,
buildColumnKeyMap: () =>
Object.fromEntries(columnNames.map((name) => [name, name])),
build: () => ({
tableName,
columns: Object.fromEntries(
columnNames.map((name) => [name, columnBuilders[name].build()]),
),
}),
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
import { describe, expect, it } from 'vitest'
import { ColumnMap } from '../src/column-map'
import type { IntrospectionResult } from '../src/introspect'
import { groupUnmodelledRows } from '../src/introspect'
import { mergeDeclaredTables, synthesizeTables } from '../src/schema-builder'
import { wasmAuthoredV3Table } from './helpers/supabase-mock'

const introspection: IntrospectionResult = [
{
Expand Down Expand Up @@ -242,3 +244,82 @@ describe('groupUnmodelledRows', () => {
expect(groupUnmodelledRows([]).size).toBe(0)
})
})

describe('ColumnMap recognises v3 columns structurally, not by class identity', () => {
// tsup emits `EncryptedV3Column` TWICE — once into the chunk
// `dist/adapter-kit.js` imports, once inline in `dist/wasm-inline.js` (a
// separate esbuild run). A table authored from `@cipherstash/stack/wasm-inline`
// therefore failed `builder instanceof EncryptedV3Column` for EVERY column,
// leaving `v3Columns` empty — so the filter collector skipped every term and
// the RAW PLAINTEXT operand went into the PostgREST query string, while
// `::jsonb` casts and decryption kept working.
//
// These two assert the MECHANISM (`v3Columns` is populated / not
// over-populated). The HARM — what PostgREST actually receives — is asserted
// in `supabase-v3-wire.test.ts` by Step 2, because a check that merely
// probed `getName` would satisfy the two below.
it('accepts a builder that merely has the v3 column surface', () => {
const table = wasmAuthoredV3Table('users', ['email'])

const columns = new ColumnMap('users', table as never, null)

expect(columns.isEncryptedV3Column('email')).toBe(true)
expect(columns.encryptedColumnNames).toContain('email')
})

it('throws on a builder missing the v3 column surface', () => {
// v2 columns have `build()` and `getName()` (`EncryptedColumn`,
// `packages/stack/src/schema/index.ts:442,449`) but neither `getEqlType()`
// nor `getQueryCapabilities()` (`eql/v3/columns.ts:445,450`). Four probes,
// not two, is what keeps the predicate honest.
//
// `columnBuilders` on an `AnyV3Table` must hold ONLY encrypted v3 columns,
// so a builder that fails the probe is malformed input. Silently skipping it
// is not a safe default: the column would drop out of `v3Columns` and its
// filter operands would go to PostgREST as PLAINTEXT. Fail closed at
// construction instead.
const v2 = { getName: () => 'email', build: () => ({}) }
const table = {
tableName: 'users',
columnBuilders: { email: v2 },
buildColumnKeyMap: () => ({ email: 'email' }),
build: () => ({ tableName: 'users', columns: {} }),
}

// Pin the SPECIFIC message, not just the `[supabase v3]` prefix: 32 errors
// across this package share that prefix, two of them thrown by `ColumnMap`
// itself. A prefix-only matcher stays green whenever a DIFFERENT one of
// those fires first — measured: with `assertNoPropertyDbNameCollision`
// throwing unconditionally, so the fail-closed probe below is never
// reached, this test still passed. It could not tell which error it caught.
// (Deleting the probe outright does turn it red — construction then
// succeeds and nothing throws. It is the bypass, not the deletion, that the
// loose matcher was blind to.)
expect(() => new ColumnMap('users', table as never, null)).toThrow(
/\[supabase v3\]: column "email" on table "users" is not a recognised EQL v3 column builder/,
)
})
})

describe('every types.* domain satisfies the structural v3 probe', () => {
// Stated through the public consequence rather than against the predicate:
// whatever the catalog grows to, ColumnMap must see the column as encrypted.
// A domain whose builder lost one of the four methods would be silently
// treated as PLAINTEXT — the PF2 failure again, from a different direction.
// Enumerated, not hardcoded (40 domains today), so a new one is covered the
// day it is added. The predicate's own shape is pinned separately, one probe
// at a time, in `column-map-predicate.test.ts`.
it('recognises a column built by any factory in the catalog', () => {
// Deterministic iteration over the WHOLE catalog, not a probabilistic
// sample: the guarantee this pins is "every domain", so every domain must
// actually run. `fc.constantFrom` would leave that to chance across its
// default run count.
for (const domain of Object.keys(types) as (keyof typeof types)[]) {
const table = encryptedTable('t', { c: types[domain]('c') })

const columns = new ColumnMap('t', table as never, null)

expect(columns.isEncryptedV3Column('c')).toBe(true)
}
})
})
73 changes: 72 additions & 1 deletion packages/stack-supabase/__tests__/supabase-v3-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
import { describe, expect, it } from 'vitest'
import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder'
import { createWirePostgrest } from './helpers/postgrest-wire'
import { createMockEncryptionClient } from './helpers/supabase-mock'
import {
createMockEncryptionClient,
wasmAuthoredV3Table,
} from './helpers/supabase-mock'

const users = encryptedTable('users', {
email: types.TextSearch('email'),
Expand Down Expand Up @@ -215,3 +218,71 @@ describe('plaintext not(col, contains, …) emits a parseable containment litera
expect(wire.operandFor('note')).toBe('not.cs.{vip}')
})
})

describe('a structurally-v3 table still encrypts the filter operand', () => {
// The regression this plan exists to stop, at the layer where it hurt: with
// the `instanceof` gate, a table that is structurally v3 but not an instance
// of THIS package's copy of `EncryptedV3Column` sent `eq.<plaintext>` to
// PostgREST.
it('emits an envelope, not the bare plaintext', async () => {
const wire = createWirePostgrest([])
const builder = new EncryptedQueryBuilderV3Impl(
'users',
wasmAuthoredV3Table('users', ['email']) as never,
createMockEncryptionClient(),
wire.client,
['id', 'email'],
)

await builder.select('id').eq('email', 'ada@example.com')

const operand = wire.operandFor('email')
expect(operand.startsWith('eq.')).toBe(true)
const value = operand.slice('eq.'.length)

// NOT `expect(operand).not.toContain('ada@example.com')`: the encryption
// double deliberately carries the plaintext in the envelope's `pt` field so
// its fake decrypt can undo it (`helpers/supabase-mock.ts`). The contract
// being pinned is that the operand is an ENVELOPE rather than the raw
// value — unfixed, `value` is literally `ada@example.com`.
expect(value).not.toBe('ada@example.com')
expect(JSON.parse(value)).toMatchObject({ c: 'ct:ada@example.com' })
})
})

describe('an unrecognised column builder fails closed', () => {
// The mirror image of the leak above: a builder that does NOT present the v3
// surface must never be silently demoted to plaintext passthrough, because
// then its filter operands would reach PostgREST in the clear. `ColumnMap` is
// built eagerly in the query-builder constructor, so construction throws
// BEFORE any request — this pins that no query string is ever emitted.
it('throws at construction, so no PostgREST request is issued', () => {
const wire = createWirePostgrest([])
const malformed = {
tableName: 'users',
columnBuilders: {
email: { getName: () => 'email', build: () => ({}) },
},
buildColumnKeyMap: () => ({ email: 'email' }),
build: () => ({ tableName: 'users', columns: {} }),
}

expect(
() =>
new EncryptedQueryBuilderV3Impl(
'users',
malformed as never,
createMockEncryptionClient(),
wire.client,
['id', 'email'],
),
// Prefix-only would not discriminate — see the matching note in
// `supabase-schema-builder.test.ts`. This test guards the HARM, so it is
// the one that most needs to fail if the fail-closed probe stops firing
// and some other `[supabase v3]` error surfaces in its place.
).toThrow(
/\[supabase v3\]: column "email" on table "users" is not a recognised EQL v3 column builder/,
)
expect(wire.urls).toEqual([])
})
})
Loading