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.
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,72 @@ 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()` (`packages/stack/src/schema/
// index.ts:257,264`) 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: {} }),
}

expect(() => new ColumnMap('users', table as never, null)).toThrow(
/\[supabase v3\]/,
)
})
})

describe('every types.* domain satisfies the structural v3 probe', () => {
// `isV3ColumnLike` is module-private, so the property is stated through the
// public consequence: 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.
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)
}
})
})
67 changes: 66 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,65 @@ 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'],
),
).toThrow(/\[supabase v3\]/)
expect(wire.urls).toEqual([])
})
})
73 changes: 65 additions & 8 deletions packages/stack-supabase/src/column-map.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { EncryptedV3Column } from '@cipherstash/stack/adapter-kit'
import type { AnyV3Table } from '@cipherstash/stack/eql/v3'
import type { ColumnSchema } from '@cipherstash/stack/schema'
import type { BuildableQueryColumn } from '@cipherstash/stack/types'
import type { DbName } from './types'

/**
* The subset of a v3 column builder the dialect relies on. Structural rather
* than the concrete class union so the runtime `instanceof EncryptedV3Column`
* gate and this type stay independent.
* The subset of a v3 column builder the dialect relies on.
*
* This is BOTH the type and the runtime gate: `isV3ColumnLike` below probes
* exactly these members. It must not become an `instanceof` check — see that
* function's comment.
*/
export type V3ColumnLike = {
getName(): string
Expand All @@ -22,6 +23,45 @@ export type V3ColumnLike = {
build(): ColumnSchema
}

/**
* Whether a column builder is an EQL v3 column, checked STRUCTURALLY.
*
* NOT `instanceof EncryptedV3Column`. tsup emits that class twice — once into
* the chunk `dist/adapter-kit.js` imports, and once inline in
* `dist/wasm-inline.js`, a separate esbuild run
* (`packages/stack/tsup.config.ts:43-52`). A table authored with
* `encryptedTable`/`types` from `@cipherstash/stack/wasm-inline` is built from
* the second copy, so an `instanceof` against the first returned `false` for
* every column: `v3Columns` came out empty and the adapter treated encrypted
* columns as plaintext — filter operands reached PostgREST in the clear, while
* `::jsonb` casts and decryption kept working (they read `buildColumnKeyMap()`
* and the encrypt config, not this map).
*
* Mirrors `hasBuildColumnKeyMap` (`packages/stack/src/types.ts:276-283`), the
* repo's canonical answer to the same problem, used identically at
* `wasm-inline.ts:1361` — including its spelling: `'k' in obj && typeof (obj as
* { k?: unknown }).k === 'function'`, one narrowed probe per member, rather
* than one blanket `as Record<string, unknown>` over the whole object.
*
* Four probes, not two: a v2 column builder has `build()` and `getName()`
* (`packages/stack/src/schema/index.ts:257,264`) but neither `getEqlType()` nor
* `getQueryCapabilities()` (`eql/v3/columns.ts:445,450`).
*/
function isV3ColumnLike(builder: unknown): builder is V3ColumnLike {
if (typeof builder !== 'object' || builder === null) return false
return (
'getName' in builder &&
typeof (builder as { getName?: unknown }).getName === 'function' &&
'getEqlType' in builder &&
typeof (builder as { getEqlType?: unknown }).getEqlType === 'function' &&
'getQueryCapabilities' in builder &&
typeof (builder as { getQueryCapabilities?: unknown })
.getQueryCapabilities === 'function' &&
'build' in builder &&
typeof (builder as { build?: unknown }).build === 'function'
)
}

/**
* Reject a declared property name that is also a DIFFERENT physical column.
*
Expand Down Expand Up @@ -102,11 +142,22 @@ export class ColumnMap {
// otherwise resolve truthy for a plaintext column of that name.
this.v3Columns = Object.create(null) as Record<string, V3ColumnLike>
for (const [property, builder] of Object.entries(table.columnBuilders)) {
if (builder instanceof EncryptedV3Column) {
const col = builder as unknown as V3ColumnLike
this.v3Columns[property] = col
this.v3Columns[col.getName()] = col
// FAIL CLOSED. `columnBuilders` is typed `EncryptedV3TableColumn`
// (`eql/v3/table.ts:18-25`), so every entry is meant to be an encrypted v3
// column. A builder that fails the structural probe is malformed input —
// and silently skipping it is the one thing we must not do here: the
// column would drop out of `v3Columns`, `isEncryptedColumn()` would return
// false for it, and its filter operands would go to PostgREST as
// PLAINTEXT. Refuse to construct instead, mirroring `build()`'s
// fail-loudly-on-malformed stance (`eql/v3/table.ts:47-51`).
if (!isV3ColumnLike(builder)) {
throw new Error(
`[supabase v3]: column "${property}" on table "${tableName}" is not a recognised EQL v3 column builder. Its filter operands would otherwise be sent to PostgREST unencrypted, so construction is refused. Author the table with \`encryptedTable\`/\`types\` from \`@cipherstash/stack/eql/v3\` or \`@cipherstash/stack/wasm-inline\`.`,
)
}
const col = builder
this.v3Columns[property] = col
this.v3Columns[col.getName()] = col
}

this.encryptedColumnNames = Object.keys(this.v3Columns)
Expand Down Expand Up @@ -213,6 +264,12 @@ export class ColumnMap {

/** The encrypted builders as the term collector's column lookup. */
queryColumnMap(): Record<string, BuildableQueryColumn> {
// `V3ColumnLike` omits `isQueryable(): true`, which `BuildableV3QueryableColumn`
// requires — and `v3Columns` intentionally holds storage-only columns, for which
// `isQueryable()` is `false`. The collector consults `getQueryCapabilities()`
// before using an entry (`query-encrypt.ts:513`), so the widening is safe;
// narrowing the type would mean narrowing the map.
// biome-ignore lint/plugin: storage-only v3 columns lack `isQueryable(): true`; widening is safe (see above).
return this.v3Columns as unknown as Record<string, BuildableQueryColumn>
}
}
Loading