Skip to content
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.
25 changes: 25 additions & 0 deletions .changeset/supabase-v2-table-diagnosis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@cipherstash/stack-supabase': patch
'@cipherstash/stack': minor
'stash': patch
---

Diagnose an EQL v2 table by name instead of crashing with a raw `TypeError`.

A v2 `EncryptedTable` is structurally identical to a v3 one — same `tableName`,
same `columnBuilders` — and only `buildColumnKeyMap()` tells them apart. Passing
one to `encryptedSupabase({ schemas })` therefore sailed past every check that
looked at shape and died deep inside verification as `builder.getEqlType is not
a function`, naming an internal method rather than the version mismatch that
caused it. Constructing the query builder directly failed the same way one layer
down, as `table.buildColumnKeyMap is not a function`.

Both paths now fail closed with the table named and the fix stated. The check
routes through `hasBuildColumnKeyMap`, the canonical v2/v3 discriminator, rather
than a second hand-written spelling of it.

`@cipherstash/stack` re-exports `hasBuildColumnKeyMap` from
`@cipherstash/stack/adapter-kit` so first-party adapters can make that routing
decision without reaching into internals. It is deliberately not on `./types`:
deciding which wire version a table targets is adapter plumbing, not end-user
API.
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()]),
),
}),
}
}
104 changes: 104 additions & 0 deletions packages/stack-supabase/__tests__/supabase-schema-builder.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
import {
encryptedColumn,
encryptedTable as v2EncryptedTable,
} from '@cipherstash/stack/schema'
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 +248,101 @@ 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/,
)
})

it('throws a diagnosis, not a raw TypeError, on a whole v2 table', () => {
// A v2 `EncryptedTable` is structurally identical to a v3 one at the table
// level — same `tableName`, same `columnBuilders`. The only discriminator
// is `buildColumnKeyMap()`, which the constructor calls UNGUARDED as its
// first statement, so a v2 table died with `table.buildColumnKeyMap is not
// a function` — naming an internal method, not the version mismatch that
// caused it.
//
// The column-level probe above cannot catch this: it runs later, and by
// then the constructor has already crashed.
const v2Table = v2EncryptedTable('users', {
email: encryptedColumn('email').equality(),
})

expect(() => new ColumnMap('users', v2Table as never, null)).toThrow(
/\[supabase v3\]: table "users" is an EQL v2 table/,
)
})
})

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)
}
})
})
27 changes: 27 additions & 0 deletions packages/stack-supabase/__tests__/supabase-v3-factory.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
import {
encryptedColumn,
encryptedTable as v2EncryptedTable,
} from '@cipherstash/stack/schema'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClientLike } from '../src/index.js'
import { encryptedSupabaseV3 } from '../src/index.js'
Expand Down Expand Up @@ -101,6 +105,29 @@ describe('encryptedSupabaseV3 factory', () => {
expect(encryptionMock).not.toHaveBeenCalled()
})

it('rejects a v2 table in schemas before introspection results are used', async () => {
// The realistic caller mistake this guards: migrating from v2 and passing
// the old `schemas` through. A v2 table has `tableName` and
// `columnBuilders` just like a v3 one, so it sails past the record-key
// check and dies deeper in — previously at `verify.ts`, as
// `builder.getEqlType is not a function`, which names an internal method
// rather than the version mismatch.
const users = v2EncryptedTable('users', {
email: encryptedColumn('email').equality(),
})

await expect(
encryptedSupabaseV3(fakeClient, {
databaseUrl: 'postgres://x',
schemas: { users } as never,
}),
).rejects.toThrow(
/\[supabase v3\]: schemas entry "users" is an EQL v2 table/,
)
// The client must never be built from a schema set we could not validate.
expect(encryptionMock).not.toHaveBeenCalled()
})

it('passes only non-empty tables to Encryption', async () => {
introspectMock.mockResolvedValue(
introspectionOf({
Expand Down
Loading