Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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/fresh-phones-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cipherstash/stack": minor
---

Remove null support from encrypt and bulk encrypt operations to improve typescript support and reduce operation complexity.
1 change: 0 additions & 1 deletion docs/reference/model-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ The type system ensures:

- Schema-defined fields are typed as `Encrypted` in the return value
- Non-schema fields retain their original types
- Correct handling of optional and nullable fields
- Preservation of nested object structures

### Using explicit type parameters
Expand Down
3 changes: 1 addition & 2 deletions docs/reference/searchable-encryption-postgres.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ By default, `encryptQuery` returns an `Encrypted` object (the raw EQL JSON paylo
| `'composite-literal'` | `string` | PostgreSQL composite literal format `("json")`. Use with Supabase `.eq()` or other APIs that require a string value. |
| `'escaped-composite-literal'` | `string` | Escaped composite literal `"(\"json\")"`. Use when the query string will be embedded inside another string or JSON value. |

The return type of `encryptQuery` is `EncryptedQueryResult`, which is `Encrypted | string | null` depending on the `returnType` and whether the input was `null`.
The return type of `encryptQuery` is `EncryptedQueryResult`, which is `Encrypted | string` depending on the `returnType`.

**Single query with `returnType`:**

Expand Down Expand Up @@ -193,7 +193,6 @@ const documents = encryptedTable('documents', {
| `string` (e.g. `'$.user.email'`) | `steVecSelector` | JSONPath selector queries |
| `object` (e.g. `{ role: 'admin' }`) | `steVecTerm` | Containment queries |
| `array` (e.g. `['admin', 'user']`) | `steVecTerm` | Containment queries |
| `null` | Returns `null` | Null handling |

#### JSONPath selector queries

Expand Down
2 changes: 1 addition & 1 deletion packages/stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ All bulk methods make a single call to ZeroKMS regardless of the number of recor
const plaintexts = [
{ id: "u1", plaintext: "alice@example.com" },
{ id: "u2", plaintext: "bob@example.com" },
{ id: "u3", plaintext: null }, // null values are preserved
{ id: "u3", plaintext: "charlie@example.com" },
]

const encrypted = await client.bulkEncrypt(plaintexts, {
Expand Down
185 changes: 2 additions & 183 deletions packages/stack/__tests__/bulk-protect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,66 +96,8 @@ describe('bulk encryption and decryption', () => {
expect(encryptedData.data[2].data).toHaveProperty('c')
}, 30000)

it('should handle null values in bulk encrypt', async () => {
const plaintexts = [
{ id: 'user1', plaintext: 'alice@example.com' },
{ id: 'user2', plaintext: null },
{ id: 'user3', plaintext: 'charlie@example.com' },
]

const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
})

if (encryptedData.failure) {
throw new Error(`[protect]: ${encryptedData.failure.message}`)
}

// Verify structure
expect(encryptedData.data).toHaveLength(3)
expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
expect(encryptedData.data[0]).toHaveProperty('data')
expect(encryptedData.data[0].data).toHaveProperty('c')
expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
expect(encryptedData.data[1]).toHaveProperty('data')
expect(encryptedData.data[1].data).toBeNull()
expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
expect(encryptedData.data[2]).toHaveProperty('data')
expect(encryptedData.data[2].data).toHaveProperty('c')
}, 30000)

it('should handle all null values in bulk encrypt', async () => {
const plaintexts = [
{ id: 'user1', plaintext: null },
{ id: 'user2', plaintext: null },
{ id: 'user3', plaintext: null },
]

const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
})

if (encryptedData.failure) {
throw new Error(`[protect]: ${encryptedData.failure.message}`)
}

// Verify structure
expect(encryptedData.data).toHaveLength(3)
expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
expect(encryptedData.data[0]).toHaveProperty('data')
expect(encryptedData.data[0].data).toBeNull()
expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
expect(encryptedData.data[1]).toHaveProperty('data')
expect(encryptedData.data[1].data).toBeNull()
expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
expect(encryptedData.data[2]).toHaveProperty('data')
expect(encryptedData.data[2].data).toBeNull()
}, 30000)

it('should handle empty array in bulk encrypt', async () => {
const plaintexts: Array<{ id?: string; plaintext: string | null }> = []
const plaintexts: Array<{ id?: string; plaintext: string }> = []

const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
column: users.email,
Expand Down Expand Up @@ -245,77 +187,6 @@ describe('bulk encryption and decryption', () => {
)
}, 30000)

it('should handle null values in bulk decrypt', async () => {
// First encrypt some data with nulls
const plaintexts = [
{ id: 'user1', plaintext: 'alice@example.com' },
{ id: 'user2', plaintext: null },
{ id: 'user3', plaintext: 'charlie@example.com' },
]

const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
})

if (encryptedData.failure) {
throw new Error(`[protect]: ${encryptedData.failure.message}`)
}

// Now decrypt the data
const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)

if (decryptedData.failure) {
throw new Error(`[protect]: ${decryptedData.failure.message}`)
}

// Verify structure
expect(decryptedData.data).toHaveLength(3)
expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com')
expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
expect(decryptedData.data[1]).toHaveProperty('data', null)
expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
expect(decryptedData.data[2]).toHaveProperty(
'data',
'charlie@example.com',
)
}, 30000)

it('should handle all null values in bulk decrypt', async () => {
// First encrypt some data with all nulls
const plaintexts = [
{ id: 'user1', plaintext: null },
{ id: 'user2', plaintext: null },
{ id: 'user3', plaintext: null },
]

const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
})

if (encryptedData.failure) {
throw new Error(`[protect]: ${encryptedData.failure.message}`)
}

// Now decrypt the data
const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)

if (decryptedData.failure) {
throw new Error(`[protect]: ${decryptedData.failure.message}`)
}

// Verify structure
expect(decryptedData.data).toHaveLength(3)
expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
expect(decryptedData.data[0]).toHaveProperty('data', null)
expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
expect(decryptedData.data[1]).toHaveProperty('data', null)
expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
expect(decryptedData.data[2]).toHaveProperty('data', null)
}, 30000)

it('should handle empty array in bulk decrypt', async () => {
const encryptedPayloads: Array<{ id?: string; data: Encrypted }> = []

Expand Down Expand Up @@ -399,57 +270,6 @@ describe('bulk encryption and decryption', () => {
)
}, 30000)

it('should handle null values with lock context', async () => {
const userJwt = process.env.USER_JWT

if (!userJwt) {
console.log('Skipping lock context test - no USER_JWT provided')
return
}

const lc = new LockContext()
const lockContext = await lc.identify(userJwt)

if (lockContext.failure) {
throw new Error(`[protect]: ${lockContext.failure.message}`)
}

const plaintexts = [
{ id: 'user1', plaintext: 'alice@example.com' },
{ id: 'user2', plaintext: null },
{ id: 'user3', plaintext: 'charlie@example.com' },
]

// Encrypt with lock context
const encryptedData = await protectClient
.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
})
.withLockContext(lockContext.data)

if (encryptedData.failure) {
throw new Error(`[protect]: ${encryptedData.failure.message}`)
}

// Verify null is preserved
expect(encryptedData.data[1]).toHaveProperty('data')
expect(encryptedData.data[1].data).toBeNull()

// Decrypt with lock context
const decryptedData = await protectClient
.bulkDecrypt(encryptedData.data)
.withLockContext(lockContext.data)

if (decryptedData.failure) {
throw new Error(`[protect]: ${decryptedData.failure.message}`)
}

// Verify null is preserved
expect(decryptedData.data[1]).toHaveProperty('data')
expect(decryptedData.data[1].data).toBeNull()
}, 30000)

it('should decrypt mixed lock context payloads with specific lock context', async () => {
const userJwt = process.env.USER_JWT
const user2Jwt = process.env.USER_2_JWT
Expand Down Expand Up @@ -533,8 +353,7 @@ describe('bulk encryption and decryption', () => {
const originalData = [
{ id: 'user1', plaintext: 'alice@example.com' },
{ id: 'user2', plaintext: 'bob@example.com' },
{ id: 'user3', plaintext: null },
{ id: 'user4', plaintext: 'dave@example.com' },
{ id: 'user3', plaintext: 'dave@example.com' },
]

// Encrypt
Expand Down
Loading