From a2ec5a66681b77b37544cc0a86781ea84545679f Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 1 Jul 2026 19:55:51 +1000 Subject: [PATCH 1/2] fix(stack): match v3 model fields by JS property, encrypt by DB name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 encrypt config keys columns by DB name (`column.getName()`), but the shared model path matched user models against those DB-name keys while models — and the typed model types — are keyed by JS property. For any column whose JS property differs from its DB name (e.g. `createdOn: encryptedDateColumn( 'created_on')`) the field never matched, so `encryptModel` silently stored it as PLAINTEXT and `decryptModel` skipped `Date` reconstruction. Add `BuildableTable.buildColumnKeyMap()` (property -> DB name), implemented by v3 `EncryptedTable`, and route the model path through `resolveEncryptColumnMap()`: match models by JS property, address the FFI/config by DB name. `reconstructRow` now keys dates by property. v2 tables omit the map and fall back to identity, so v2 behavior is unchanged. Rework the schema-v3 date round-trip to exercise the typed `decryptModel` Date reconstruction (single-value `decrypt` returns an ISO string by design, so the old strict `toEqual(Date)` could never hold), and add regression coverage: - non-live: `resolveEncryptColumnMap`/`buildColumnKeyMap` mapping and a property-vs-DB-name `reconstructRow` case via the fake-client harness; - live: property-vs-DB-name model encrypt (no plaintext leak) + decrypt. Also drop the int8 (bigint) domain from the v3 SDK surface until the native FFI round-trips bigint losslessly, removing the now-dead bigint reconstruction path. --- .../__tests__/model-column-mapping.test.ts | 43 ++++++++ .../stack/__tests__/schema-v3-client.test.ts | 76 ++++++++++----- packages/stack/__tests__/schema-v3.test-d.ts | 12 +-- packages/stack/__tests__/schema-v3.test.ts | 62 ++++-------- .../stack/__tests__/typed-client-v3.test-d.ts | 12 +-- .../stack/__tests__/typed-client-v3.test.ts | 26 ++--- .../encryption/helpers/infer-index-type.ts | 6 +- .../src/encryption/helpers/model-helpers.ts | 46 +++++++-- packages/stack/src/encryption/v3.ts | 42 ++++---- packages/stack/src/schema/v3/index.ts | 97 +++++++------------ packages/stack/src/types.ts | 18 +++- 11 files changed, 246 insertions(+), 194 deletions(-) create mode 100644 packages/stack/__tests__/model-column-mapping.test.ts diff --git a/packages/stack/__tests__/model-column-mapping.test.ts b/packages/stack/__tests__/model-column-mapping.test.ts new file mode 100644 index 00000000..785a47cd --- /dev/null +++ b/packages/stack/__tests__/model-column-mapping.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { resolveEncryptColumnMap } from '@/encryption/helpers/model-helpers' +import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' +import { + encryptedDateColumn, + encryptedTable, + encryptedTextColumn, +} from '@/schema/v3' + +// `resolveEncryptColumnMap` is how the model path reconciles the two keyings a +// table can use: models are matched by JS property name, but the FFI / encrypt +// config is addressed by DB column name. A mismatch here is a real data-leak +// bug — a schema field that fails to match is passed through as plaintext. +describe('resolveEncryptColumnMap', () => { + it('v3: matches by JS property, addresses the FFI by DB name', () => { + const users = encryptedTable('users', { + createdOn: encryptedDateColumn('created_on'), + notes: encryptedTextColumn('notes'), // property == name + }) + + const { columnPaths, toColumnName } = resolveEncryptColumnMap(users) + + // Fields are matched against JS property names (what a model is keyed by)… + expect(columnPaths.sort()).toEqual(['createdOn', 'notes']) + // …and each maps to the DB name the config/FFI is keyed by. + expect(toColumnName('createdOn')).toBe('created_on') + expect(toColumnName('notes')).toBe('notes') + }) + + it('v2: no property→DB map, so both keying schemes are the JS property', () => { + // v2 `build()` keys columns by the JS property, so matching and addressing + // use that same key — the resolver must fall back to identity and leave the + // v2 model path unchanged. + const legacy = encryptedTableV2('legacy', { + fooBar: encryptedColumn('foo_bar'), + }) + + const { columnPaths, toColumnName } = resolveEncryptColumnMap(legacy) + + expect(columnPaths).toEqual(['fooBar']) + expect(toColumnName('fooBar')).toBe('fooBar') + }) +}) diff --git a/packages/stack/__tests__/schema-v3-client.test.ts b/packages/stack/__tests__/schema-v3-client.test.ts index d6db9ce6..88cc7dd2 100644 --- a/packages/stack/__tests__/schema-v3-client.test.ts +++ b/packages/stack/__tests__/schema-v3-client.test.ts @@ -1,12 +1,12 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' +import { typedClient } from '@/encryption/v3' import { Encryption } from '@/index' import { encryptedBoolColumn, encryptedDateColumn, encryptedInt4OrdColumn, - encryptedInt8Column, encryptedTable, encryptedTextColumn, encryptedTextEqColumn, @@ -23,7 +23,9 @@ const users = encryptedTable('schema_v3_client_users', { body: encryptedTextMatchColumn('body'), notes: encryptedTextColumn('notes'), active: encryptedBoolColumn('active'), - externalId: encryptedInt8Column('external_id'), + // camelCase JS property → snake_case DB name on purpose: the model path must + // match models by JS property (`createdOn`) yet address the FFI/config by DB + // name (`created_on`). The round-trip tests below exercise that mapping. createdOn: encryptedDateColumn('created_on'), occurredAt: encryptedTimestamptzColumn('occurred_at'), }) @@ -177,35 +179,61 @@ describeLive('eql_v3 client integration', () => { expect(matchTerm).not.toHaveProperty('c') }, 30000) - it('round-trips a representative int8 storage domain (string plaintext)', async () => { - // int8 domains use `string` plaintext until the native FFI supports bigint - // I/O. `string` is lossless across the full int8 range (this value exceeds - // Number.MAX_SAFE_INTEGER); `cast_as: bigint` handles server-side casting. - const int8Encrypted = unwrapResult( - await protectClient.encrypt('1234567890123456789', { - table: users, - column: users.externalId, - }), - ) - expect(unwrapResult(await protectClient.decrypt(int8Encrypted))).toBe( - '1234567890123456789', - ) - }, 30000) - - it('round-trips a representative date storage domain', async () => { + // int8 (bigint) storage domains are omitted from the v3 SDK until the native + // protect-ffi supports lossless bigint round-tripping — a `bigint` fails JSON + // serialization and a `string` is rejected for a `big_int` column. Re-add a + // round-trip test alongside the domain builders when the FFI lands. + + // A `date` domain decrypts to an ISO 8601 string from the native FFI, so the + // single-value `decrypt` path returns a string (a lone ciphertext carries no + // column context). The typed client's `decryptModel` reconstructs a real + // `Date` from the encrypt-config `cast_as` (`reconstructRow`), keyed by the + // JS property (`createdOn`) even though the DB column is `created_on`. + it('round-trips a representative date storage domain via decryptModel', async () => { + const typed = typedClient(protectClient, users) + // Zero milliseconds so the FFI dropping sub-second precision (`...00Z` vs + // `...000Z`) does not perturb the reconstructed instant. const day = new Date('2026-07-01T00:00:00.000Z') + + // Encrypt via the single-value path (the proven route for a `Date` domain), + // then decrypt through the model path so `reconstructRow` rebuilds a `Date` + // from the encrypt-config `cast_as`. const dateEncrypted = unwrapResult( await protectClient.encrypt(day, { table: users, column: users.createdOn, }), ) - // Assertion pending live verification: `decrypt` has no `castAs` context, so - // whether a `date` domain returns a `Date` or an ISO string is FFI-dependent. - // If this returns a string, that is a separate pre-existing gap to handle as - // a follow-up (client-side Date reconstruction or a string assertion). - expect(unwrapResult(await protectClient.decrypt(dateEncrypted))).toEqual( - day, + // Guard against a false pass: the value must be an actual ciphertext, not a + // plaintext `Date` that would trivially satisfy the assertions below. + expect(dateEncrypted).toHaveProperty('c') + + const decrypted = unwrapResult( + await typed.decryptModel({ createdOn: dateEncrypted }, users), + ) + expect(decrypted.createdOn).toBeInstanceOf(Date) + expect(decrypted.createdOn).toEqual(day) + }, 30000) + + // Regression: a camelCase JS property mapping to a snake_case DB column + // (`nickname` is name==key, but `createdOn`→`created_on` is not) must be + // ENCRYPTED by the model path — not silently passed through as plaintext + // because the field key (`createdOn`) fails to match the DB-keyed config. + it('encrypts a property-vs-DB-name column through encryptModel (no plaintext leak)', async () => { + const typed = typedClient(protectClient, users) + const day = new Date('2026-07-01T00:00:00.000Z') + + const encrypted = unwrapResult( + await typed.encryptModel({ createdOn: day, notes: 'hello' }, users), ) + // The schema field must become a ciphertext (has `c`), NOT remain a Date. + expect(encrypted.createdOn).not.toBeInstanceOf(Date) + expect(encrypted.createdOn).toHaveProperty('c') + expect(encrypted.notes).toHaveProperty('c') + + const decrypted = unwrapResult(await typed.decryptModel(encrypted, users)) + expect(decrypted.createdOn).toBeInstanceOf(Date) + expect(decrypted.createdOn).toEqual(day) + expect(decrypted.notes).toBe('hello') }, 30000) }) diff --git a/packages/stack/__tests__/schema-v3.test-d.ts b/packages/stack/__tests__/schema-v3.test-d.ts index cade3b04..b99fdbe3 100644 --- a/packages/stack/__tests__/schema-v3.test-d.ts +++ b/packages/stack/__tests__/schema-v3.test-d.ts @@ -14,9 +14,9 @@ import type { } from '@/schema/v3' import { encryptedBoolColumn, + encryptedDateColumn, encryptedFloat8Column, encryptedInt4Column, - encryptedInt8Column, encryptedTable, encryptedTextColumn, encryptedTextEqColumn, @@ -69,7 +69,6 @@ describe('eql_v3 schema type inference', () => { const metrics = encryptedTable('metrics', { name: encryptedTextColumn('name'), age: encryptedInt4Column('age'), - id64: encryptedInt8Column('id64'), active: encryptedBoolColumn('active'), createdAt: encryptedTimestamptzColumn('created_at'), score: encryptedFloat8Column('score'), @@ -80,7 +79,6 @@ describe('eql_v3 schema type inference', () => { expectTypeOf().toEqualTypeOf<{ name: string age: number - id64: string active: boolean createdAt: Date score: number @@ -88,13 +86,13 @@ describe('eql_v3 schema type inference', () => { }) it('v3 domain classes remain nominal by literal domain definition', () => { - const int8 = encryptedInt8Column('id64') + const date = encryptedDateColumn('created_on') const bool = encryptedBoolColumn('active') - expectTypeOf(int8).not.toEqualTypeOf<typeof bool>() + expectTypeOf(date).not.toEqualTypeOf<typeof bool>() - // @ts-expect-error - storage-only bool is not assignable to storage-only int8 - const invalid: typeof int8 = bool + // @ts-expect-error - storage-only bool is not assignable to storage-only date + const invalid: typeof date = bool void invalid }) }) diff --git a/packages/stack/__tests__/schema-v3.test.ts b/packages/stack/__tests__/schema-v3.test.ts index 7fbe5e1b..a9a4ae77 100644 --- a/packages/stack/__tests__/schema-v3.test.ts +++ b/packages/stack/__tests__/schema-v3.test.ts @@ -24,10 +24,6 @@ import { EncryptedInt4EqColumn, EncryptedInt4OrdColumn, EncryptedInt4OrdOreColumn, - EncryptedInt8Column, - EncryptedInt8EqColumn, - EncryptedInt8OrdColumn, - EncryptedInt8OrdOreColumn, EncryptedNumericColumn, EncryptedNumericEqColumn, EncryptedNumericOrdColumn, @@ -64,10 +60,6 @@ import { encryptedInt4EqColumn, encryptedInt4OrdColumn, encryptedInt4OrdOreColumn, - encryptedInt8Column, - encryptedInt8EqColumn, - encryptedInt8OrdColumn, - encryptedInt8OrdOreColumn, encryptedNumericColumn, encryptedNumericEqColumn, encryptedNumericOrdColumn, @@ -150,38 +142,6 @@ const domainCases = [ { ore: {} }, { equality: true, orderAndRange: true, freeTextSearch: false }, ], - [ - 'eql_v3.int8', - encryptedInt8Column, - EncryptedInt8Column, - 'bigint', - {}, - { equality: false, orderAndRange: false, freeTextSearch: false }, - ], - [ - 'eql_v3.int8_eq', - encryptedInt8EqColumn, - EncryptedInt8EqColumn, - 'bigint', - { unique: { token_filters: [] } }, - { equality: true, orderAndRange: false, freeTextSearch: false }, - ], - [ - 'eql_v3.int8_ord_ore', - encryptedInt8OrdOreColumn, - EncryptedInt8OrdOreColumn, - 'bigint', - { ore: {} }, - { equality: true, orderAndRange: true, freeTextSearch: false }, - ], - [ - 'eql_v3.int8_ord', - encryptedInt8OrdColumn, - EncryptedInt8OrdColumn, - 'bigint', - { ore: {} }, - { equality: true, orderAndRange: true, freeTextSearch: false }, - ], [ 'eql_v3.date', encryptedDateColumn, @@ -670,16 +630,32 @@ describe('eql_v3 buildEncryptConfig', () => { // `column.getName()`, so keying by the JS property name makes the FFI // report "column not found in Encrypt config" at encrypt time. const users = encryptedTable('accounts', { - externalId: encryptedInt8Column('external_id'), createdOn: encryptedDateColumn('created_on'), + lastSeen: encryptedTimestamptzColumn('last_seen'), }) const config = buildEncryptConfig(users) expect(Object.keys(config.tables.accounts).sort()).toEqual([ 'created_on', - 'external_id', + 'last_seen', ]) - expect(config.tables.accounts).not.toHaveProperty('externalId') expect(config.tables.accounts).not.toHaveProperty('createdOn') + expect(config.tables.accounts).not.toHaveProperty('lastSeen') + }) + + it('buildColumnKeyMap maps JS property → DB column name', () => { + // The model path matches user models by JS property but must address the + // FFI/config by DB name. `build()` discards the property→name relationship + // (it keys by DB name); `buildColumnKeyMap()` recovers it. + const users = encryptedTable('accounts', { + createdOn: encryptedDateColumn('created_on'), + lastSeen: encryptedTimestamptzColumn('last_seen'), + email: encryptedTextSearchColumn('email'), + }) + expect(users.buildColumnKeyMap()).toEqual({ + createdOn: 'created_on', + lastSeen: 'last_seen', + email: 'email', + }) }) it('throws when two tables share the same tableName (no silent drop)', () => { diff --git a/packages/stack/__tests__/typed-client-v3.test-d.ts b/packages/stack/__tests__/typed-client-v3.test-d.ts index fe7cf35d..54bfca0c 100644 --- a/packages/stack/__tests__/typed-client-v3.test-d.ts +++ b/packages/stack/__tests__/typed-client-v3.test-d.ts @@ -4,7 +4,6 @@ import type { EncryptionClient } from '@/encryption' // from src/encryption/v3.ts), exercising the re-export at the same time. import { encryptedInt4OrdColumn, - encryptedInt8Column, encryptedTable, encryptedTextColumn, encryptedTextEqColumn, @@ -22,7 +21,6 @@ const users = encryptedTable('users', { bio: encryptedTextSearchColumn('bio'), // equality + order + free-text note: encryptedTextColumn('note'), // storage only (not queryable) createdAt: encryptedTimestamptzOrdColumn('created_at'), // equality + order - id64: encryptedInt8Column('id64'), // storage-only bigint }) // A second registered table whose `weight` domain (int4_ord) is NOT present in @@ -39,11 +37,6 @@ describe('typed v3 client — encrypt plaintext is pinned to the column domain', table: users, column: users.email, }) - // int8 domains use `string` plaintext until the native FFI supports bigint. - expectTypeOf(client.encrypt).toBeCallableWith('1', { - table: users, - column: users.id64, - }) expectTypeOf(client.encrypt).toBeCallableWith(new Date(), { table: users, column: users.createdAt, @@ -138,17 +131,16 @@ describe('typed v3 client — model encrypt validates schema fields', () => { describe('typed v3 client — model decrypt yields precise plaintext', () => { it('reconstructs schema columns to their plaintext type regardless of the input field type', () => { // Input is the encrypted row; output pins each schema column to its plaintext - // type (Date for timestamptz, bigint for int8, string for text). + // type (Date for timestamptz, string for text). expectTypeOf< V3DecryptedModel< typeof users, - { id: string; email: Encrypted; createdAt: Encrypted; id64: Encrypted } + { id: string; email: Encrypted; createdAt: Encrypted } > >().toEqualTypeOf<{ id: string email: string createdAt: Date - id64: string }>() }) diff --git a/packages/stack/__tests__/typed-client-v3.test.ts b/packages/stack/__tests__/typed-client-v3.test.ts index d96b0e3e..96754cdc 100644 --- a/packages/stack/__tests__/typed-client-v3.test.ts +++ b/packages/stack/__tests__/typed-client-v3.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' import { - encryptedInt8Column, + encryptedDateColumn, encryptedTable, encryptedTextColumn, encryptedTimestamptzColumn, @@ -10,8 +10,10 @@ import { const table = encryptedTable('t', { when: encryptedTimestamptzColumn('when'), - big: encryptedInt8Column('big'), note: encryptedTextColumn('note'), + // camelCase JS property → snake_case DB name: reconstruction must key by the + // JS property (how the decrypted row is keyed), not the DB column name. + createdOn: encryptedDateColumn('created_on'), }) /** @@ -27,12 +29,12 @@ function fakeClient(data: Record<string, unknown>): EncryptionClient { } describe('typedClient — decrypt reconstruction', () => { - it('reconstructs Date and bigint columns from cast_as', async () => { + it('reconstructs Date columns from cast_as', async () => { const client = typedClient( fakeClient({ when: '2020-01-02T03:04:05.000Z', - big: '42', note: 'hi', + createdOn: '2026-07-01T00:00:00.000Z', }), table, ) @@ -44,28 +46,29 @@ describe('typedClient — decrypt reconstruction', () => { const data = result.data as Record<string, unknown> expect(data.when).toBeInstanceOf(Date) expect((data.when as Date).toISOString()).toBe('2020-01-02T03:04:05.000Z') - expect(data.big).toBe(42n) + // Reconstructed by JS property (`createdOn`), though the DB column is + // `created_on` — a regression here would leave it an unparsed string. + expect(data.createdOn).toBeInstanceOf(Date) + expect((data.createdOn as Date).toISOString()).toBe( + '2026-07-01T00:00:00.000Z', + ) expect(data.note).toBe('hi') // string column untouched }) it('leaves null column values untouched', async () => { - const client = typedClient( - fakeClient({ when: null, big: null, note: null }), - table, - ) + const client = typedClient(fakeClient({ when: null, note: null }), table) const result = await client.decryptModel({}, table) if (result.failure) return const data = result.data as Record<string, unknown> expect(data.when).toBeNull() - expect(data.big).toBeNull() expect(data.note).toBeNull() }) it('reconstructs each row for bulkDecryptModels', async () => { const client = typedClient( - fakeClient({ when: '2021-06-01T00:00:00.000Z', big: '7', note: 'x' }), + fakeClient({ when: '2021-06-01T00:00:00.000Z', note: 'x' }), table, ) @@ -75,7 +78,6 @@ describe('typedClient — decrypt reconstruction', () => { const rows = result.data as Array<Record<string, unknown>> expect(rows).toHaveLength(1) expect(rows[0].when).toBeInstanceOf(Date) - expect(rows[0].big).toBe(7n) }) it('propagates a failure result unchanged', async () => { diff --git a/packages/stack/src/encryption/helpers/infer-index-type.ts b/packages/stack/src/encryption/helpers/infer-index-type.ts index f77829a4..fb6a9a54 100644 --- a/packages/stack/src/encryption/helpers/infer-index-type.ts +++ b/packages/stack/src/encryption/helpers/infer-index-type.ts @@ -39,12 +39,12 @@ export function inferQueryOpFromPlaintext(plaintext: Plaintext): QueryOpName { if (typeof plaintext === 'string') { return 'ste_vec_selector' } - // Objects, arrays, numbers, booleans are all valid JSONB containment values + // Objects (incl. Date), arrays, numbers, booleans are all valid JSONB + // containment values if ( typeof plaintext === 'object' || typeof plaintext === 'number' || - typeof plaintext === 'boolean' || - typeof plaintext === 'bigint' + typeof plaintext === 'boolean' ) { return 'ste_vec_term' } diff --git a/packages/stack/src/encryption/helpers/model-helpers.ts b/packages/stack/src/encryption/helpers/model-helpers.ts index 7864ad1e..17da7dc7 100644 --- a/packages/stack/src/encryption/helpers/model-helpers.ts +++ b/packages/stack/src/encryption/helpers/model-helpers.ts @@ -204,6 +204,32 @@ function prepareFieldsForDecryption<T extends Record<string, unknown>>( /** * Helper function to prepare fields for encryption */ +/** + * Resolve how a table's model fields map onto encrypt-config columns. + * + * `columnPaths` are the keys used to MATCH a user model's fields (the JS + * property names); `toColumnName` maps a matched field to the name the FFI / + * encrypt config is keyed by (the DB name). + * + * When a table exposes `buildColumnKeyMap()` (v3), those two can differ, so we + * match by property but address by DB name. Otherwise (v2) `build()` already + * keys columns by the property name, so both are that same key (identity map). + */ +export function resolveEncryptColumnMap(table: BuildableTable): { + columnPaths: string[] + toColumnName: (path: string) => string +} { + const keyMap = table.buildColumnKeyMap?.() + if (keyMap) { + return { + columnPaths: Object.keys(keyMap), + toColumnName: (path) => keyMap[path] ?? path, + } + } + const columnPaths = Object.keys(table.build().columns) + return { columnPaths, toColumnName: (path) => path } +} + function prepareFieldsForEncryption<T extends Record<string, unknown>>( model: T, table: BuildableTable, @@ -263,8 +289,8 @@ function prepareFieldsForEncryption<T extends Record<string, unknown>>( } } - // Get all column paths from the table schema - const columnPaths = Object.keys(table.build().columns) + // Get all column paths from the table schema (matched by JS property name). + const { columnPaths } = resolveEncryptColumnMap(table) processNestedFields(model, '', columnPaths) return { otherFields, operationFields, keyMap, nullFields } @@ -336,12 +362,13 @@ export async function encryptModelFields( const { otherFields, operationFields, keyMap, nullFields } = prepareFieldsForEncryption(model, table) + const { toColumnName } = resolveEncryptColumnMap(table) const bulkEncryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, plaintext: value as string, table: table.tableName, - column: key, + column: toColumnName(key), }), ) @@ -452,12 +479,13 @@ export async function encryptModelFieldsWithLockContext( const { otherFields, operationFields, keyMap, nullFields } = prepareFieldsForEncryption(model, table) + const { toColumnName } = resolveEncryptColumnMap(table) const bulkEncryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, plaintext: value as string, table: table.tableName, - column: key, + column: toColumnName(key), lockContext, }), ) @@ -559,8 +587,8 @@ function prepareBulkModelsForOperation<T extends Record<string, unknown>>( } if (table) { - // Get all column paths from the table schema - const columnPaths = Object.keys(table.build().columns) + // Get all column paths from the table schema (matched by JS property name). + const { columnPaths } = resolveEncryptColumnMap(table) processNestedFields(model, '', columnPaths) } else { // For decryption, process all encrypted fields @@ -636,12 +664,13 @@ export async function bulkEncryptModels( const { otherFields, operationFields, keyMap, nullFields } = prepareBulkModelsForOperation(models, table) + const { toColumnName } = resolveEncryptColumnMap(table) const bulkEncryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, plaintext: value as string, table: table.tableName, - column: key, + column: toColumnName(key), })), ) @@ -846,12 +875,13 @@ export async function bulkEncryptModelsWithLockContext( const { otherFields, operationFields, keyMap, nullFields } = prepareBulkModelsForOperation(models, table) + const { toColumnName } = resolveEncryptColumnMap(table) const bulkEncryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, plaintext: value as string, table: table.tableName, - column: key, + column: toColumnName(key), lockContext, })), ) diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index db6fba33..bff929f7 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -37,14 +37,14 @@ import { * Every method derives its types from the concrete `table` / `column` builder * arguments (which carry their branded types at the call site), so: * - `encrypt` / `encryptQuery` pin the plaintext to the column's domain type - * (`text → string`, `int8 → bigint`, `timestamptz → Date`, …); + * (`text → string`, `timestamptz → Date`, …); * - `encryptQuery` additionally constrains `queryType` to the column's * capabilities and rejects storage-only columns outright; * - `encryptModel` / `bulkEncryptModels` validate schema-column fields against * their inferred plaintext type (passthrough fields are untouched) and return * a precise encrypted model; * - `decryptModel` / `bulkDecryptModels` return the precise plaintext model, - * reconstructing `Date` / `bigint` values from the encrypt-config `cast_as`. + * reconstructing `Date` values from the encrypt-config `cast_as`. * * @typeParam S - the tuple of registered v3 tables; `table` arguments must be a * member of this tuple. @@ -87,7 +87,7 @@ export interface TypedEncryptionClient<S extends readonly AnyV3Table[]> { /** * Decrypt a model, returning the precise plaintext shape for `table`. `Date` - * and `bigint` columns are reconstructed from the encrypt-config `cast_as`. + * columns are reconstructed from the encrypt-config `cast_as`. * * Pass `lockContext` to decrypt identity-bound data — the same context that * was supplied at encrypt time must be provided here. @@ -117,25 +117,29 @@ export interface TypedEncryptionClient<S extends readonly AnyV3Table[]> { } /** - * Reconstruct `Date` / `bigint` values on a decrypted row from the table's - * encrypt-config `cast_as`. The FFI returns `JsPlaintext` (string/number/boolean/ - * …) with no `Date` / `bigint`, so those columns arrive as their serialized form - * and are rebuilt here. Safe (idempotent) if the FFI ever returns `Date` / - * `bigint` directly: `new Date(date)` / `BigInt(bigint)` are no-ops. + * Reconstruct `Date` values on a decrypted row from the table's encrypt-config + * `cast_as`. The FFI returns `JsPlaintext` (string/number/boolean/…) with no + * `Date`, so those columns arrive as their serialized form and are rebuilt here. + * Safe (idempotent) if the FFI ever returns `Date` directly: `new Date(date)` is + * a no-op. + * + * NOTE: `bigint` (int8) reconstruction is intentionally absent — int8 domains are + * omitted from the v3 SDK until the native FFI supports lossless bigint I/O. */ function reconstructRow( row: Record<string, unknown>, table: AnyV3Table, ): Record<string, unknown> { + // The decrypted row is keyed by JS property name, but `cast_as` lives on the + // config keyed by DB name — bridge the two via the table's property→DB map. const { columns } = table.build() + const propToDb = table.buildColumnKeyMap() const out: Record<string, unknown> = { ...row } - for (const [key, schema] of Object.entries(columns)) { - const value = out[key] + for (const [property, dbName] of Object.entries(propToDb)) { + const value = out[property] if (value == null) continue - if (schema.cast_as === 'date') { - out[key] = new Date(value as string | number | Date) - } else if (schema.cast_as === 'bigint') { - out[key] = BigInt(value as string | number | bigint) + if (columns[dbName]?.cast_as === 'date') { + out[property] = new Date(value as string | number | Date) } } return out @@ -145,7 +149,7 @@ function reconstructRow( * Wrap an already-built {@link EncryptionClient} in a {@link TypedEncryptionClient} * for the given v3 schemas. Zero runtime cost for the encrypt/query paths (the * underlying operations are returned unchanged); the decrypt-model paths add a - * per-column `Date` / `bigint` reconstruction step. + * per-column `Date` reconstruction step. * * The `schemas` are captured with a `const` type parameter so array-literal * widening does not collapse per-table inference. @@ -166,17 +170,13 @@ export function typedClient<const S extends readonly AnyV3Table[]>( decrypt: (encrypted) => client.decrypt(encrypted), decryptModel: async (input, table, lockContext) => { const op = client.decryptModel(input as never) - const result = await (lockContext - ? op.withLockContext(lockContext) - : op) + const result = await (lockContext ? op.withLockContext(lockContext) : op) if (result.failure) return result as never return { data: reconstructRow(result.data, table) } as never }, bulkDecryptModels: async (input, table, lockContext) => { const op = client.bulkDecryptModels(input as never) - const result = await (lockContext - ? op.withLockContext(lockContext) - : op) + const result = await (lockContext ? op.withLockContext(lockContext) : op) if (result.failure) return result as never return { data: result.data.map((row) => diff --git a/packages/stack/src/schema/v3/index.ts b/packages/stack/src/schema/v3/index.ts index 65e126a3..c1f732aa 100644 --- a/packages/stack/src/schema/v3/index.ts +++ b/packages/stack/src/schema/v3/index.ts @@ -19,7 +19,7 @@ export type QueryCapabilities = Readonly<{ /** The plaintext (TypeScript) kind a v3 domain decrypts to. A subset of the * SDK `CastAs` enum, restricted to the scalar kinds v3 domains actually use. */ -type PlaintextKind = 'string' | 'number' | 'bigint' | 'boolean' | 'date' +type PlaintextKind = 'string' | 'number' | 'boolean' | 'date' /** * The full, literal definition of a v3 domain. This is the LOAD-BEARING type: @@ -27,7 +27,7 @@ type PlaintextKind = 'string' | 'number' | 'bigint' | 'boolean' | 'date' * concrete (otherwise-empty) subclass is discriminated by its literal * `eqlType`/`castAs`/`capabilities` — TypeScript empty subclasses are NOT * nominal, so without this a storage-only `bool` column would be assignable to - * a storage-only `int8` column and plaintext inference would collapse. + * a storage-only `date` column and plaintext inference would collapse. */ type V3DomainDefinition = Readonly<{ eqlType: `eql_v3.${string}` @@ -128,26 +128,12 @@ const INT2_ORD = { capabilities: ORDER_AND_RANGE, } as const -const INT8 = { - eqlType: 'eql_v3.int8', - castAs: 'bigint', - capabilities: STORAGE_ONLY, -} as const -const INT8_EQ = { - eqlType: 'eql_v3.int8_eq', - castAs: 'bigint', - capabilities: EQUALITY_ONLY, -} as const -const INT8_ORD_ORE = { - eqlType: 'eql_v3.int8_ord_ore', - castAs: 'bigint', - capabilities: ORDER_AND_RANGE, -} as const -const INT8_ORD = { - eqlType: 'eql_v3.int8_ord', - castAs: 'bigint', - capabilities: ORDER_AND_RANGE, -} as const +// NOTE: int8 (bigint) domains are intentionally NOT defined yet. The native +// protect-ffi build cannot round-trip a 64-bit int losslessly: a JS `bigint` +// fails JSON serialization, and a `string` is rejected for a `big_int` column +// ("Cannot convert String to BigInt"), while `number` loses precision above +// 2^53. Re-add INT8/INT8_EQ/INT8_ORD_ORE/INT8_ORD and their builders once the +// FFI accepts a lossless bigint on input and returns it on decrypt. const DATE = { eqlType: 'eql_v3.date', @@ -376,7 +362,7 @@ function isQueryableCapabilities(capabilities: QueryCapabilities): boolean { * literal {@link V3DomainDefinition} (not by capabilities alone): the private * `definition` field carries the literal `eqlType`/`castAs`/`capabilities`, so * two otherwise-empty subclasses (e.g. `EncryptedBoolColumn` and - * `EncryptedInt8Column`, both storage-only) are NOT mutually assignable. This + * `EncryptedDateColumn`, both storage-only) are NOT mutually assignable. This * nominality is what keeps plaintext inference precise. */ class EncryptedV3Column<D extends V3DomainDefinition> { @@ -562,26 +548,9 @@ export class EncryptedInt2OrdColumn extends EncryptedV3Column< export const encryptedInt2OrdColumn = (columnName: string) => new EncryptedInt2OrdColumn(columnName, INT2_ORD) -// int8 -export class EncryptedInt8Column extends EncryptedV3Column<typeof INT8> {} -export const encryptedInt8Column = (columnName: string) => - new EncryptedInt8Column(columnName, INT8) - -export class EncryptedInt8EqColumn extends EncryptedV3Column<typeof INT8_EQ> {} -export const encryptedInt8EqColumn = (columnName: string) => - new EncryptedInt8EqColumn(columnName, INT8_EQ) - -export class EncryptedInt8OrdOreColumn extends EncryptedV3Column< - typeof INT8_ORD_ORE -> {} -export const encryptedInt8OrdOreColumn = (columnName: string) => - new EncryptedInt8OrdOreColumn(columnName, INT8_ORD_ORE) - -export class EncryptedInt8OrdColumn extends EncryptedV3Column< - typeof INT8_ORD -> {} -export const encryptedInt8OrdColumn = (columnName: string) => - new EncryptedInt8OrdColumn(columnName, INT8_ORD) +// int8 (bigint) domain builders are intentionally omitted pending FFI support +// for lossless bigint round-tripping — see the note by the INT4/DATE domain +// definitions above. // date export class EncryptedDateColumn extends EncryptedV3Column<typeof DATE> {} @@ -743,10 +712,6 @@ export type AnyEncryptedV3Column = | EncryptedInt2EqColumn | EncryptedInt2OrdOreColumn | EncryptedInt2OrdColumn - | EncryptedInt8Column - | EncryptedInt8EqColumn - | EncryptedInt8OrdOreColumn - | EncryptedInt8OrdColumn | EncryptedDateColumn | EncryptedDateEqColumn | EncryptedDateOrdOreColumn @@ -807,8 +772,8 @@ export class EncryptedTable<T extends EncryptedV3TableColumn> { // Key by the column's DB name (`getName()`), NOT the JS property name. // `encrypt`/`decrypt` look columns up in the config by `column.getName()`, // so a camelCase JS key mapping to a snake_case DB column (e.g. - // `externalId: encryptedInt8Column('external_id')`) must register under - // `external_id` or the FFI reports "column not found in Encrypt config". + // `createdOn: encryptedDateColumn('created_on')`) must register under + // `created_on` or the FFI reports "column not found in Encrypt config". builtColumns[builder.getName()] = builder.build() } return { @@ -816,6 +781,21 @@ export class EncryptedTable<T extends EncryptedV3TableColumn> { columns: builtColumns, } } + + /** + * Map each column's JS property name to its DB column name (`getName()`). + * The model path matches user models by property name but must address the + * encrypt config and FFI by DB name — `build()` keys columns by DB name, so + * the two only agree when property == name. This recovers the mapping that + * `build()` discards. + */ + buildColumnKeyMap(): Record<string, string> { + const map: Record<string, string> = {} + for (const [property, builder] of Object.entries(this.columnBuilders)) { + map[property] = builder.getName() + } + return map + } } /** @@ -831,6 +811,7 @@ const RESERVED_TABLE_KEYS = new Set([ 'columnBuilders', '_columnType', 'build', + 'buildColumnKeyMap', ]) /** @@ -903,19 +884,11 @@ type PlaintextFromKind<K extends PlaintextKind> = K extends 'string' ? string : K extends 'number' ? number - : K extends 'bigint' - ? // int8 domains accept/return `string` until the native FFI supports - // bigint I/O. The domain's `cast_as` stays `'bigint'` → `'big_int'`, so - // server-side casting is unchanged; only the JS plaintext type differs. - // `string` is lossless across the full int8 range (`number` would - // corrupt values above 2^53). Revert to `bigint` once the FFI accepts - // it on input and returns it on decrypt. - string - : K extends 'boolean' - ? boolean - : K extends 'date' - ? Date - : never + : K extends 'boolean' + ? boolean + : K extends 'date' + ? Date + : never /** * The plaintext type for a single v3 column, read from the literal domain diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 099203ba..1a1dad06 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -73,10 +73,9 @@ export type EncryptedQuery = CipherStashEncryptedQuery * * `bigint` is intentionally NOT included: the native `@cipherstash/protect-ffi` * build cannot marshal a JS `bigint` (V8 throws "Do not know how to serialize a - * BigInt"), and `decrypt` has no `castAs` context to reconstruct one on the way - * out. v3 int8 domains therefore use `string` plaintext (lossless across the - * full int8 range) for now. `bigint` returns once the upstream FFI input union - * supports it on input and returns it on decrypt. + * BigInt") and rejects a `string` for a `big_int` column. The v3 int8 domains + * are therefore omitted from the SDK entirely (see `schema/v3`) until the FFI + * supports lossless bigint I/O; `bigint` returns here alongside them. * * When the upstream FFI `JsPlaintext` is corrected to include `Date`, the `Date` * arm can collapse back into `JsPlaintext`. @@ -183,6 +182,17 @@ export type BuildableQueryColumn = EncryptedColumn | BuildableV3QueryableColumn export interface BuildableTable { tableName: string build(): { tableName: string; columns: Record<string, ColumnSchema> } + /** + * Optional map from a model field's JS property name to its encrypt-config + * column name (the DB name). Present when the two can differ — v3 tables key + * their config by DB name (`column.getName()`) while models are written with + * JS property keys, so the model path must match by property but address the + * FFI/config by DB name. + * + * Absent on v2 tables, whose `build()` already keys columns by the JS property + * name; the model path then matches and addresses by that same key. + */ + buildColumnKeyMap?(): Record<string, string> } export type EncryptionClientConfig = { From dce98840a3f28c0ec56a0cd3f35177a43864c24f Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 1 Jul 2026 14:49:27 +1000 Subject: [PATCH 2/2] ci(stack): add blocking FTA complexity gate for EQL v3 Add a per-package Fast TypeScript Analyzer (fta-cli) gate scoped to the EQL v3 text-search schema source (packages/stack/src/schema/v3). The gate fails CI when any v3 file exceeds the FTA score cap. - pin fta-cli@3.0.0 as a stack devDependency (repo installs tooling via frozen-lockfile; no pnpm dlx/npx per supply-chain policy) - add analyze:complexity script: fta src/schema/v3 --score-cap 72 (current v3 score is 71.08, so the cap blocks regressions) - add paths-filtered blocking workflow .github/workflows/fta-v3.yml; no build/DB/credentials needed (FTA is static source analysis) --- .github/workflows/fta-v3.yml | 60 ++++++++++++++++++++++++++++++++++++ packages/stack/package.json | 2 ++ pnpm-lock.yaml | 9 ++++++ 3 files changed, 71 insertions(+) create mode 100644 .github/workflows/fta-v3.yml diff --git a/.github/workflows/fta-v3.yml b/.github/workflows/fta-v3.yml new file mode 100644 index 00000000..3ee52b01 --- /dev/null +++ b/.github/workflows/fta-v3.yml @@ -0,0 +1,60 @@ +name: "FTA Complexity (EQL v3)" + +# Blocking complexity gate for the EQL v3 text-search schema. Runs the Fast +# TypeScript Analyzer (fta-cli) against the v3 source directory only and fails +# the check when any file exceeds the score cap (`--score-cap` in the +# `analyze:complexity` script). FTA is pure static source analysis, so this job +# needs no build step, database, or credentials. + +on: + push: + branches: + - 'main' + paths: + - 'packages/stack/src/schema/v3/**' + - 'packages/stack/package.json' + - '.github/workflows/fta-v3.yml' + pull_request: + branches: + - "**" + paths: + - 'packages/stack/src/schema/v3/**' + - 'packages/stack/package.json' + - '.github/workflows/fta-v3.yml' + +permissions: + contents: read + +jobs: + fta: + name: Analyze v3 complexity + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6.0.8 + name: Install pnpm + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: 'pnpm' + + # node-pty's install hook falls back to `node-gyp rebuild` when no + # linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships + # node-gyp on PATH, so install it explicitly. + - name: Install node-gyp + run: npm install -g node-gyp + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Non-zero exit (score above the cap) fails the check — this is the + # blocking gate. No `continue-on-error`. + - name: Analyze v3 complexity + run: pnpm --filter @cipherstash/stack run analyze:complexity diff --git a/packages/stack/package.json b/packages/stack/package.json index 99ce4fcd..9ce6281d 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -217,6 +217,7 @@ "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "tsup", "dev": "tsup --watch", + "analyze:complexity": "fta src/schema/v3 --score-cap 72", "db:eql-v3:install": "tsx scripts/install-eql-v3.ts", "test": "vitest run", "test:types": "vitest --run --typecheck.only", @@ -229,6 +230,7 @@ "dotenv": "17.4.2", "drizzle-orm": "^0.45.2", "execa": "^9.5.2", + "fta-cli": "3.0.0", "json-schema-to-typescript": "^15.0.2", "postgres": "^3.4.8", "tsup": "catalog:repo", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3297d549..a2d4c85b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -588,6 +588,9 @@ importers: execa: specifier: ^9.5.2 version: 9.6.1 + fta-cli: + specifier: 3.0.0 + version: 3.0.0 json-schema-to-typescript: specifier: ^15.0.2 version: 15.0.4 @@ -3013,6 +3016,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fta-cli@3.0.0: + resolution: {integrity: sha512-SBmoqIwbN7PLDmwmrPgjr6Z6/S9jPhNz5TCPmEVFkIaeloc/T2WXLeeXqhG1+C0UQxpOfGrC7CUb4friqbc2kQ==} + hasBin: true + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -6223,6 +6230,8 @@ snapshots: fsevents@2.3.3: optional: true + fta-cli@3.0.0: {} + function-bind@1.1.2: {} gel@2.2.0: