Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/eql-v3-ffi-0-28-concrete-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/stack': minor
---

Upgrade `@cipherstash/protect-ffi` to 0.28.0 and update EQL v3 concrete Postgres domain names to match the SQL fixture (`integer*`, `smallint*`, `bool`, `real*`, and `double*`). The public factories remain semantic (`Integer`, `Smallint`, `Boolean`, `Real`, `Double`) while their concrete domains change, so this is a minor release.
6 changes: 3 additions & 3 deletions .changeset/eql-v3-typed-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@ Every method derives its types from the concrete `table` / `column` builder
arguments:

- `encrypt` / `encryptQuery` pin the plaintext to the column's domain type
(`text → string`, `int8 → bigint`, `timestamp → Date`, …).
(`text → string`, `timestamp → Date`, …).
- `encryptQuery` constrains `queryType` to the column's capabilities and rejects
storage-only columns at compile time.
- `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`.

Because the typed methods bind to the concrete branded v3 classes, a hand-rolled
structural table/column is rejected — closing the soundness gap where a non-branded
table could be encrypted at runtime while typed as plaintext.

Runtime behaviour is unchanged: the encrypt/query paths return the same operations
as the base client; only the model-decrypt paths add a per-column `Date` / `bigint`
as the base client; only the model-decrypt paths add a per-column `Date`
reconstruction step. The v2 client surface (`Encryption`) is untouched.
4 changes: 2 additions & 2 deletions .changeset/eql-v3-typed-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
'@cipherstash/stack': minor
---

Add EQL v3 schema builders for all generated SQL domains under `@cipherstash/stack/eql/v3`, exposed as the `types` namespace (one member per EQL v3 domain, e.g. `types.TextEq` / `types.Int4Ord` / `types.Timestamp`), including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`).
Add EQL v3 schema builders for supported generated SQL domains under `@cipherstash/stack/eql/v3`, exposed as the `types` namespace (one member per supported EQL v3 domain, e.g. `types.TextEq` / `types.IntegerOrd` / `types.Timestamp`), including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`).

Also widen the accepted plaintext input type for `encrypt` / `encryptQuery` to include `Date` and `bigint` (via the new `Plaintext` type), so v3 `date` / `timestamp` / `int8` domains can be encrypted and queried with their natural JavaScript values.
Also widen the accepted plaintext input type for `encrypt` / `encryptQuery` to include `Date` (via the new `Plaintext` type), so v3 `date` / `timestamp` domains can be encrypted and queried with their natural JavaScript values.
5 changes: 5 additions & 0 deletions .changeset/skip-v3-bigint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/stack': patch
---

Omit EQL v3 bigint factories from the public DSL and test matrix until native bigint round-tripping is supported.
2 changes: 1 addition & 1 deletion e2e/wasm/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"imports": {
"@cipherstash/stack/wasm-inline": "../../packages/stack/dist/wasm-inline.js",
"@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.26.0/wasm-inline",
"@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.28.0/wasm-inline",
"@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.40.0/wasm-inline"
}
}
2 changes: 1 addition & 1 deletion packages/stack/__tests__/cjs-require.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('CJS consumers can require the built bundles', () => {
`if (typeof v3.encryptedTable !== 'function') { throw new Error('missing v3 CJS export: encryptedTable') }`,
`if (typeof v3.buildEncryptConfig !== 'function') { throw new Error('missing v3 CJS export: buildEncryptConfig') }`,
`if (typeof v3.types !== 'object' || v3.types === null) { throw new Error('missing v3 CJS export: types namespace') }`,
`const requiredTypes = ['TextSearch', 'TextEq', 'Int4Ord', 'Bool', 'Timestamp']`,
`const requiredTypes = ['TextSearch', 'TextEq', 'IntegerOrd', 'Boolean', 'Timestamp']`,
`const missing = requiredTypes.filter((k) => typeof v3.types[k] !== 'function')`,
`if (missing.length > 0) { throw new Error('missing v3 types.* CJS members: ' + missing.join(', ')) }`,
].join('\n')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const users = encryptedTable('users', {
})

const usersV3 = encryptedTableV3('users_v3', {
score: types.Int4Ord('score'),
score: types.IntegerOrd('score'),
})

// biome-ignore lint/suspicious/noExplicitAny: test helper reads the Result union
Expand Down
31 changes: 18 additions & 13 deletions packages/stack/__tests__/error-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { EncryptionClient } from '@/encryption'
import { EncryptionErrorTypes } from '@/errors'
import { Encryption } from '@/index'
import { encryptedColumn, encryptedTable } from '@/schema'
import type { BulkDecryptPayload } from '@/types'

/** FFI tests require longer timeout due to client initialization */
const FFI_TEST_TIMEOUT = 30_000
Expand Down Expand Up @@ -246,26 +247,30 @@ describe('FFI Error Code Preservation', () => {

describe('bulkDecrypt error codes', () => {
it(
'returns undefined code for malformed ciphertexts (non-FFI validation)',
'returns per-item errors for malformed ciphertexts',
async () => {
// bulkDecrypt uses the "fallible" FFI API (decryptBulkFallible) which normally:
// - Succeeds at the operation level
// - Returns per-item results with either { data } or { error }
//
// However, malformed ciphertexts cause parsing errors BEFORE the fallible API,
// which throws and triggers a top-level failure (not per-item errors).
// These pre-FFI errors don't have structured FFI error codes.
// bulkDecrypt uses the fallible FFI API, so malformed items are
// reported per item instead of failing the whole operation.
const invalidCiphertexts = [
{ data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid1' } },
{ data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid2' } },
]
] as unknown as BulkDecryptPayload

const result = await protectClient.bulkDecrypt(invalidCiphertexts)

expect(result.failure).toBeDefined()
expect(result.failure?.type).toBe(EncryptionErrorTypes.DecryptionError)
// FFI parsing errors don't have structured error codes
expect(result.failure?.code).toBeUndefined()
if (result.failure) {
throw new Error(
`Expected per-item errors, got ${result.failure.type}`,
)
}

expect(result.data).toHaveLength(2)
expect(result.data.every((item) => 'error' in item)).toBe(true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gap: This rewrite now documents the fallible per-item behavior, but only for an all-malformed batch. The property that actually changed — a valid ciphertext still decrypts to { data } while a malformed sibling is isolated as { error } in the same call — is untested. This is the "one axis tested, discriminating axis not" anti-pattern: the all-invalid case would also pass under a hypothetical regression that fails the whole batch as soon as one item is bad.

it(
  'isolates malformed items without failing valid siblings',
  async () => {
    const good = await protectClient.encrypt('valid@example.com', {
      column: testSchema.email,
      table: testSchema,
    })
    if (good.failure) throw new Error(good.failure.message)

    const mixed = [
      { data: good.data },
      { data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid' } },
    ] as unknown as BulkDecryptPayload

    const result = await protectClient.bulkDecrypt(mixed)
    if (result.failure) {
      throw new Error(`Expected per-item errors, got ${result.failure.type}`)
    }

    expect(result.data).toHaveLength(2)
    expect('data' in result.data[0]).toBe(true) // valid item survives
    expect('error' in result.data[1]).toBe(true) // malformed item isolated
  },
  FFI_TEST_TIMEOUT,
)

Expected: the valid entry round-trips to { data: 'valid@example.com' } and the malformed entry carries a per-item { error }, proving failure isolation rather than the all-or-nothing case the current test covers.


for (const item of result.data) {
expect(item.error).toBeDefined()
expect('code' in item).toBe(false)
}
},
FFI_TEST_TIMEOUT,
)
Expand Down
Loading
Loading