Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/fta-v3.yml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions packages/stack/__tests__/model-column-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
76 changes: 52 additions & 24 deletions packages/stack/__tests__/schema-v3-client.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'),
})
Expand Down Expand Up @@ -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)
})
12 changes: 5 additions & 7 deletions packages/stack/__tests__/schema-v3.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import type {
} from '@/schema/v3'
import {
encryptedBoolColumn,
encryptedDateColumn,
encryptedFloat8Column,
encryptedInt4Column,
encryptedInt8Column,
encryptedTable,
encryptedTextColumn,
encryptedTextEqColumn,
Expand Down Expand Up @@ -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'),
Expand All @@ -80,21 +79,20 @@ describe('eql_v3 schema type inference', () => {
expectTypeOf<Plaintext>().toEqualTypeOf<{
name: string
age: number
id64: string
active: boolean
createdAt: Date
score: number
}>()
})

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
})
})
Expand Down
62 changes: 19 additions & 43 deletions packages/stack/__tests__/schema-v3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@ import {
EncryptedInt4EqColumn,
EncryptedInt4OrdColumn,
EncryptedInt4OrdOreColumn,
EncryptedInt8Column,
EncryptedInt8EqColumn,
EncryptedInt8OrdColumn,
EncryptedInt8OrdOreColumn,
EncryptedNumericColumn,
EncryptedNumericEqColumn,
EncryptedNumericOrdColumn,
Expand Down Expand Up @@ -64,10 +60,6 @@ import {
encryptedInt4EqColumn,
encryptedInt4OrdColumn,
encryptedInt4OrdOreColumn,
encryptedInt8Column,
encryptedInt8EqColumn,
encryptedInt8OrdColumn,
encryptedInt8OrdOreColumn,
encryptedNumericColumn,
encryptedNumericEqColumn,
encryptedNumericOrdColumn,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)', () => {
Expand Down
Loading
Loading