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/.changeset/supabase-structural-v3-columns.md b/.changeset/supabase-structural-v3-columns.md new file mode 100644 index 000000000..27d017118 --- /dev/null +++ b/.changeset/supabase-structural-v3-columns.md @@ -0,0 +1,11 @@ +--- +'@cipherstash/stack-supabase': patch +--- + +Fix: a table authored with `encryptedTable`/`types` imported from `@cipherstash/stack/wasm-inline` was treated as having **no encrypted columns**, so filter operands were sent to PostgREST as plaintext. + +`ColumnMap` gated on `builder instanceof EncryptedV3Column`, and the published bundles contain two separately-emitted copies of that class (`dist/adapter-kit.js` and `dist/wasm-inline.js` are separate esbuild runs). The check is now structural, so both copies are recognised. Tables authored from `@cipherstash/stack/eql/v3` were never affected — they resolve to the same copy the adapter imports. + +The failure was silent: `::jsonb` casts and result decryption go through a different path and kept working. + +The recognition now also fails closed: a column builder that does not present the v3 surface makes `encryptedSupabase` throw at construction rather than silently omitting the column — an omitted column would send its filter operands to PostgREST as plaintext. 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__/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 => ({ + getName: () => 'email', + getEqlType: () => 'public.eql_v3_text_search', + getQueryCapabilities: () => ({ + equality: true, + orderAndRange: false, + freeTextSearch: true, + }), + build: () => ({}), +}) + +const withoutProbe = (probe: Probe): Record => { + const builder = conforming() + delete builder[probe] + return builder +} + +const withNonFunctionProbe = (probe: Probe): Record => ({ + ...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/__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..185700364 100644 --- a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts @@ -1,8 +1,14 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +import { + encryptedColumn, + encryptedTable as v2EncryptedTable, +} from '@cipherstash/stack/schema' import { describe, expect, it } from 'vitest' +import { ColumnMap } from '../src/column-map' import type { IntrospectionResult } from '../src/introspect' import { groupUnmodelledRows } from '../src/introspect' import { mergeDeclaredTables, synthesizeTables } from '../src/schema-builder' +import { wasmAuthoredV3Table } from './helpers/supabase-mock' const introspection: IntrospectionResult = [ { @@ -242,3 +248,101 @@ describe('groupUnmodelledRows', () => { expect(groupUnmodelledRows([]).size).toBe(0) }) }) + +describe('ColumnMap recognises v3 columns structurally, not by class identity', () => { + // tsup emits `EncryptedV3Column` TWICE — once into the chunk + // `dist/adapter-kit.js` imports, once inline in `dist/wasm-inline.js` (a + // separate esbuild run). A table authored from `@cipherstash/stack/wasm-inline` + // therefore failed `builder instanceof EncryptedV3Column` for EVERY column, + // leaving `v3Columns` empty — so the filter collector skipped every term and + // the RAW PLAINTEXT operand went into the PostgREST query string, while + // `::jsonb` casts and decryption kept working. + // + // These two assert the MECHANISM (`v3Columns` is populated / not + // over-populated). The HARM — what PostgREST actually receives — is asserted + // in `supabase-v3-wire.test.ts` by Step 2, because a check that merely + // probed `getName` would satisfy the two below. + it('accepts a builder that merely has the v3 column surface', () => { + const table = wasmAuthoredV3Table('users', ['email']) + + const columns = new ColumnMap('users', table as never, null) + + expect(columns.isEncryptedV3Column('email')).toBe(true) + expect(columns.encryptedColumnNames).toContain('email') + }) + + it('throws on a builder missing the v3 column surface', () => { + // v2 columns have `build()` and `getName()` (`EncryptedColumn`, + // `packages/stack/src/schema/index.ts:442,449`) but neither `getEqlType()` + // nor `getQueryCapabilities()` (`eql/v3/columns.ts:445,450`). Four probes, + // not two, is what keeps the predicate honest. + // + // `columnBuilders` on an `AnyV3Table` must hold ONLY encrypted v3 columns, + // so a builder that fails the probe is malformed input. Silently skipping it + // is not a safe default: the column would drop out of `v3Columns` and its + // filter operands would go to PostgREST as PLAINTEXT. Fail closed at + // construction instead. + const v2 = { getName: () => 'email', build: () => ({}) } + const table = { + tableName: 'users', + columnBuilders: { email: v2 }, + buildColumnKeyMap: () => ({ email: 'email' }), + build: () => ({ tableName: 'users', columns: {} }), + } + + // Pin the SPECIFIC message, not just the `[supabase v3]` prefix: 32 errors + // across this package share that prefix, two of them thrown by `ColumnMap` + // itself. A prefix-only matcher stays green whenever a DIFFERENT one of + // those fires first — measured: with `assertNoPropertyDbNameCollision` + // throwing unconditionally, so the fail-closed probe below is never + // reached, this test still passed. It could not tell which error it caught. + // (Deleting the probe outright does turn it red — construction then + // succeeds and nothing throws. It is the bypass, not the deletion, that the + // loose matcher was blind to.) + expect(() => new ColumnMap('users', table as never, null)).toThrow( + /\[supabase v3\]: column "email" on table "users" is not a recognised EQL v3 column builder/, + ) + }) + + it('throws a diagnosis, not a raw TypeError, on a whole v2 table', () => { + // A v2 `EncryptedTable` is structurally identical to a v3 one at the table + // level — same `tableName`, same `columnBuilders`. The only discriminator + // is `buildColumnKeyMap()`, which the constructor calls UNGUARDED as its + // first statement, so a v2 table died with `table.buildColumnKeyMap is not + // a function` — naming an internal method, not the version mismatch that + // caused it. + // + // The column-level probe above cannot catch this: it runs later, and by + // then the constructor has already crashed. + const v2Table = v2EncryptedTable('users', { + email: encryptedColumn('email').equality(), + }) + + expect(() => new ColumnMap('users', v2Table as never, null)).toThrow( + /\[supabase v3\]: table "users" is an EQL v2 table/, + ) + }) +}) + +describe('every types.* domain satisfies the structural v3 probe', () => { + // Stated through the public consequence rather than against the predicate: + // whatever the catalog grows to, ColumnMap must see the column as encrypted. + // A domain whose builder lost one of the four methods would be silently + // treated as PLAINTEXT — the PF2 failure again, from a different direction. + // Enumerated, not hardcoded (40 domains today), so a new one is covered the + // day it is added. The predicate's own shape is pinned separately, one probe + // at a time, in `column-map-predicate.test.ts`. + it('recognises a column built by any factory in the catalog', () => { + // Deterministic iteration over the WHOLE catalog, not a probabilistic + // sample: the guarantee this pins is "every domain", so every domain must + // actually run. `fc.constantFrom` would leave that to chance across its + // default run count. + for (const domain of Object.keys(types) as (keyof typeof types)[]) { + const table = encryptedTable('t', { c: types[domain]('c') }) + + const columns = new ColumnMap('t', table as never, null) + + expect(columns.isEncryptedV3Column('c')).toBe(true) + } + }) +}) 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/__tests__/supabase-v3-wire.test.ts b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts index 6b33b7c66..3ffc94d92 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,71 @@ describe('plaintext not(col, contains, …) emits a parseable containment litera expect(wire.operandFor('note')).toBe('not.cs.{vip}') }) }) + +describe('a structurally-v3 table still encrypts the filter operand', () => { + // The regression this plan exists to stop, at the layer where it hurt: with + // the `instanceof` gate, a table that is structurally v3 but not an instance + // of THIS package's copy of `EncryptedV3Column` sent `eq.` to + // PostgREST. + it('emits an envelope, not the bare plaintext', async () => { + const wire = createWirePostgrest([]) + const builder = new EncryptedQueryBuilderV3Impl( + 'users', + wasmAuthoredV3Table('users', ['email']) as never, + createMockEncryptionClient(), + wire.client, + ['id', 'email'], + ) + + await builder.select('id').eq('email', 'ada@example.com') + + const operand = wire.operandFor('email') + expect(operand.startsWith('eq.')).toBe(true) + const value = operand.slice('eq.'.length) + + // NOT `expect(operand).not.toContain('ada@example.com')`: the encryption + // double deliberately carries the plaintext in the envelope's `pt` field so + // its fake decrypt can undo it (`helpers/supabase-mock.ts`). The contract + // being pinned is that the operand is an ENVELOPE rather than the raw + // value — unfixed, `value` is literally `ada@example.com`. + expect(value).not.toBe('ada@example.com') + expect(JSON.parse(value)).toMatchObject({ c: 'ct:ada@example.com' }) + }) +}) + +describe('an unrecognised column builder fails closed', () => { + // The mirror image of the leak above: a builder that does NOT present the v3 + // surface must never be silently demoted to plaintext passthrough, because + // then its filter operands would reach PostgREST in the clear. `ColumnMap` is + // built eagerly in the query-builder constructor, so construction throws + // BEFORE any request — this pins that no query string is ever emitted. + it('throws at construction, so no PostgREST request is issued', () => { + const wire = createWirePostgrest([]) + const malformed = { + tableName: 'users', + columnBuilders: { + email: { getName: () => 'email', build: () => ({}) }, + }, + buildColumnKeyMap: () => ({ email: 'email' }), + build: () => ({ tableName: 'users', columns: {} }), + } + + expect( + () => + new EncryptedQueryBuilderV3Impl( + 'users', + malformed as never, + createMockEncryptionClient(), + wire.client, + ['id', 'email'], + ), + // Prefix-only would not discriminate — see the matching note in + // `supabase-schema-builder.test.ts`. This test guards the HARM, so it is + // the one that most needs to fail if the fail-closed probe stops firing + // and some other `[supabase v3]` error surfaces in its place. + ).toThrow( + /\[supabase v3\]: column "email" on table "users" is not a recognised EQL v3 column builder/, + ) + expect(wire.urls).toEqual([]) + }) +}) diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts index 8920d87d1..c3974b0fa 100644 --- a/packages/stack-supabase/src/column-map.ts +++ b/packages/stack-supabase/src/column-map.ts @@ -1,13 +1,15 @@ -import { EncryptedV3Column } from '@cipherstash/stack/adapter-kit' +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' 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 +24,52 @@ 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()` + * (`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. + */ +export 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. * @@ -87,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 @@ -102,11 +166,22 @@ export class ColumnMap { // otherwise resolve truthy for a plaintext column of that name. this.v3Columns = Object.create(null) as Record<string, V3ColumnLike> for (const [property, builder] of Object.entries(table.columnBuilders)) { - if (builder instanceof EncryptedV3Column) { - const col = builder as unknown as V3ColumnLike - this.v3Columns[property] = col - this.v3Columns[col.getName()] = col + // FAIL CLOSED. `columnBuilders` is typed `EncryptedV3TableColumn` + // (`eql/v3/table.ts:18-25`), so every entry is meant to be an encrypted v3 + // column. A builder that fails the structural probe is malformed input — + // and silently skipping it is the one thing we must not do here: the + // column would drop out of `v3Columns`, `isEncryptedColumn()` would return + // false for it, and its filter operands would go to PostgREST as + // PLAINTEXT. Refuse to construct instead, mirroring `build()`'s + // fail-loudly-on-malformed stance (`eql/v3/table.ts:47-51`). + if (!isV3ColumnLike(builder)) { + throw new Error( + `[supabase v3]: column "${property}" on table "${tableName}" is not a recognised EQL v3 column builder. Its filter operands would otherwise be sent to PostgREST unencrypted, so construction is refused. Author the table with \`encryptedTable\`/\`types\` from \`@cipherstash/stack/eql/v3\` or \`@cipherstash/stack/wasm-inline\`.`, + ) } + const col = builder + this.v3Columns[property] = col + this.v3Columns[col.getName()] = col } this.encryptedColumnNames = Object.keys(this.v3Columns) @@ -213,6 +288,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> } } 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/__tests__/helpers/process-free-realm.mjs b/packages/stack/__tests__/helpers/process-free-realm.mjs new file mode 100644 index 000000000..cf1a8be8d --- /dev/null +++ b/packages/stack/__tests__/helpers/process-free-realm.mjs @@ -0,0 +1,76 @@ +/** + * 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 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. + * + * Usage: node --experimental-vm-modules process-free-realm.mjs <entry.js> + * Exits 0 and prints `OK <n> 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 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)) + 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..87e607f40 --- /dev/null +++ b/packages/stack/__tests__/logger-edge-safety.test.ts @@ -0,0 +1,48 @@ +/** + * `@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. + * (`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' +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/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/packages/stack/src/utils/logger/index.ts b/packages/stack/src/utils/logger/index.ts index cf84ab9ad..0d54ead7c 100644 --- a/packages/stack/src/utils/logger/index.ts +++ b/packages/stack/src/utils/logger/index.ts @@ -14,7 +14,16 @@ 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. 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' || !process.env + ? 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..8649e7366 --- /dev/null +++ b/packages/stack/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["^build", "build"] + } + } +} 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