From 43b955a472ce63b5f2982a043fd9b3927289ca68 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Sun, 26 Jul 2026 10:19:06 +1000 Subject: [PATCH 1/3] 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/3] 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/3] 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' }