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
28 changes: 28 additions & 0 deletions packages/stack/__tests__/helpers/live-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe } from 'vitest'

/**
* Shared env gates for the live (network-touching) suites. Previously each
* live suite re-declared these; one definition keeps the credential list —
* and therefore what "live" means — from drifting between files.
*
* Callers must `import 'dotenv/config'` BEFORE importing this module (all
* live suites already do, as their first import) so the env is populated
* when these are evaluated.
*/

/** True when live CipherStash (ZeroKMS/CTS) credentials are configured. */
export const LIVE_CIPHERSTASH_ENABLED = Boolean(
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY &&
process.env.CS_CLIENT_ACCESS_KEY,
)

/** True when live credentials AND a Postgres `DATABASE_URL` are configured. */
export const LIVE_EQL_V3_PG_ENABLED = Boolean(
process.env.DATABASE_URL && LIVE_CIPHERSTASH_ENABLED,
)

export const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip

export const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip
10 changes: 1 addition & 9 deletions packages/stack/__tests__/schema-v3-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { typedClient } from '@/encryption/v3'
import { encryptedTable, types } from '@/eql/v3'
import { Encryption } from '@/index'
import { unwrapResult } from './fixtures'
import { describeLive, LIVE_CIPHERSTASH_ENABLED } from './helpers/live-gate'

const users = encryptedTable('schema_v3_client_users', {
email: types.TextSearch('email'),
Expand All @@ -20,15 +21,6 @@ const users = encryptedTable('schema_v3_client_users', {
occurredAt: types.Timestamptz('occurred_at'),
})

const LIVE_CIPHERSTASH_ENABLED = Boolean(
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY &&
process.env.CS_CLIENT_ACCESS_KEY,
)

const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip

describeLive('eql_v3 client integration', () => {
let protectClient: EncryptionClient

Expand Down
11 changes: 1 addition & 10 deletions packages/stack/__tests__/schema-v3-pg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,7 @@ import { Encryption } from '@/index'
import type { Encrypted } from '@/types'
import { unwrapResult } from './fixtures'
import { installEqlV3IfNeeded } from './helpers/eql-v3'

const LIVE_EQL_V3_PG_ENABLED = Boolean(
process.env.DATABASE_URL &&
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY &&
process.env.CS_CLIENT_ACCESS_KEY,
)

const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip
import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from './helpers/live-gate'

const databaseUrl = process.env.DATABASE_URL
const sql = LIVE_EQL_V3_PG_ENABLED
Expand Down
9 changes: 1 addition & 8 deletions packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,7 @@ import 'dotenv/config'
import { beforeAll, describe, expect, it } from 'vitest'
import { EncryptionV3, encryptedTable, types } from '@/encryption/v3'
import { unwrapResult } from '../fixtures'

const LIVE_CIPHERSTASH_ENABLED = Boolean(
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY &&
process.env.CS_CLIENT_ACCESS_KEY,
)
const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip
import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate'

const people = encryptedTable('v3_bulk_people', {
nickname: types.TextEq('nickname'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { EncryptionV3, encryptedTable, types } from '@/encryption/v3'
import { LockContext } from '@/identity'
import { unwrapResult } from '../fixtures'

const LIVE_CIPHERSTASH_ENABLED = Boolean(
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY &&
process.env.CS_CLIENT_ACCESS_KEY,
)
const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip
import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate'

const users = encryptedTable('v3_identity_live_users', {
email: types.TextEq('email'),
Expand Down
9 changes: 1 addition & 8 deletions packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,12 @@ import { ensureKeyset } from '@cipherstash/protect-ffi'
import { beforeAll, describe, expect, it } from 'vitest'
import { EncryptionV3, encryptedTable, types } from '@/encryption/v3'
import { unwrapResult } from '../fixtures'
import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate'

const users = encryptedTable('v3_keyset_users', {
email: types.TextEq('email'),
})

const LIVE_CIPHERSTASH_ENABLED = Boolean(
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY &&
process.env.CS_CLIENT_ACCESS_KEY,
)
const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip

describe('EncryptionV3 keyset config (deterministic)', () => {
it('rejects an invalid keyset id before touching the network', async () => {
await expect(
Expand Down
9 changes: 1 addition & 8 deletions packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { EncryptionV3, encryptedTable } from '@/encryption/v3'
import { unwrapResult } from '../fixtures'
import { installEqlV3IfNeeded } from '../helpers/eql-v3'
import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from '../helpers/live-gate'
import {
type DomainSpec,
type EqlV3TypeName,
typedEntries,
V3_MATRIX,
} from './catalog'

const LIVE_EQL_V3_PG_ENABLED = Boolean(
process.env.DATABASE_URL &&
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY &&
process.env.CS_CLIENT_ACCESS_KEY,
)
// Previously force-skipped (CI run 28569708268, PR #540): `beforeAll` crashed
// with `PostgresError: invalid input syntax for type json` on the dynamic
// 35-column INSERT. Root cause was a postgres.js serialization gap — a bare
Expand All @@ -58,7 +52,6 @@ const LIVE_EQL_V3_PG_ENABLED = Boolean(
// after the skip and the skip was simply left stale). Re-enabled here as an
// ordinary credential-gated suite: it runs in CI (which supplies DATABASE_URL +
// CS_* creds) and self-skips locally when they are absent.
const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip

const databaseUrl = process.env.DATABASE_URL
const sql = LIVE_EQL_V3_PG_ENABLED
Expand Down
9 changes: 1 addition & 8 deletions packages/stack/__tests__/v3-matrix/matrix-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,14 @@ import 'dotenv/config'
import { beforeAll, describe, expect, it } from 'vitest'
import { EncryptionV3, encryptedTable } from '@/encryption/v3'
import { unwrapResult } from '../fixtures'
import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate'
import {
type DomainSpec,
type EqlV3TypeName,
typedEntries,
V3_MATRIX,
} from './catalog'

const LIVE_CIPHERSTASH_ENABLED = Boolean(
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY &&
process.env.CS_CLIENT_ACCESS_KEY,
)
const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip

/** `eql_v3.int4_ord` → `int4_ord`: a valid, per-domain-unique column name. */
const slug = (t: EqlV3TypeName): string => t.replace('eql_v3.', '')

Expand Down
23 changes: 3 additions & 20 deletions packages/stack/src/encryption/operations/encrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type JsPlaintext,
} from '@cipherstash/protect-ffi'
import { getErrorCode } from '@/encryption/helpers/error-code'
import { assertValidNumericValue } from '@/encryption/helpers/validation'
import { type EncryptionError, EncryptionErrorTypes } from '@/errors'
import { type LockContextInput, resolveLockContext } from '@/identity'
import type {
Expand Down Expand Up @@ -71,19 +72,7 @@ export class EncryptOperation extends EncryptionOperation<Encrypted> {
return null as unknown as Encrypted
}

if (
typeof this.plaintext === 'number' &&
Number.isNaN(this.plaintext)
) {
throw new Error('[encryption]: Cannot encrypt NaN value')
}

if (
typeof this.plaintext === 'number' &&
!Number.isFinite(this.plaintext)
) {
throw new Error('[encryption]: Cannot encrypt Infinity value')
}
assertValidNumericValue(this.plaintext)

const { metadata } = this.getAuditData()

Expand Down Expand Up @@ -160,13 +149,7 @@ export class EncryptOperationWithLockContext extends EncryptionOperation<Encrypt
return null as unknown as Encrypted
}

if (typeof plaintext === 'number' && Number.isNaN(plaintext)) {
throw new Error('[encryption]: Cannot encrypt NaN value')
}

if (typeof plaintext === 'number' && !Number.isFinite(plaintext)) {
throw new Error('[encryption]: Cannot encrypt Infinity value')
}
assertValidNumericValue(plaintext)

const { metadata } = this.getAuditData()
const lockContext = resolveLockContext(this.lockContext)
Expand Down
41 changes: 25 additions & 16 deletions packages/stack/src/encryption/v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,32 +117,40 @@ export interface TypedEncryptionClient<S extends readonly AnyV3Table[]> {
}

/**
* 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.
* Build a per-row reconstructor of `Date` values 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.
*
* A factory rather than a `(row, table)` function so the table config —
* row-invariant, but non-trivial to build — is derived once per call site,
* not once per row on the bulk path.
*
* 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>,
function rowReconstructor(
table: AnyV3Table,
): Record<string, unknown> {
): (row: Record<string, unknown>) => 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 [property, dbName] of Object.entries(propToDb)) {
const value = out[property]
if (value == null) continue
if (columns[dbName]?.cast_as === 'date') {
// Only date columns need per-row work; resolve them up front.
const dateProperties = Object.entries(propToDb)
.filter(([, dbName]) => columns[dbName]?.cast_as === 'date')
.map(([property]) => property)

return (row) => {
const out: Record<string, unknown> = { ...row }
for (const property of dateProperties) {
const value = out[property]
if (value == null) continue
out[property] = new Date(value as string | number | Date)
}
return out
}
return out
}

/**
Expand Down Expand Up @@ -172,15 +180,16 @@ export function typedClient<const S extends readonly AnyV3Table[]>(
const op = client.decryptModel(input as never)
const result = await (lockContext ? op.withLockContext(lockContext) : op)
if (result.failure) return result as never
return { data: reconstructRow(result.data, table) } as never
return { data: rowReconstructor(table)(result.data) } as never
},
bulkDecryptModels: async (input, table, lockContext) => {
const op = client.bulkDecryptModels(input as never)
const result = await (lockContext ? op.withLockContext(lockContext) : op)
if (result.failure) return result as never
const reconstruct = rowReconstructor(table)
return {
data: result.data.map((row) =>
reconstructRow(row as Record<string, unknown>, table),
reconstruct(row as Record<string, unknown>),
),
} as never
},
Expand Down
Loading
Loading