From 43b955a472ce63b5f2982a043fd9b3927289ca68 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Sun, 26 Jul 2026 10:19:06 +1000 Subject: [PATCH 1/7] fix(stack): make adapter-kit importable without a process global --- .changeset/stack-logger-edge-safe.md | 11 +++ .../__tests__/helpers/process-free-realm.mjs | 73 +++++++++++++++++++ .../__tests__/logger-edge-safety.test.ts | 47 ++++++++++++ packages/stack/src/utils/logger/index.ts | 7 +- packages/stack/turbo.json | 9 +++ 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 .changeset/stack-logger-edge-safe.md create mode 100644 packages/stack/__tests__/helpers/process-free-realm.mjs create mode 100644 packages/stack/__tests__/logger-edge-safety.test.ts create mode 100644 packages/stack/turbo.json diff --git a/.changeset/stack-logger-edge-safe.md b/.changeset/stack-logger-edge-safe.md new file mode 100644 index 000000000..c84c0d97e --- /dev/null +++ b/.changeset/stack-logger-edge-safe.md @@ -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. diff --git a/packages/stack/__tests__/helpers/process-free-realm.mjs b/packages/stack/__tests__/helpers/process-free-realm.mjs new file mode 100644 index 000000000..4ec23c2c8 --- /dev/null +++ b/packages/stack/__tests__/helpers/process-free-realm.mjs @@ -0,0 +1,73 @@ +/** + * Evaluate an emitted ESM file graph in a realm with NO `process` global — + * i.e. a Worker or a Deno isolate. + * + * Uses `node:vm`'s `SourceTextModule` with a linker that COMPILES each relative + * import into the same sandbox context, rather than `import()`ing it in the host + * realm (which would hand the module the host's `process` and prove nothing). + * Bare specifiers are rejected: the graphs this is pointed at have none, and a + * new one appearing is itself a finding. + * + * Run with `node --experimental-vm-modules`; callers spawn it that way, as a + * CHILD PROCESS, so no vitest configuration or flag is involved. + * + * Usage: node --experimental-vm-modules process-free-realm.mjs + * Exits 0 and prints `OK exports`, or exits 1 and prints the failure. + */ +import { readFileSync } from 'node:fs' +import { dirname, resolve as resolvePath } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import vm from 'node:vm' + +async function loadInProcessFreeRealm(entryPath) { + // Deliberately minimal: `console` for diagnostics and the handful of globals + // a Worker genuinely has. NO `process`, NO `require`, NO `Buffer`. + const context = vm.createContext({ + console, + TextEncoder, + TextDecoder, + URL, + WebAssembly, + atob, + btoa, + }) + const cache = new Map() + + const compile = (file) => { + const cached = cache.get(file) + if (cached) return cached + const mod = new vm.SourceTextModule(readFileSync(file, 'utf8'), { + context, + identifier: pathToFileURL(file).href, + initializeImportMeta(meta) { + meta.url = pathToFileURL(file).href + }, + }) + cache.set(file, mod) + return mod + } + + const link = (specifier, referencing) => { + if (!specifier.startsWith('.') && !specifier.startsWith('/')) { + throw new Error( + `unexpected bare specifier "${specifier}" in ${referencing.identifier} — this graph is supposed to be self-contained`, + ) + } + const base = dirname(fileURLToPath(referencing.identifier)) + return compile(resolvePath(base, specifier)) + } + + const entry = compile(entryPath) + await entry.link(link) + await entry.evaluate() + return entry.namespace +} + +const [, , entryPath] = process.argv +try { + const namespace = await loadInProcessFreeRealm(entryPath) + console.log(`OK ${Object.keys(namespace).length} exports`) +} catch (error) { + console.error(`${error.constructor.name}: ${error.message}`) + process.exit(1) +} diff --git a/packages/stack/__tests__/logger-edge-safety.test.ts b/packages/stack/__tests__/logger-edge-safety.test.ts new file mode 100644 index 000000000..a28ba0f2a --- /dev/null +++ b/packages/stack/__tests__/logger-edge-safety.test.ts @@ -0,0 +1,47 @@ +/** + * `@cipherstash/stack/adapter-kit` re-exports this package's `logger` + * (`src/adapter-kit.ts:60`), and three first-party adapters value-import + * adapter-kit: `packages/stack-supabase/src/column-map.ts:1`, + * `packages/stack-drizzle/src/column.ts:1`, + * `packages/prisma-next/src/exports/column-types.ts:19`. A realm with no + * `process` binding turns an unguarded module-scope `process.env` read in the + * logger into a `ReferenceError` at import time on exactly the runtimes those + * builds exist to serve. + * + * This asserts the fix rather than a proxy for it: delete the `typeof process` + * guard from `src/utils/logger/index.ts`, rebuild, and this test fails. + * + * It reads `dist/`, so it SKIPS when the package has not been built — run + * `pnpm --filter @cipherstash/stack build` first for it to mean anything. The + * portable-entry plan's `bundling-isolation.test.ts` spawns the same harness + * against the WASM entry. + */ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const execFileAsync = promisify(execFile) +const testsDir = fileURLToPath(new URL('.', import.meta.url)) +const harness = resolve(testsDir, 'helpers/process-free-realm.mjs') +const emittedEntry = resolve(testsDir, '../dist/adapter-kit.js') + +describe.skipIf(!existsSync(emittedEntry))( + 'the emitted adapter-kit seam imports without a process global', + () => { + it('evaluates dist/adapter-kit.js in a process-free realm', async () => { + // Rejects on a non-zero exit, so the harness's own failure line + // (`ReferenceError: process is not defined`) surfaces as the assertion + // failure. + const { stdout } = await execFileAsync(process.execPath, [ + '--experimental-vm-modules', + harness, + emittedEntry, + ]) + + expect(stdout.trim()).toMatch(/^OK \d+ exports$/) + }, 30_000) + }, +) diff --git a/packages/stack/src/utils/logger/index.ts b/packages/stack/src/utils/logger/index.ts index cf84ab9ad..8e6e8e611 100644 --- a/packages/stack/src/utils/logger/index.ts +++ b/packages/stack/src/utils/logger/index.ts @@ -14,7 +14,12 @@ export type LogLevel = 'debug' | 'info' | 'error' const validLevels: readonly LogLevel[] = ['debug', 'info', 'error'] as const function levelFromEnv(): LogLevel { - const env = process.env.STASH_STACK_LOG + // `process` is absent in a Worker or Deno isolate. This module is reachable + // from `@cipherstash/stack/adapter-kit` (`src/adapter-kit.ts:60`), which the + // Supabase, Drizzle and Prisma Next adapters all value-import — an unguarded + // read here is a ReferenceError at import time on those runtimes. + const env = + typeof process === 'undefined' ? undefined : process.env.STASH_STACK_LOG if (env && validLevels.includes(env as LogLevel)) return env as LogLevel return 'error' } diff --git a/packages/stack/turbo.json b/packages/stack/turbo.json new file mode 100644 index 000000000..5ff716600 --- /dev/null +++ b/packages/stack/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["build"] + } + } +} From 772281c42c83a796e9383c23a614b3f5f9b9e46d Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Sun, 26 Jul 2026 10:19:06 +1000 Subject: [PATCH 2/7] fix(stack-supabase): recognise v3 columns structurally, not by class identity --- .changeset/supabase-structural-v3-columns.md | 9 +++ .../__tests__/helpers/supabase-mock.ts | 40 +++++++++++ .../__tests__/supabase-schema-builder.test.ts | 66 +++++++++++++++++++ .../__tests__/supabase-v3-wire.test.ts | 36 +++++++++- packages/stack-supabase/src/column-map.ts | 58 ++++++++++++++-- 5 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 .changeset/supabase-structural-v3-columns.md diff --git a/.changeset/supabase-structural-v3-columns.md b/.changeset/supabase-structural-v3-columns.md new file mode 100644 index 000000000..3d50513a4 --- /dev/null +++ b/.changeset/supabase-structural-v3-columns.md @@ -0,0 +1,9 @@ +--- +'@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. diff --git a/packages/stack-supabase/__tests__/helpers/supabase-mock.ts b/packages/stack-supabase/__tests__/helpers/supabase-mock.ts index 26d8576a4..866a93283 100644 --- a/packages/stack-supabase/__tests__/helpers/supabase-mock.ts +++ b/packages/stack-supabase/__tests__/helpers/supabase-mock.ts @@ -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()]), + ), + }), + } +} diff --git a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts index caa0192b6..43f955048 100644 --- a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts @@ -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 = [ { @@ -242,3 +244,67 @@ 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('still rejects a v2 column builder', () => { + // 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. + const v2 = { getName: () => 'email', build: () => ({}) } + const table = { + tableName: 'users', + columnBuilders: { email: v2 }, + buildColumnKeyMap: () => ({ email: 'email' }), + build: () => ({ tableName: 'users', columns: {} }), + } + + const columns = new ColumnMap('users', table as never, null) + + expect(columns.isEncryptedV3Column('email')).toBe(false) + expect(columns.encryptedColumnNames).toEqual([]) + }) +}) + +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) + } + }) +}) diff --git a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts index 6b33b7c66..b7c483ed1 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts @@ -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'), @@ -215,3 +218,34 @@ 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.` 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' }) + }) +}) diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts index 8920d87d1..4c6b932a6 100644 --- a/packages/stack-supabase/src/column-map.ts +++ b/packages/stack-supabase/src/column-map.ts @@ -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 @@ -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. * @@ -102,8 +142,8 @@ 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 + if (isV3ColumnLike(builder)) { + const col = builder this.v3Columns[property] = col this.v3Columns[col.getName()] = col } @@ -213,6 +253,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> } } From b223badd86193c6b2ea23252bf376757e991f4c0 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Sun, 26 Jul 2026 11:40:28 +1000 Subject: [PATCH 3/7] fix(stack-supabase): fail closed on unrecognised v3 column builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #799. - ColumnMap now throws at construction when a builder in an AnyV3Table's columnBuilders fails the structural v3 probe, instead of silently omitting it. An omitted column dropped out of v3Columns and its filter operands went to PostgREST as plaintext — the exact leak this work closes, from the other direction. Regression tests prove construction throws and no PostgREST request is issued. - Harden the logger env guard against a process defined without env. --- .changeset/supabase-structural-v3-columns.md | 2 ++ .../__tests__/supabase-schema-builder.test.ts | 15 ++++++--- .../__tests__/supabase-v3-wire.test.ts | 31 +++++++++++++++++++ packages/stack-supabase/src/column-map.ts | 19 +++++++++--- packages/stack/src/utils/logger/index.ts | 8 +++-- 5 files changed, 64 insertions(+), 11 deletions(-) diff --git a/.changeset/supabase-structural-v3-columns.md b/.changeset/supabase-structural-v3-columns.md index 3d50513a4..27d017118 100644 --- a/.changeset/supabase-structural-v3-columns.md +++ b/.changeset/supabase-structural-v3-columns.md @@ -7,3 +7,5 @@ Fix: a table authored with `encryptedTable`/`types` imported from `@cipherstash/ `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. diff --git a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts index 43f955048..8d9ac2803 100644 --- a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts @@ -267,11 +267,17 @@ describe('ColumnMap recognises v3 columns structurally, not by class identity', expect(columns.encryptedColumnNames).toContain('email') }) - it('still rejects a v2 column builder', () => { + 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', @@ -280,10 +286,9 @@ describe('ColumnMap recognises v3 columns structurally, not by class identity', build: () => ({ tableName: 'users', columns: {} }), } - const columns = new ColumnMap('users', table as never, null) - - expect(columns.isEncryptedV3Column('email')).toBe(false) - expect(columns.encryptedColumnNames).toEqual([]) + expect(() => new ColumnMap('users', table as never, null)).toThrow( + /\[supabase v3\]/, + ) }) }) diff --git a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts index b7c483ed1..edd5fe6f3 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts @@ -249,3 +249,34 @@ describe('a structurally-v3 table still encrypts the filter operand', () => { 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([]) + }) +}) diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts index 4c6b932a6..81c85dd26 100644 --- a/packages/stack-supabase/src/column-map.ts +++ b/packages/stack-supabase/src/column-map.ts @@ -142,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 (isV3ColumnLike(builder)) { - const col = builder - 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) diff --git a/packages/stack/src/utils/logger/index.ts b/packages/stack/src/utils/logger/index.ts index 8e6e8e611..0d54ead7c 100644 --- a/packages/stack/src/utils/logger/index.ts +++ b/packages/stack/src/utils/logger/index.ts @@ -17,9 +17,13 @@ function levelFromEnv(): LogLevel { // `process` is absent in a Worker or Deno isolate. This module is reachable // from `@cipherstash/stack/adapter-kit` (`src/adapter-kit.ts:60`), which the // Supabase, Drizzle and Prisma Next adapters all value-import — an unguarded - // read here is a ReferenceError at import time on those runtimes. + // read here is a ReferenceError at import time on those runtimes. Guard + // `process.env` too: some partial polyfills define `process` without `env`, + // where `process.env.STASH_STACK_LOG` would throw just the same. const env = - typeof process === 'undefined' ? undefined : process.env.STASH_STACK_LOG + typeof process === 'undefined' || !process.env + ? undefined + : process.env.STASH_STACK_LOG if (env && validLevels.includes(env as LogLevel)) return env as LogLevel return 'error' } From 02b1734df56dab6d74a536fed2714248ba485ccc Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Mon, 27 Jul 2026 15:14:22 +1000 Subject: [PATCH 4/7] test(stack-supabase): pin the fail-closed message, not the [supabase v3] prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both assertions on the unrecognised-builder path matched `/\[supabase v3\]/`, which 32 messages across this package satisfy — two of them thrown by `ColumnMap` itself. Neither test could tell which error it caught. Demonstrated rather than assumed: making `assertNoPropertyDbNameCollision` throw unconditionally, so the fail-closed probe is bypassed entirely, left both tests GREEN. (Deleting the probe outright does turn them red — construction then succeeds and nothing throws. It is the bypass, not the deletion, the loose matcher was blind to.) `supabase-v3-wire.test.ts` is the more serious of the two: it guards the harm — no query string emitted — not just the mechanism. Both now pin the identity clause and both interpolations. Stops short of the advisory tail, which carries six unescaped `/` and would make the matcher a syntax error rather than a stricter test. Re-running the same bypass mutant now fails both. Also corrects a misattributed citation the assertion's comment repeats: the v2 `build()`/`getName()` at `schema/index.ts:257,264` are `EncryptedField`'s. `EncryptedColumn` opens at :275, so its own are at :442,449. --- .../__tests__/supabase-schema-builder.test.ts | 32 ++++++++++++------- .../__tests__/supabase-v3-wire.test.ts | 8 ++++- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts index 8d9ac2803..94f1a7d04 100644 --- a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts @@ -268,10 +268,10 @@ describe('ColumnMap recognises v3 columns structurally, not by class identity', }) 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. + // 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 @@ -286,19 +286,29 @@ describe('ColumnMap recognises v3 columns structurally, not by class identity', 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\]/, + /\[supabase v3\]: column "email" on table "users" is not a recognised EQL v3 column builder/, ) }) }) 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. + // 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 diff --git a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts index edd5fe6f3..3ffc94d92 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts @@ -276,7 +276,13 @@ describe('an unrecognised column builder fails closed', () => { wire.client, ['id', 'email'], ), - ).toThrow(/\[supabase v3\]/) + // 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([]) }) }) From d34a16b63b0b77d059107ef962f58d93b71d8985 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Mon, 27 Jul 2026 15:14:22 +1000 Subject: [PATCH 5/7] test(stack-supabase): unit-test the structural v3 probe, one member at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isV3ColumnLike` had no test that could distinguish a four-probe gate from a two-probe one. Every builder reaching it either satisfied all four probes (the 40 catalog domains, the wasm-authored double) or missed two at once (the v2 stub) — so no fixture was ever one member away from passing. Measured with a mutation harness: deleting any single conjunct, weakening any single `typeof` to `true`, or dropping the object/null guard entirely left the whole 471-test suite green. Nine surviving mutants of those enumerated. (The `'x' in builder` half of each conjunct is an equivalent mutant — for any non-exotic object the `typeof` half subsumes it — so those four are correctly left alive.) The new cases are each one member apart from a conforming builder, so each fails if and only if its own probe goes. All nine die, and precisely: deleting a conjunct fails exactly its two tests, weakening a `typeof` exactly its one, and the two halves of the object/null guard are split so a mutant that drops one half names which half it dropped (1 test vs 4). Also covered: prototype-borne members (the real `Encrypted*Column` classes carry all four on the prototype, so `in` must not become `Object.hasOwn`), and a real `encryptedColumn().equality()` v2 builder rather than a hand-rolled stub — the case the four-probe design exists for. Neither kills a mutant the others miss; both are kept because they turn a 335-test avalanche and an opaque `true !== false` into one precise, self-describing failure. `isV3ColumnLike` is exported to allow this. `column-map.ts` is not re-exported from `src/index.ts` and the package publishes only `.`, so the published surface is unchanged: the built `dist/index.d.ts` and `.d.cts` contain no mention of it, and both bundles' runtime exports remain exactly `encryptedSupabase, encryptedSupabaseV3`. --- .../__tests__/column-map-predicate.test.ts | 133 ++++++++++++++++++ packages/stack-supabase/src/column-map.ts | 13 +- 2 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 packages/stack-supabase/__tests__/column-map-predicate.test.ts diff --git a/packages/stack-supabase/__tests__/column-map-predicate.test.ts b/packages/stack-supabase/__tests__/column-map-predicate.test.ts new file mode 100644 index 000000000..2575e993a --- /dev/null +++ b/packages/stack-supabase/__tests__/column-map-predicate.test.ts @@ -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) + }) +}) diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts index 81c85dd26..1bfb0a8c6 100644 --- a/packages/stack-supabase/src/column-map.ts +++ b/packages/stack-supabase/src/column-map.ts @@ -44,10 +44,17 @@ export type V3ColumnLike = { * 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`). + * (`EncryptedColumn`, `packages/stack/src/schema/index.ts:442,449`) but neither + * `getEqlType()` nor `getQueryCapabilities()` (`eql/v3/columns.ts:445,450`). + * + * Exported for `__tests__/column-map-predicate.test.ts` only — `column-map.ts` + * is not re-exported from `src/index.ts` and the package publishes just `.`, so + * this does not widen the published surface (`V3ColumnLike` above is exported + * on the same terms). Testing it through `ColumnMap` cannot distinguish a + * four-probe gate from a two-probe one: every builder that reaches the + * constructor either passes every probe or fails two at once. */ -function isV3ColumnLike(builder: unknown): builder is V3ColumnLike { +export function isV3ColumnLike(builder: unknown): builder is V3ColumnLike { if (typeof builder !== 'object' || builder === null) return false return ( 'getName' in builder && From f503d8d091a297571ea19f79a2f7da3f6162556c Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Mon, 27 Jul 2026 15:14:22 +1000 Subject: [PATCH 6/7] docs(stack): a new bare specifier is a signal, not automatically a defect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The process-free realm harness rejects every bare specifier, which is stricter than the resolution any real runtime performs. It passes today only because everything the adapter-kit graph reaches is bundled via tsup `noExternal` — load-bearing, not incidental: the chunk adapter-kit imports carries inlined `evlog`, so without that entry it would emit a bare import. `@cipherstash/auth` and `@cipherstash/protect-ffi` stay external and are simply unreachable from that graph; if either became reachable, the gate would fail with a message reading like a self-containment defect. The header and the throw now say what the constraint rests on and what to do about it. No behaviour change — verified by pointing the harness at `dist/index.js`, whose bare `@cipherstash/auth` import produces the reworded error. `turbo.json`'s override also replaced the root `dependsOn` rather than extending it, resolving `test` to `["build"]` and dropping the inherited `["^build"]`. This is defence-in-depth rather than a live bug: ordering survives transitively via `build`'s own `^build`, as `packages/prisma-next` demonstrates with the identical override and real workspace deps. Made explicit anyway, matching how the root spells `test:e2e`; prisma-next is left alone deliberately, since the same reasoning says it is not broken either. Lastly, `logger-edge-safety.test.ts` described a `bundling-isolation.test.ts` that spawns this harness against the WASM entry as though it existed. It does not yet — reworded to future tense, and the turbo/bare-invocation skip asymmetry noted where a reader will hit it. --- packages/stack/__tests__/helpers/process-free-realm.mjs | 9 ++++++--- packages/stack/__tests__/logger-edge-safety.test.ts | 7 ++++--- packages/stack/turbo.json | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/stack/__tests__/helpers/process-free-realm.mjs b/packages/stack/__tests__/helpers/process-free-realm.mjs index 4ec23c2c8..cf1a8be8d 100644 --- a/packages/stack/__tests__/helpers/process-free-realm.mjs +++ b/packages/stack/__tests__/helpers/process-free-realm.mjs @@ -5,8 +5,11 @@ * Uses `node:vm`'s `SourceTextModule` with a linker that COMPILES each relative * import into the same sandbox context, rather than `import()`ing it in the host * realm (which would hand the module the host's `process` and prove nothing). - * Bare specifiers are rejected: the graphs this is pointed at have none, and a - * new one appearing is itself a finding. + * Bare specifiers are rejected. The graphs this is pointed at have none today + * only because everything they reach is bundled (`noExternal` in + * `packages/stack/tsup.config.ts`) — a property of the build config, not of + * module resolution. So a new one is a SIGNAL, not automatically a defect: if + * it is a legitimate external the target runtime resolves, allow it here. * * Run with `node --experimental-vm-modules`; callers spawn it that way, as a * CHILD PROCESS, so no vitest configuration or flag is involved. @@ -50,7 +53,7 @@ async function loadInProcessFreeRealm(entryPath) { const link = (specifier, referencing) => { if (!specifier.startsWith('.') && !specifier.startsWith('/')) { throw new Error( - `unexpected bare specifier "${specifier}" in ${referencing.identifier} — this graph is supposed to be self-contained`, + `unexpected bare specifier "${specifier}" in ${referencing.identifier} — this graph is bundled (tsup \`noExternal\`) and should have none. If this is a legitimate new external, allow it in this harness.`, ) } const base = dirname(fileURLToPath(referencing.identifier)) diff --git a/packages/stack/__tests__/logger-edge-safety.test.ts b/packages/stack/__tests__/logger-edge-safety.test.ts index a28ba0f2a..87e607f40 100644 --- a/packages/stack/__tests__/logger-edge-safety.test.ts +++ b/packages/stack/__tests__/logger-edge-safety.test.ts @@ -12,9 +12,10 @@ * guard from `src/utils/logger/index.ts`, rebuild, and this test fails. * * It reads `dist/`, so it SKIPS when the package has not been built — run - * `pnpm --filter @cipherstash/stack build` first for it to mean anything. The - * portable-entry plan's `bundling-isolation.test.ts` spawns the same harness - * against the WASM entry. + * `pnpm --filter @cipherstash/stack build` first for it to mean anything. + * (`turbo.json` wires `test` to `build`, so the turbo path cannot skip it; a + * bare `pnpm --filter … test` on a clean checkout still can.) The + * portable-entry plan will point the same harness at the WASM entry. */ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' diff --git a/packages/stack/turbo.json b/packages/stack/turbo.json index 5ff716600..8649e7366 100644 --- a/packages/stack/turbo.json +++ b/packages/stack/turbo.json @@ -3,7 +3,7 @@ "extends": ["//"], "tasks": { "test": { - "dependsOn": ["build"] + "dependsOn": ["^build", "build"] } } } From 5761f6bfc42b4184c207f113cd2f1025c755f6e2 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Mon, 27 Jul 2026 17:15:29 +1000 Subject: [PATCH 7/7] fix(stack,stack-supabase): diagnose an EQL v2 table instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A v2 `EncryptedTable` is structurally identical to a v3 one — same `tableName`, same `columnBuilders` — and only `buildColumnKeyMap()` tells them apart. So nothing that inspected shape caught the swap, and TypeScript only helps callers who are actually type-checking. Two paths, two raw TypeErrors, both naming an internal method rather than the version mismatch behind it: - `encryptedSupabase({ schemas })` sailed past the record-key check and died in `verifyDeclaredSchemas` as `builder.getEqlType is not a function`. This is the reachable one — the realistic mistake is migrating from v2 and passing the old `schemas` through. - Constructing the query builder directly died one layer down in `ColumnMap`, on the constructor's very first statement, as `table.buildColumnKeyMap is not a function`. Not reachable from the factory today (`mergeDeclaredTables` rebuilds a fresh v3 table), so this half is defence-in-depth at the seam — symmetric with the column-level fail-closed guard already there, which cannot cover it because it runs after the constructor has already crashed. Both now name the table and state the fix. Each guard is load-bearing: removing either puts its original TypeError back. The check routes through `hasBuildColumnKeyMap` rather than a second hand-written spelling, per that function's own doctrine — "a second hand-written spelling of the check is how a v2 envelope eventually gets built for a v3 table once the marker drifts". It is re-exported from `@cipherstash/stack/adapter-kit`, the first-party adapter seam, and deliberately NOT promoted to `./types`: deciding which wire version a table targets is adapter plumbing, not end-user API. Verified edge-safe — `dist/adapter-kit.js` still evaluates in a process-free realm with no bare specifiers (`OK 14 exports`). `skills/stash-supabase` gains the error under "Legacy: EQL v2", where a migrating reader will hit it. --- .changeset/supabase-v2-table-diagnosis.md | 25 +++++++++++++++++ .../__tests__/supabase-schema-builder.test.ts | 23 ++++++++++++++++ .../__tests__/supabase-v3-factory.test.ts | 27 +++++++++++++++++++ packages/stack-supabase/src/column-map.ts | 17 ++++++++++++ packages/stack-supabase/src/index.ts | 13 +++++++++ packages/stack/src/adapter-kit.ts | 12 ++++++--- skills/stash-supabase/SKILL.md | 11 ++++++++ 7 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 .changeset/supabase-v2-table-diagnosis.md diff --git a/.changeset/supabase-v2-table-diagnosis.md b/.changeset/supabase-v2-table-diagnosis.md new file mode 100644 index 000000000..1c6cfb160 --- /dev/null +++ b/.changeset/supabase-v2-table-diagnosis.md @@ -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. diff --git a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts index 94f1a7d04..185700364 100644 --- a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts @@ -1,4 +1,8 @@ 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' @@ -299,6 +303,25 @@ describe('ColumnMap recognises v3 columns structurally, not by class identity', /\[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', () => { diff --git a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts index b9cebc982..3a5b1d262 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts @@ -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' @@ -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({ diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts index 1bfb0a8c6..c3974b0fa 100644 --- a/packages/stack-supabase/src/column-map.ts +++ b/packages/stack-supabase/src/column-map.ts @@ -1,3 +1,4 @@ +import { hasBuildColumnKeyMap } 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' @@ -134,6 +135,22 @@ export class ColumnMap { table: AnyV3Table, allColumns: string[] | null, ) { + // FAIL CLOSED at the table level, for the same reason the column loop does + // below. `buildColumnKeyMap()` is the canonical v2/v3 discriminator + // (`packages/stack/src/types.ts:276`), and it is also the very first thing + // this constructor calls — so a v2 table died on the next line with + // `table.buildColumnKeyMap is not a function`, naming an internal method + // instead of the version mismatch. The column-level probe cannot cover + // this: it runs after the constructor has already crashed. + // + // Routed through `hasBuildColumnKeyMap` rather than hand-spelled, per that + // function's own doctrine — a second spelling is how the marker drifts. + if (!hasBuildColumnKeyMap(table)) { + throw new Error( + `[supabase v3]: table "${tableName}" is an EQL v2 table — it has no buildColumnKeyMap(), the marker every v3 table carries. This adapter is EQL v3 only. Author the table with \`encryptedTable\`/\`types\` from \`@cipherstash/stack/eql/v3\` or \`@cipherstash/stack/wasm-inline\`.`, + ) + } + this.propToDb = table.buildColumnKeyMap() this.columnSchemas = table.build().columns diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index 4710e6f52..a05c7b81f 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -1,4 +1,5 @@ import { Encryption } from '@cipherstash/stack' +import { hasBuildColumnKeyMap } from '@cipherstash/stack/adapter-kit' import type { UnmodelledColumn } from './introspect' import { eqlRequiresQueryDomains, introspect } from './introspect' import { EncryptedQueryBuilderImpl } from './query-builder' @@ -169,6 +170,18 @@ export async function encryptedSupabase( `[supabase v3]: schemas key "${key}" does not match its table name "${table.tableName}" — the record key must equal the table's name`, ) } + // A v2 `EncryptedTable` carries `tableName` and `columnBuilders` exactly + // as a v3 one does, so nothing above this distinguishes them and the + // types only catch it for callers who are actually type-checking. Left + // unguarded it reached `verifyDeclaredSchemas`, which calls + // `builder.getEqlType()` — absent on a v2 column — and died as + // `builder.getEqlType is not a function`: an internal method name, from + // a caller's perspective unrelated to the mistake they made. + if (!hasBuildColumnKeyMap(table)) { + throw new Error( + `[supabase v3]: schemas entry "${key}" is an EQL v2 table — it has no buildColumnKeyMap(), the marker every v3 table carries. This adapter is EQL v3 only. Author the table with \`encryptedTable\`/\`types\` from \`@cipherstash/stack/eql/v3\`.`, + ) + } assertTableIsModelled(key, unmodelled) } verifyDeclaredSchemas(options.schemas, tables) diff --git a/packages/stack/src/adapter-kit.ts b/packages/stack/src/adapter-kit.ts index 2d1fad591..a46706d8d 100644 --- a/packages/stack/src/adapter-kit.ts +++ b/packages/stack/src/adapter-kit.ts @@ -26,7 +26,6 @@ export { // Audit config carried by chainable operations. export type { AuditConfig } from './encryption/operations/base-operation.js' - // v3 column model + the date-like cast set the Supabase builder uses to // reconstruct `Date` values from PostgREST select aliases. export { @@ -34,7 +33,6 @@ export { DATE_LIKE_CASTS, EncryptedV3Column, } from './eql/v3/columns.js' - // Domain registry: the Supabase adapter classifies introspected columns by their // Postgres domain and rebuilds each column's encryption config from it. export { @@ -42,7 +40,6 @@ export { factoryForDomain, stripDomainSchema, } from './eql/v3/domain-registry.js' - // Shared JSONPath-selector path handling (parse/validate, needle-document // reconstruction, scalar-leaf guard) for encrypted-JSON querying — used by the // Drizzle selector operators (#651) and the Supabase selector filters (#650) so @@ -53,8 +50,15 @@ export { reconstructSelectorDocument, unsupportedLeafReason, } from './eql/v3/selector-path.js' - // Shared match-index guard (short-needle rejection), reused by both adapters. export { matchNeedleError } from './schema/match-defaults.js' +// The canonical EQL v2/v3 table discriminator. Added because the adapters must +// make the same routing decision core does, and that function's own doctrine is +// that every site routes through it — "a second hand-written spelling of the +// check is how a v2 envelope eventually gets built for a v3 table once the +// marker drifts" (`types.ts:268-275`). Re-exported here rather than promoted to +// `./types`: deciding which wire version a table targets is adapter plumbing, +// not something an end user should be reaching for. +export { hasBuildColumnKeyMap } from './types.js' // Shared structured logger (adapters log rejections through the same instance). export { logger } from './utils/logger/index.js' diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 2cb45f9a8..4bcec8b35 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -778,6 +778,17 @@ and queries EQL v3 only, via the introspecting `encryptedSupabase(url, key)` / `encryptedSupabase(client, options)` factory described above. There is no longer a code path in this package that emits or reads `eql_v2_encrypted` columns. +Passing a v2 table in `schemas` is rejected by name: + +``` +[supabase v3]: schemas entry "users" is an EQL v2 table — it has no +buildColumnKeyMap(), the marker every v3 table carries. +``` + +A v2 `encryptedTable` is structurally identical to a v3 one apart from that +marker, so TypeScript alone will not always catch the swap — re-author the table +with `encryptedTable`/`types` from `@cipherstash/stack/eql/v3`. + Existing v2 deployments have two options: - **Migrate to EQL v3** (recommended): add an `eql_v3_*` twin column and run the