From ccef5bb35ab98ab13a9756fa208fec51639b8a36 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 29 Jan 2026 20:26:56 +1100 Subject: [PATCH] feat(protect): add encryptQuery API with batch support Add the new unified encryptQuery API: - EncryptQueryOperation for single value encryption - BatchEncryptQueryOperation for batch encryption - QuerySearchTermsOperation for legacy compatibility - Updated ProtectClient with encryptQuery() method overloads Supports: - Scalar terms with explicit queryType - JSON path queries (searchableJson) - JSON containment queries Includes comprehensive test coverage for all query types. --- .../__tests__/batch-encrypt-query.test.ts | 544 ++++++++++++++ .../protect/__tests__/encrypt-query.test.ts | 690 ++++++++++++++++++ packages/protect/src/ffi/index.ts | 214 +++++- .../src/ffi/operations/batch-encrypt-query.ts | 325 +++++++++ .../src/ffi/operations/encrypt-query.ts | 126 ++++ .../src/ffi/operations/query-search-terms.ts | 73 ++ packages/protect/src/index.ts | 128 +++- 7 files changed, 2054 insertions(+), 46 deletions(-) create mode 100644 packages/protect/__tests__/batch-encrypt-query.test.ts create mode 100644 packages/protect/__tests__/encrypt-query.test.ts create mode 100644 packages/protect/src/ffi/operations/batch-encrypt-query.ts create mode 100644 packages/protect/src/ffi/operations/encrypt-query.ts create mode 100644 packages/protect/src/ffi/operations/query-search-terms.ts diff --git a/packages/protect/__tests__/batch-encrypt-query.test.ts b/packages/protect/__tests__/batch-encrypt-query.test.ts new file mode 100644 index 00000000..c1af3c1f --- /dev/null +++ b/packages/protect/__tests__/batch-encrypt-query.test.ts @@ -0,0 +1,544 @@ +import 'dotenv/config' +import { csColumn, csTable } from '@cipherstash/schema' +import { beforeAll, describe, expect, it } from 'vitest' +import { LockContext, type QueryTerm, protect, type ProtectErrorCode } from '../src' +import { + expectHasHm, + expectSteVecArray, + expectJsonPathWithValue, + expectJsonPathSelectorOnly, + expectCompositeLiteralWithEncryption, +} from './test-utils/query-terms' + +const users = csTable('users', { + email: csColumn('email').freeTextSearch().equality().orderAndRange(), + score: csColumn('score').dataType('number').orderAndRange(), +}) + +const jsonSchema = csTable('json_users', { + metadata: csColumn('metadata').searchableJson(), +}) + +let protectClient: Awaited> + +beforeAll(async () => { + protectClient = await protect({ schemas: [users, jsonSchema] }) +}) + +describe('encryptQuery batch overload', () => { + it('should return empty array for empty input', async () => { + const result = await protectClient.encryptQuery([]) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toEqual([]) + }) + + it('should encrypt batch of scalar terms', async () => { + const terms: QueryTerm[] = [ + { + value: 'test@example.com', + column: users.email, + table: users, + queryType: 'equality', + }, + { value: 100, column: users.score, table: users, queryType: 'orderAndRange' }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(2) + expect(result.data[0]).toHaveProperty('hm') // unique returns HMAC + }) +}) + +describe('encryptQuery batch - JSON path queries', () => { + it('should encrypt JSON path query with value', async () => { + const terms: QueryTerm[] = [ + { + path: 'user.email', + value: 'test@example.com', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }) + + it('should encrypt JSON path query without value (selector only)', async () => { + const terms: QueryTerm[] = [ + { path: 'user.role', column: jsonSchema.metadata, table: jsonSchema }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }) +}) + +describe('encryptQuery batch - JSON containment queries', () => { + it('should encrypt JSON contains query', async () => { + const terms: QueryTerm[] = [ + { + contains: { role: 'admin' }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + // sv array length depends on FFI flattening implementation + expectSteVecArray(result.data[0] as { sv: Array> }) + }) + + it('should encrypt JSON containedBy query', async () => { + const terms: QueryTerm[] = [ + { + containedBy: { status: 'active' }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectSteVecArray(result.data[0] as { sv: Array> }) + }) +}) + +describe('encryptQuery batch - mixed term types', () => { + it('should encrypt mixed batch of scalar and JSON terms', async () => { + const terms: QueryTerm[] = [ + { + value: 'test@example.com', + column: users.email, + table: users, + queryType: 'equality', + }, + { + path: 'user.email', + value: 'json@example.com', + column: jsonSchema.metadata, + table: jsonSchema, + }, + { + contains: { role: 'admin' }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(3) + // First term: scalar unique - should have HMAC + expectHasHm(result.data[0] as { hm?: string }) + + // Second term: JSON path with value - should have selector and encrypted content + expectJsonPathWithValue(result.data[1] as Record) + + // Third term: JSON containment with sv array + expectSteVecArray(result.data[2] as { sv: Array> }) + }) +}) + +describe('encryptQuery batch - return type formatting', () => { + it('should format as composite-literal', async () => { + const terms: QueryTerm[] = [ + { + value: 'test@example.com', + column: users.email, + table: users, + queryType: 'equality', + returnType: 'composite-literal', + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expectCompositeLiteralWithEncryption( + result.data[0] as string, + (parsed) => expectHasHm(parsed as { hm?: string }) + ) + }) +}) + +describe('encryptQuery batch - readonly/as const support', () => { + it('should accept readonly array (as const)', async () => { + const terms = [ + { + value: 'test@example.com', + column: users.email, + table: users, + queryType: 'equality' as const, + }, + ] as const + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + }) +}) + +describe('encryptQuery batch - auto-infer index type', () => { + it('should auto-infer index type when not specified', async () => { + const result = await protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users }, + // No indexType - should auto-infer from column config + ]) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + // Auto-inferred result should be a valid encrypted payload + expect(result.data[0]).not.toBeNull() + expect(typeof result.data[0]).toBe('object') + expect(result.data[0]).toHaveProperty('c') + }) + + it('should use explicit index type when specified', async () => { + const result = await protectClient.encryptQuery([ + { + value: 'test@example.com', + column: users.email, + table: users, + queryType: 'equality', + }, + ]) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('hm') // unique returns HMAC + }) + + it('should handle mixed batch with and without indexType', async () => { + const result = await protectClient.encryptQuery([ + // Explicit indexType + { + value: 'explicit@example.com', + column: users.email, + table: users, + queryType: 'equality', + }, + // Auto-infer indexType + { value: 'auto@example.com', column: users.email, table: users }, + // Another explicit indexType + { value: 100, column: users.score, table: users, queryType: 'orderAndRange' }, + ]) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(3) + // First term: explicit unique should have hm + expect(result.data[0]).toHaveProperty('hm') + // Second term: auto-inferred should be valid encrypted payload + expect(result.data[1]).not.toBeNull() + expect(typeof result.data[1]).toBe('object') + expect(result.data[1]).toHaveProperty('c') + // Third term: explicit ore should have valid encryption + expect(result.data[2]).not.toBeNull() + }) +}) + + + +describe('encryptQuery - ste_vec type inference', () => { + it('should infer selector mode for JSON path string plaintext with queryOp default', async () => { + // JSON path string + queryOp: 'default' for ste_vec → produces selector-only output (has `s` field) + // String must be a valid JSON path starting with '$' + const result = await protectClient.encryptQuery([ + { + value: '$.user.email', + column: jsonSchema.metadata, + table: jsonSchema, + queryType: 'searchableJson', + queryOp: 'default', + }, + ]) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + const encrypted = result.data[0] as Record + // JSON path string with default queryOp produces selector-only output + expect(encrypted).toHaveProperty('s') + expect(typeof encrypted.s).toBe('string') + // Selector-only should NOT have sv array + expect(encrypted).not.toHaveProperty('sv') + }) + + it('should infer containment mode for object plaintext with queryOp default', async () => { + // Object plaintext + queryOp: 'default' for ste_vec → produces containment output (has `sv` array) + const result = await protectClient.encryptQuery([ + { + value: { role: 'admin', status: 'active' }, + column: jsonSchema.metadata, + table: jsonSchema, + queryType: 'searchableJson', + queryOp: 'default', + }, + ]) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + const encrypted = result.data[0] as Record + // Object plaintext with default queryOp produces containment output + expect(encrypted).toHaveProperty('sv') + expect(Array.isArray(encrypted.sv)).toBe(true) + const svArray = encrypted.sv as Array> + expect(svArray.length).toBeGreaterThan(0) + // Each sv entry should have a selector + expect(svArray[0]).toHaveProperty('s') + }) + + it('should infer containment mode for array plaintext with queryOp default', async () => { + // Array plaintext + queryOp: 'default' for ste_vec → produces containment output (has `sv` array) + const result = await protectClient.encryptQuery([ + { + value: ['tag1', 'tag2', 'tag3'], + column: jsonSchema.metadata, + table: jsonSchema, + queryType: 'searchableJson', + queryOp: 'default', + }, + ]) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + const encrypted = result.data[0] as Record + // Array plaintext with default queryOp produces containment output + expect(encrypted).toHaveProperty('sv') + expect(Array.isArray(encrypted.sv)).toBe(true) + const svArray = encrypted.sv as Array> + expect(svArray.length).toBeGreaterThan(0) + }) + + it('should respect explicit ste_vec_selector queryOp', async () => { + const result = await protectClient.encryptQuery([ + { + value: '$.user.email', + column: jsonSchema.metadata, + table: jsonSchema, + queryType: 'searchableJson', + queryOp: 'ste_vec_selector', + }, + ]) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + const encrypted = result.data[0] as Record + // Explicit ste_vec_selector produces selector-only output + expect(encrypted).toHaveProperty('s') + expect(typeof encrypted.s).toBe('string') + }) + + it('should respect explicit ste_vec_term queryOp', async () => { + const result = await protectClient.encryptQuery([ + { + value: { key: 'value' }, + column: jsonSchema.metadata, + table: jsonSchema, + queryType: 'searchableJson', + queryOp: 'ste_vec_term', + }, + ]) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + const encrypted = result.data[0] as Record + // Explicit ste_vec_term produces containment output + expect(encrypted).toHaveProperty('sv') + expect(Array.isArray(encrypted.sv)).toBe(true) + }) +}) + +describe('encryptQuery single-value - auto-infer index type', () => { + it('should auto-infer index type for single value when not specified', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + // No indexType - should auto-infer from column config + }) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + // Auto-inferred result should be a valid encrypted payload + expect(result.data).not.toBeNull() + expect(typeof result.data).toBe('object') + expect(result.data).toHaveProperty('c') + }) + + it('should use explicit index type for single value when specified', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + }) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveProperty('hm') // unique returns HMAC + }) + + it('should handle null value with auto-infer', async () => { + const result = await protectClient.encryptQuery(null, { + column: users.email, + table: users, + // No indexType + }) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toBeNull() + }) +}) + +// Schema without ste_vec index for error testing +const schemaWithoutSteVec = csTable('test_no_ste_vec', { + data: csColumn('data').dataType('json'), +}) + +describe('encryptQuery - error code propagation', () => { + let clientWithNoSteVec: Awaited> + + beforeAll(async () => { + clientWithNoSteVec = await protect({ schemas: [users, schemaWithoutSteVec] }) + }) + + it('should propagate UNKNOWN_COLUMN error code for non-existent column', async () => { + // Create a fake column reference that doesn't exist in the schema + const result = await protectClient.encryptQuery([ + { + value: 'test', + column: { getName: () => 'nonexistent_column' } as any, + table: users, + queryType: 'equality', + }, + ]) + + expect(result.failure).toBeDefined() + expect(result.failure?.code).toBe('UNKNOWN_COLUMN' as ProtectErrorCode) + }) + + it('should propagate MISSING_INDEX error code for column without required index', async () => { + // Query with ste_vec on a column that only has json dataType (no searchableJson) + const result = await clientWithNoSteVec.encryptQuery([ + { + value: { key: 'value' }, + column: schemaWithoutSteVec.data, + table: schemaWithoutSteVec, + queryType: 'searchableJson', + }, + ]) + + expect(result.failure).toBeDefined() + expect(result.failure?.code).toBe('MISSING_INDEX' as ProtectErrorCode) + }) + + it('should include error code in failure object when FFI throws', async () => { + const result = await protectClient.encryptQuery([ + { + value: 'test', + column: { getName: () => 'bad_column' } as any, + table: { tableName: 'bad_table' } as any, + queryType: 'equality', + }, + ]) + + expect(result.failure).toBeDefined() + // Error should have a code property (could be UNKNOWN_COLUMN or other FFI error) + expect(result.failure?.message).toBeDefined() + // The code property should exist on errors from FFI + if (result.failure?.code) { + expect(typeof result.failure.code).toBe('string') + } + }) + + it('should preserve error message alongside error code', async () => { + const result = await protectClient.encryptQuery([ + { + value: 'test', + column: { getName: () => 'missing_column' } as any, + table: users, + queryType: 'equality', + }, + ]) + + expect(result.failure).toBeDefined() + expect(result.failure?.message).toBeTruthy() + expect(result.failure?.type).toBe('EncryptionError') + // Both message and code should be present + if (result.failure?.code) { + expect(['UNKNOWN_COLUMN', 'UNKNOWN']).toContain(result.failure.code) + } + }) +}) diff --git a/packages/protect/__tests__/encrypt-query.test.ts b/packages/protect/__tests__/encrypt-query.test.ts new file mode 100644 index 00000000..766c7a5f --- /dev/null +++ b/packages/protect/__tests__/encrypt-query.test.ts @@ -0,0 +1,690 @@ +/** + * encryptQuery API Tests + * + * Comprehensive tests for the encryptQuery API, covering: + * - Scalar queries (equality, orderAndRange, freeTextSearch) + * - JSON path queries (selector-only, path+value, deep paths, array wildcards) + * - JSON containment queries (contains, containedBy) + * - Bulk operations (multiple terms, mixed query types) + * - Error handling + */ +import 'dotenv/config' +import { csColumn, csTable } from '@cipherstash/schema' +import { beforeAll, describe, expect, it } from 'vitest' +import { protect, type QueryTerm } from '../src' +import { + expectSteVecArray, + expectJsonPathWithValue, + expectJsonPathSelectorOnly, +} from './test-utils/query-terms' + +// Schema for scalar query tests +const scalarSchema = csTable('test_scalar_queries', { + email: csColumn('email').freeTextSearch().equality().orderAndRange(), + name: csColumn('name').freeTextSearch(), + age: csColumn('age').dataType('number').equality().orderAndRange(), +}) + +// Schema for JSON query tests +const jsonSchema = csTable('test_json_queries', { + metadata: csColumn('metadata').searchableJson(), + config: csColumn('config').searchableJson(), +}) + +// Schema without searchableJson for error testing +const plainJsonSchema = csTable('test_plain_json', { + data: csColumn('data').dataType('json'), +}) + +describe('encryptQuery API - Scalar Queries', () => { + let protectClient: Awaited> + + beforeAll(async () => { + protectClient = await protect({ schemas: [scalarSchema] }) + }) + + describe('Single value encryption', () => { + it('should encrypt a single value with auto-inferred query type', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: scalarSchema.email, + table: scalarSchema, + }) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + // Should have encrypted data with appropriate index + expect(result.data).toHaveProperty('c') + }) + + it('should encrypt with explicit equality query type', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: scalarSchema.email, + table: scalarSchema, + queryType: 'equality', + }) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('hm') + }) + + it('should encrypt with orderAndRange query type', async () => { + const result = await protectClient.encryptQuery(25, { + column: scalarSchema.age, + table: scalarSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + expect(Array.isArray(result.data.ob)).toBe(true) + }) + + it('should encrypt with freeTextSearch query type', async () => { + const result = await protectClient.encryptQuery('john', { + column: scalarSchema.name, + table: scalarSchema, + queryType: 'freeTextSearch', + }) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('bf') + }) + }) +}) + +describe('encryptQuery API - JSON Path Queries', () => { + let protectClient: Awaited> + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonSchema] }) + }) + + describe('Selector-only queries (path without value)', () => { + it('should create selector for simple path', async () => { + const terms: QueryTerm[] = [ + { + path: 'user.email', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }) + + it('should create selector for deep path', async () => { + const terms: QueryTerm[] = [ + { + path: 'user.settings.preferences.theme', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }) + + it('should create selector for array wildcard path', async () => { + const terms: QueryTerm[] = [ + { + path: 'items[@]', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }) + + it('should accept path as array format', async () => { + const terms: QueryTerm[] = [ + { + path: ['user', 'profile', 'name'], + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }) + }) + + describe('Path with value queries', () => { + it('should encrypt path with string value', async () => { + const terms: QueryTerm[] = [ + { + path: 'user.email', + value: 'test@example.com', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }) + + it('should encrypt path with numeric value', async () => { + const terms: QueryTerm[] = [ + { + path: 'user.age', + value: 25, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }) + + it('should encrypt path with boolean value', async () => { + const terms: QueryTerm[] = [ + { + path: 'user.active', + value: true, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }) + + it('should encrypt array wildcard path with value', async () => { + const terms: QueryTerm[] = [ + { + path: 'tags[@]', + value: 'premium', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }) + }) +}) + +describe('encryptQuery API - JSON Containment Queries', () => { + let protectClient: Awaited> + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonSchema] }) + }) + + describe('Contains (@>) queries', () => { + it('should encrypt contains with simple object', async () => { + const terms: QueryTerm[] = [ + { + contains: { role: 'admin' }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectSteVecArray(result.data[0] as { sv: Array> }) + }) + + it('should encrypt contains with nested object', async () => { + const terms: QueryTerm[] = [ + { + contains: { user: { role: 'admin' } }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectSteVecArray(result.data[0] as { sv: Array> }) + }) + + it('should encrypt contains with array value', async () => { + const terms: QueryTerm[] = [ + { + contains: { tags: ['premium', 'verified'] }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectSteVecArray(result.data[0] as { sv: Array> }) + }) + + it('should encrypt contains with multiple keys', async () => { + const terms: QueryTerm[] = [ + { + contains: { role: 'admin', status: 'active' }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + const encrypted = result.data[0] as { sv: Array> } + expect(encrypted).toHaveProperty('sv') + expect(encrypted.sv.length).toBeGreaterThanOrEqual(2) + }) + }) + + describe('Contained by (<@) queries', () => { + it('should encrypt containedBy with simple object', async () => { + const terms: QueryTerm[] = [ + { + containedBy: { role: 'admin' }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectSteVecArray(result.data[0] as { sv: Array> }) + }) + + it('should encrypt containedBy with nested object', async () => { + const terms: QueryTerm[] = [ + { + containedBy: { user: { permissions: ['read', 'write', 'admin'] } }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectSteVecArray(result.data[0] as { sv: Array> }) + }) + }) +}) + +describe('encryptQuery API - Bulk Operations', () => { + let protectClient: Awaited> + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonSchema] }) + }) + + it('should handle multiple path queries in single call', async () => { + const terms: QueryTerm[] = [ + { + path: 'user.email', + value: 'test@example.com', + column: jsonSchema.metadata, + table: jsonSchema, + }, + { + path: 'user.name', + value: 'John Doe', + column: jsonSchema.metadata, + table: jsonSchema, + }, + { + path: 'status', + value: 'active', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(3) + for (const item of result.data) { + expectJsonPathWithValue(item as Record) + } + }) + + it('should handle multiple containment queries in single call', async () => { + const terms: QueryTerm[] = [ + { + contains: { role: 'admin' }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + { + contains: { enabled: true }, + column: jsonSchema.config, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(2) + for (const item of result.data) { + expectSteVecArray(item as { sv: Array> }) + } + }) + + it('should handle mixed path and containment queries', async () => { + const terms: QueryTerm[] = [ + { + path: 'user.email', + value: 'test@example.com', + column: jsonSchema.metadata, + table: jsonSchema, + }, + { + contains: { role: 'admin' }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + { + path: 'settings.theme', + column: jsonSchema.config, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(3) + // First: path with value + expectJsonPathWithValue(result.data[0] as Record) + // Second: containment + expectSteVecArray(result.data[1] as { sv: Array> }) + // Third: path-only + expectJsonPathSelectorOnly(result.data[2] as Record) + }) + + it('should handle empty terms array', async () => { + const terms: QueryTerm[] = [] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(0) + }) +}) + +describe('encryptQuery API - Error Handling', () => { + let protectClient: Awaited> + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonSchema, plainJsonSchema] }) + }) + + it('should fail for path query on column without ste_vec index', async () => { + const terms: QueryTerm[] = [ + { + path: 'user.email', + value: 'test@example.com', + column: plainJsonSchema.data, + table: plainJsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + expect(result.failure).toBeDefined() + expect(result.failure?.message).toContain('does not have ste_vec index') + }) + + it('should fail for containment query on column without ste_vec index', async () => { + const terms: QueryTerm[] = [ + { + contains: { role: 'admin' }, + column: plainJsonSchema.data, + table: plainJsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + expect(result.failure).toBeDefined() + expect(result.failure?.message).toContain('does not have ste_vec index') + }) +}) + +describe('encryptQuery API - Edge Cases', () => { + let protectClient: Awaited> + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonSchema] }) + }) + + it('should handle unicode in paths', async () => { + const terms: QueryTerm[] = [ + { + path: ['用户', '电子邮件'], + value: 'test@example.com', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }) + + it('should handle unicode in values', async () => { + const terms: QueryTerm[] = [ + { + path: 'message', + value: '你好世界 🌍', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }) + + it('should handle special characters in keys', async () => { + const terms: QueryTerm[] = [ + { + contains: { 'key-with-dash': 'value', key_with_underscore: 'value2' }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + const encrypted = result.data[0] as { sv: Array> } + expect(encrypted.sv.length).toBeGreaterThanOrEqual(2) + }) + + it('should handle null values in containment queries', async () => { + const terms: QueryTerm[] = [ + { + contains: { status: null }, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }) + + it('should handle deeply nested paths (10+ levels)', async () => { + const terms: QueryTerm[] = [ + { + path: 'a.b.c.d.e.f.g.h.i.j.k', + value: 'deep_value', + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }) + + it('should handle large containment objects (50 keys)', async () => { + const largeObject: Record = {} + for (let i = 0; i < 50; i++) { + largeObject[`key${i}`] = `value${i}` + } + + const terms: QueryTerm[] = [ + { + contains: largeObject, + column: jsonSchema.metadata, + table: jsonSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + const encrypted = result.data[0] as { sv: Array> } + expect(encrypted.sv.length).toBeGreaterThanOrEqual(50) + }) +}) diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index 8f7fa35d..7cf0797c 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -16,10 +16,15 @@ import type { Client, Decrypted, EncryptOptions, + EncryptQueryOptions, Encrypted, KeysetIdentifier, + QuerySearchTerm, + QueryTerm, SearchTerm, } from '../types' +import { isQueryTermArray } from '../query-term-guards' +import { BatchEncryptQueryOperation } from './operations/batch-encrypt-query' import { BulkDecryptOperation } from './operations/bulk-decrypt' import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models' import { BulkEncryptOperation } from './operations/bulk-encrypt' @@ -28,6 +33,8 @@ import { DecryptOperation } from './operations/decrypt' import { DecryptModelOperation } from './operations/decrypt-model' import { EncryptOperation } from './operations/encrypt' import { EncryptModelOperation } from './operations/encrypt-model' +import { EncryptQueryOperation } from './operations/encrypt-query' +import { QuerySearchTermsOperation } from './operations/query-search-terms' import { SearchTermsOperation } from './operations/search-terms' export const noClientError = () => @@ -215,29 +222,29 @@ export class ProtectClient { } /** - * Encrypt a model based on its encryptConfig. + * Encrypt an entire object (model) based on its table schema. + * + * This method automatically encrypts fields defined in the schema while + * preserving other fields (like IDs, timestamps, or nested structures). + * + * @param input - The model with plaintext values. + * @param table - The table definition from your schema. + * @returns An EncryptModelOperation that can be awaited or chained with .withLockContext(). * * @example * ```typescript * type User = { * id: string; * email: string; // encrypted + * createdAt: Date; // unchanged * } * - * // Define the schema for the users table - * const usersSchema = csTable('users', { - * email: csColumn('email').freeTextSearch().equality().orderAndRange(), - * }) - * - * // Initialize the Protect client - * const protectClient = await protect({ schemas: [usersSchema] }) - * - * // Encrypt a user model - * const encryptedModel = await protectClient.encryptModel( - * { id: 'user_123', email: 'person@example.com' }, - * usersSchema, - * ) + * const user = { id: '1', email: 'alice@example.com', createdAt: new Date() }; + * const encryptedResult = await protectClient.encryptModel(user, usersTable); * ``` + * + * @see {@link Result} + * @see {@link csTable} */ encryptModel>( input: Decrypted, @@ -247,10 +254,17 @@ export class ProtectClient { } /** - * Decrypt a model with encrypted values - * Usage: - * await eqlClient.decryptModel(encryptedModel) - * await eqlClient.decryptModel(encryptedModel).withLockContext(lockContext) + * Decrypt an entire object (model) containing encrypted values. + * + * This method automatically detects and decrypts any encrypted fields in your model. + * + * @param input - The model containing encrypted values. + * @returns A DecryptModelOperation that can be awaited or chained with .withLockContext(). + * + * @example + * ```typescript + * const decryptedResult = await protectClient.decryptModel(encryptedUser); + * ``` */ decryptModel>( input: T, @@ -259,10 +273,11 @@ export class ProtectClient { } /** - * Bulk encrypt models with decrypted values - * Usage: - * await eqlClient.bulkEncryptModels(decryptedModels, table) - * await eqlClient.bulkEncryptModels(decryptedModels, table).withLockContext(lockContext) + * Bulk encrypt multiple objects (models) for better performance. + * + * @param input - Array of models with plaintext values. + * @param table - The table definition from your schema. + * @returns A BulkEncryptModelsOperation that can be awaited or chained with .withLockContext(). */ bulkEncryptModels>( input: Array>, @@ -272,10 +287,10 @@ export class ProtectClient { } /** - * Bulk decrypt models with encrypted values - * Usage: - * await eqlClient.bulkDecryptModels(encryptedModels) - * await eqlClient.bulkDecryptModels(encryptedModels).withLockContext(lockContext) + * Bulk decrypt multiple objects (models). + * + * @param input - Array of models containing encrypted values. + * @returns A BulkDecryptModelsOperation that can be awaited or chained with .withLockContext(). */ bulkDecryptModels>( input: Array, @@ -284,10 +299,11 @@ export class ProtectClient { } /** - * Bulk encryption - returns a thenable object. - * Usage: - * await eqlClient.bulkEncrypt(plaintexts, { column, table }) - * await eqlClient.bulkEncrypt(plaintexts, { column, table }).withLockContext(lockContext) + * Bulk encryption - returns a promise which resolves to an array of encrypted values. + * + * @param plaintexts - Array of plaintext values to be encrypted. + * @param opts - Options specifying the column and table for encryption. + * @returns A BulkEncryptOperation that can be awaited or chained with .withLockContext(). */ bulkEncrypt( plaintexts: BulkEncryptPayload, @@ -297,25 +313,153 @@ export class ProtectClient { } /** - * Bulk decryption - returns a thenable object. - * Usage: - * await eqlClient.bulkDecrypt(encryptedPayloads) - * await eqlClient.bulkDecrypt(encryptedPayloads).withLockContext(lockContext) + * Bulk decryption - returns a promise which resolves to an array of decrypted values. + * + * @param encryptedPayloads - Array of encrypted payloads to be decrypted. + * @returns A BulkDecryptOperation that can be awaited or chained with .withLockContext(). */ bulkDecrypt(encryptedPayloads: BulkDecryptPayload): BulkDecryptOperation { return new BulkDecryptOperation(this.client, encryptedPayloads) } /** + * @deprecated Use `encryptQuery(terms)` instead with QueryTerm types. Will be removed in v2.0. + * * Create search terms to use in a query searching encrypted data * Usage: * await eqlClient.createSearchTerms(searchTerms) - * await eqlClient.createSearchTerms(searchTerms).withLockContext(lockContext) */ createSearchTerms(terms: SearchTerm[]): SearchTermsOperation { return new SearchTermsOperation(this.client, terms) } + /** + * Encrypt a single value for query operations with explicit index type control. + * + * This method produces SEM-only payloads optimized for database queries, + * allowing you to specify which index type to use. + * + * @param plaintext - The value to encrypt for querying + * @param opts - Options specifying the column, table, index type, and optional query operation + * @returns An EncryptQueryOperation that can be awaited + * + * @example + * ```typescript + * // Encrypt for ORE range query + * const term = await protectClient.encryptQuery(100, { + * column: usersSchema.score, + * table: usersSchema, + * queryType: 'orderAndRange', + * }) + * ``` + * + * @see {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries | Supported Query Types} + */ + encryptQuery( + plaintext: JsPlaintext | null, + opts: EncryptQueryOptions, + ): EncryptQueryOperation + + /** + * Encrypt multiple query terms in batch with explicit control over each term. + * + * Supports scalar terms (with explicit queryType), JSON path queries, and JSON containment queries. + * JSON queries implicitly use searchableJson query type. + * + * @param terms - Array of query terms to encrypt + * @returns A BatchEncryptQueryOperation that can be awaited + * + * @example + * ```typescript + * const terms = await protectClient.encryptQuery([ + * // Scalar term with explicit queryType + * { value: 'admin@example.com', column: users.email, table: users, queryType: 'equality' }, + * // JSON path query (searchableJson implicit) + * { path: 'user.email', value: 'test@example.com', column: jsonSchema.metadata, table: jsonSchema }, + * // JSON containment query (searchableJson implicit) + * { contains: { role: 'admin' }, column: jsonSchema.metadata, table: jsonSchema }, + * ]) + * ``` + * + * @remarks + * Note: Empty arrays `[]` are treated as scalar plaintext values for backward + * compatibility with the single-value overload. Pass a non-empty array to use + * batch encryption. + */ + encryptQuery(terms: readonly QueryTerm[]): BatchEncryptQueryOperation + + // Implementation + encryptQuery( + plaintextOrTerms: JsPlaintext | null | readonly QueryTerm[], + opts?: EncryptQueryOptions, + ): EncryptQueryOperation | BatchEncryptQueryOperation { + // Check if this is a QueryTerm array by looking for QueryTerm-specific properties + // This is needed because JsPlaintext includes JsPlaintext[] which overlaps with QueryTerm[] + // Empty arrays are explicitly handled as batch operations (return empty result) + if (Array.isArray(plaintextOrTerms)) { + if (plaintextOrTerms.length === 0 || isQueryTermArray(plaintextOrTerms)) { + return new BatchEncryptQueryOperation( + this.client, + plaintextOrTerms as unknown as readonly QueryTerm[], + ) + } + } + // Non-array values pass through to single-value encryption + if (!opts) { + throw new Error( + 'encryptQuery requires options when called with a single value', + ) + } + return new EncryptQueryOperation( + this.client, + plaintextOrTerms as JsPlaintext | null, + opts, + ) + } + + /** + * @deprecated Use `encryptQuery(terms)` instead. Will be removed in v2.0. + * + * Create multiple encrypted query terms with explicit index type control. + * + * This method produces SEM-only payloads optimized for database queries, + * providing explicit control over which index type and query operation to use for each term. + * + * @param terms - Array of query search terms with index type specifications + * @returns A QuerySearchTermsOperation that can be awaited + * + * @example + * ```typescript + * const terms = await protectClient.createQuerySearchTerms([ + * { + * value: 'admin@example.com', + * column: usersSchema.email, + * table: usersSchema, + * queryType: 'equality', + * }, + * { + * value: 100, + * column: usersSchema.score, + * table: usersSchema, + * queryType: 'orderAndRange', + * }, + * ]) + * + * // Use in PostgreSQL query + * const result = await db.query( + * `SELECT * FROM users + * WHERE cs_unique_v1(email) = $1 + * AND cs_ore_64_8_v1(score) > $2`, + * [terms.data[0], terms.data[1]] + * ) + * ``` + * + * @see {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries | Supported Query Types} + */ + createQuerySearchTerms(terms: QuerySearchTerm[]): QuerySearchTermsOperation { + return new QuerySearchTermsOperation(this.client, terms) + } + /** e.g., debugging or environment info */ clientInfo() { return { diff --git a/packages/protect/src/ffi/operations/batch-encrypt-query.ts b/packages/protect/src/ffi/operations/batch-encrypt-query.ts new file mode 100644 index 00000000..ea163054 --- /dev/null +++ b/packages/protect/src/ffi/operations/batch-encrypt-query.ts @@ -0,0 +1,325 @@ +import { type Result, withResult } from '@byteslice/result' +import { + encryptBulk, + encryptQueryBulk, + ProtectError as FfiProtectError, +} from '@cipherstash/protect-ffi' +import { type ProtectError, ProtectErrorTypes } from '../..' +import { logger } from '../../../../utils/logger' +import { + isJsonContainedByQueryTerm, + isJsonContainsQueryTerm, + isJsonPathQueryTerm, + isScalarQueryTerm, +} from '../../query-term-guards' +import type { + Client, + Encrypted, + EncryptedSearchTerm, + QueryTypeName, + JsPlaintext, + QueryOpName, + QueryTerm, +} from '../../types' +import { queryTypeToFfi } from '../../types' +import { noClientError } from '../index' +import { buildNestedObject, toDollarPath } from './json-path-utils' +import { ProtectOperation } from './base-operation' + +/** Tracks JSON containment items - pass raw JSON to FFI */ +type JsonContainmentItem = { + termIndex: number + plaintext: JsPlaintext + column: string + table: string +} + +/** Tracks JSON path items that need value encryption */ +type JsonPathEncryptionItem = { + plaintext: JsPlaintext + column: string + table: string +} + +/** + * Helper to check if a scalar term has an explicit queryType + */ +function hasExplicitQueryType( + term: QueryTerm, +): term is QueryTerm & { queryType: QueryTypeName } { + return 'queryType' in term && term.queryType !== undefined +} + +/** + * Helper function to encrypt batch query terms + */ +async function encryptBatchQueryTermsHelper( + client: Client, + terms: readonly QueryTerm[], + metadata: Record | undefined, +): Promise { + if (!client) { + throw noClientError() + } + + // Partition terms by type + // Scalar terms WITH queryType → encryptQueryBulk (explicit control) + const scalarWithQueryType: Array<{ term: QueryTerm; index: number }> = [] + // Scalar terms WITHOUT queryType → encryptBulk (auto-infer) + const scalarAutoInfer: Array<{ term: QueryTerm; index: number }> = [] + // JSON containment items - pass raw JSON to FFI + const jsonContainmentItems: JsonContainmentItem[] = [] + // JSON path items that need value encryption + const jsonPathItems: JsonPathEncryptionItem[] = [] + // Selector-only terms (JSON path without value) + const selectorOnlyItems: Array<{ + selector: string + column: string + table: string + }> = [] + + for (let i = 0; i < terms.length; i++) { + const term = terms[i] + + if (isScalarQueryTerm(term)) { + if (hasExplicitQueryType(term)) { + scalarWithQueryType.push({ term, index: i }) + } else { + scalarAutoInfer.push({ term, index: i }) + } + } else if (isJsonContainsQueryTerm(term)) { + // Validate ste_vec index + const columnConfig = term.column.build() + if (!columnConfig.indexes.ste_vec) { + throw new Error( + `Column "${term.column.getName()}" does not have ste_vec index configured. Use .searchableJson() when defining the column.`, + ) + } + + // Pass raw JSON directly - FFI handles flattening internally + jsonContainmentItems.push({ + termIndex: i, + plaintext: term.contains, + column: term.column.getName(), + table: term.table.tableName, + }) + } else if (isJsonContainedByQueryTerm(term)) { + // Validate ste_vec index + const columnConfig = term.column.build() + if (!columnConfig.indexes.ste_vec) { + throw new Error( + `Column "${term.column.getName()}" does not have ste_vec index configured. Use .searchableJson() when defining the column.`, + ) + } + + // Pass raw JSON directly - FFI handles flattening internally + jsonContainmentItems.push({ + termIndex: i, + plaintext: term.containedBy, + column: term.column.getName(), + table: term.table.tableName, + }) + } else if (isJsonPathQueryTerm(term)) { + // Validate ste_vec index + const columnConfig = term.column.build() + if (!columnConfig.indexes.ste_vec) { + throw new Error( + `Column "${term.column.getName()}" does not have ste_vec index configured. Use .searchableJson() when defining the column.`, + ) + } + + if (term.value !== undefined) { + const pathArray = Array.isArray(term.path) + ? term.path + : term.path.split('.') + const wrappedValue = buildNestedObject(pathArray, term.value) + jsonPathItems.push({ + plaintext: wrappedValue, + column: term.column.getName(), + table: term.table.tableName, + }) + } else { + // Path-only terms (no value) need selector encryption + const selector = toDollarPath(term.path) + selectorOnlyItems.push({ + selector, + column: term.column.getName(), + table: term.table.tableName, + }) + } + } + } + + // Encrypt scalar terms WITH explicit queryType using encryptQueryBulk + const scalarExplicitEncrypted = + scalarWithQueryType.length > 0 + ? await encryptQueryBulk(client, { + queries: scalarWithQueryType.map(({ term }) => { + if (!isScalarQueryTerm(term)) + throw new Error('Expected scalar term') + return { + plaintext: term.value, + column: term.column.getName(), + table: term.table.tableName, + indexType: queryTypeToFfi[term.queryType!], + queryOp: term.queryOp, + } + }), + unverifiedContext: metadata, + }) + : [] + + // Encrypt scalar terms WITHOUT queryType using encryptBulk (auto-infer) + const scalarAutoInferEncrypted = + scalarAutoInfer.length > 0 + ? await encryptBulk(client, { + plaintexts: scalarAutoInfer.map(({ term }) => { + if (!isScalarQueryTerm(term)) + throw new Error('Expected scalar term') + return { + plaintext: term.value, + column: term.column.getName(), + table: term.table.tableName, + } + }), + unverifiedContext: metadata, + }) + : [] + + // Encrypt JSON containment terms - pass raw JSON, FFI handles flattening + const jsonContainmentEncrypted = + jsonContainmentItems.length > 0 + ? await encryptQueryBulk(client, { + queries: jsonContainmentItems.map((item) => ({ + plaintext: item.plaintext, + column: item.column, + table: item.table, + indexType: queryTypeToFfi.searchableJson, + queryOp: 'default' as QueryOpName, + })), + unverifiedContext: metadata, + }) + : [] + + // Encrypt selectors for JSON terms without values (ste_vec_selector op) + const selectorOnlyEncrypted = + selectorOnlyItems.length > 0 + ? await encryptQueryBulk(client, { + queries: selectorOnlyItems.map((item) => ({ + plaintext: item.selector, + column: item.column, + table: item.table, + indexType: queryTypeToFfi.searchableJson, + queryOp: 'ste_vec_selector' as QueryOpName, + })), + unverifiedContext: metadata, + }) + : [] + + // Encrypt JSON path terms with values + const jsonPathEncrypted = + jsonPathItems.length > 0 + ? await encryptQueryBulk(client, { + queries: jsonPathItems.map((item) => ({ + plaintext: item.plaintext, + column: item.column, + table: item.table, + indexType: queryTypeToFfi.searchableJson, + queryOp: 'default' as QueryOpName, + })), + unverifiedContext: metadata, + }) + : [] + + // Reassemble results in original order + const results: EncryptedSearchTerm[] = new Array(terms.length) + let scalarExplicitIdx = 0 + let scalarAutoInferIdx = 0 + let containmentIdx = 0 + let pathIdx = 0 + let selectorOnlyIdx = 0 + + for (let i = 0; i < terms.length; i++) { + const term = terms[i] + + if (isScalarQueryTerm(term)) { + // Determine which result array to pull from based on whether term had explicit queryType + let encrypted: Encrypted + if (hasExplicitQueryType(term)) { + encrypted = scalarExplicitEncrypted[scalarExplicitIdx] + scalarExplicitIdx++ + } else { + encrypted = scalarAutoInferEncrypted[scalarAutoInferIdx] + scalarAutoInferIdx++ + } + + if (term.returnType === 'composite-literal') { + results[i] = `(${JSON.stringify(JSON.stringify(encrypted))})` + } else if (term.returnType === 'escaped-composite-literal') { + results[i] = + `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encrypted))})`)}` + } else { + results[i] = encrypted + } + } else if (isJsonContainsQueryTerm(term) || isJsonContainedByQueryTerm(term)) { + // FFI returns complete { sv: [...] } structure - use directly + results[i] = jsonContainmentEncrypted[containmentIdx] as Encrypted + containmentIdx++ + } else if (isJsonPathQueryTerm(term)) { + if (term.value !== undefined) { + // FFI returns complete { sv: [...] } structure for path+value queries + results[i] = jsonPathEncrypted[pathIdx] as Encrypted + pathIdx++ + } else { + results[i] = selectorOnlyEncrypted[selectorOnlyIdx] + selectorOnlyIdx++ + } + } + } + + return results +} + +/** + * @internal + * Operation for encrypting multiple query terms in batch. + * See {@link ProtectClient.encryptQuery} for the public interface. + */ +export class BatchEncryptQueryOperation extends ProtectOperation< + EncryptedSearchTerm[] +> { + private client: Client + private terms: readonly QueryTerm[] + + constructor(client: Client, terms: readonly QueryTerm[]) { + super() + this.client = client + this.terms = terms + } + + public getOperation(): { client: Client; terms: readonly QueryTerm[] } { + return { client: this.client, terms: this.terms } + } + + public async execute(): Promise> { + logger.debug('Encrypting batch query terms', { + termCount: this.terms.length, + }) + + return await withResult( + async () => { + const { metadata } = this.getAuditData() + return await encryptBatchQueryTermsHelper( + this.client, + this.terms, + metadata, + ) + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + code: error instanceof FfiProtectError ? error.code : undefined, + }), + ) + } +} \ No newline at end of file diff --git a/packages/protect/src/ffi/operations/encrypt-query.ts b/packages/protect/src/ffi/operations/encrypt-query.ts new file mode 100644 index 00000000..3ff918e2 --- /dev/null +++ b/packages/protect/src/ffi/operations/encrypt-query.ts @@ -0,0 +1,126 @@ +import { type Result, withResult } from '@byteslice/result' +import { + type JsPlaintext, + encryptBulk, + encryptQuery as ffiEncryptQuery, + ProtectError as FfiProtectError, +} from '@cipherstash/protect-ffi' +import type { + ProtectColumn, + ProtectTable, + ProtectTableColumn, + ProtectValue, +} from '@cipherstash/schema' +import { type ProtectError, ProtectErrorTypes } from '../..' +import { logger } from '../../../../utils/logger' +import type { + Client, + EncryptQueryOptions, + Encrypted, + QueryTypeName, + QueryOpName, +} from '../../types' +import { queryTypeToFfi } from '../../types' +import { noClientError } from '../index' +import { ProtectOperation } from './base-operation' + +/** + * @internal + * Operation for encrypting a single query term. + * When queryType is provided, uses explicit query type control via ffiEncryptQuery. + * When queryType is omitted, auto-infers from column config via encryptBulk. + * See {@link ProtectClient.encryptQuery} for the public interface and documentation. + */ +export class EncryptQueryOperation extends ProtectOperation { + private client: Client + private plaintext: JsPlaintext | null + private column: ProtectColumn | ProtectValue + private table: ProtectTable + private queryType?: QueryTypeName + private queryOp?: QueryOpName + + constructor( + client: Client, + plaintext: JsPlaintext | null, + opts: EncryptQueryOptions, + ) { + super() + this.client = client + this.plaintext = plaintext + this.column = opts.column + this.table = opts.table + this.queryType = opts.queryType + this.queryOp = opts.queryOp + } + + public async execute(): Promise> { + logger.debug('Encrypting query', { + column: this.column.getName(), + table: this.table.tableName, + queryType: this.queryType, + queryOp: this.queryOp, + }) + + return await withResult( + async () => { + if (!this.client) { + throw noClientError() + } + + if (this.plaintext === null) { + return null + } + + const { metadata } = this.getAuditData() + + // Use explicit query type if provided, otherwise auto-infer via encryptBulk + if (this.queryType !== undefined) { + return await ffiEncryptQuery(this.client, { + plaintext: this.plaintext, + column: this.column.getName(), + table: this.table.tableName, + indexType: queryTypeToFfi[this.queryType], + queryOp: this.queryOp, + unverifiedContext: metadata, + }) + } + + // Auto-infer query type via encryptBulk + const results = await encryptBulk(this.client, { + plaintexts: [ + { + plaintext: this.plaintext, + column: this.column.getName(), + table: this.table.tableName, + }, + ], + unverifiedContext: metadata, + }) + return results[0] + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + code: error instanceof FfiProtectError ? error.code : undefined, + }), + ) + } + + public getOperation(): { + client: Client + plaintext: JsPlaintext | null + column: ProtectColumn | ProtectValue + table: ProtectTable + queryType?: QueryTypeName + queryOp?: QueryOpName + } { + return { + client: this.client, + plaintext: this.plaintext, + column: this.column, + table: this.table, + queryType: this.queryType, + queryOp: this.queryOp, + } + } +} \ No newline at end of file diff --git a/packages/protect/src/ffi/operations/query-search-terms.ts b/packages/protect/src/ffi/operations/query-search-terms.ts new file mode 100644 index 00000000..3ff9be7c --- /dev/null +++ b/packages/protect/src/ffi/operations/query-search-terms.ts @@ -0,0 +1,73 @@ +import { type Result, withResult } from '@byteslice/result' +import { encryptQueryBulk } from '@cipherstash/protect-ffi' +import { type ProtectError, ProtectErrorTypes } from '../..' +import { logger } from '../../../../utils/logger' +import type { Client, EncryptedSearchTerm, QuerySearchTerm } from '../../types' +import { queryTypeToFfi } from '../../types' +import { noClientError } from '../index' +import { ProtectOperation } from './base-operation' + +/** + * @internal + * Operation for encrypting multiple query terms with explicit index type control. + * See {@link ProtectClient.createQuerySearchTerms} for the public interface and documentation. + */ +export class QuerySearchTermsOperation extends ProtectOperation< + EncryptedSearchTerm[] +> { + private client: Client + private terms: QuerySearchTerm[] + + constructor(client: Client, terms: QuerySearchTerm[]) { + super() + this.client = client + this.terms = terms + } + + public getOperation() { + return { client: this.client, terms: this.terms } + } + + public async execute(): Promise> { + logger.debug('Creating query search terms', { + termCount: this.terms.length, + }) + + return await withResult( + async () => { + if (!this.client) { + throw noClientError() + } + + const { metadata } = this.getAuditData() + + const encrypted = await encryptQueryBulk(this.client, { + queries: this.terms.map((term) => ({ + plaintext: term.value, + column: term.column.getName(), + table: term.table.tableName, + indexType: queryTypeToFfi[term.queryType], + queryOp: term.queryOp, + })), + unverifiedContext: metadata, + }) + + return this.terms.map((term, index) => { + if (term.returnType === 'composite-literal') { + return `(${JSON.stringify(JSON.stringify(encrypted[index]))})` + } + + if (term.returnType === 'escaped-composite-literal') { + return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encrypted[index]))})`)}` + } + + return encrypted[index] + }) + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + }), + ) + } +} \ No newline at end of file diff --git a/packages/protect/src/index.ts b/packages/protect/src/index.ts index 54d4a8d9..dd303a4f 100644 --- a/packages/protect/src/index.ts +++ b/packages/protect/src/index.ts @@ -11,19 +11,46 @@ export const ProtectErrorTypes = { CtsTokenError: 'CtsTokenError', } +// Re-export FFI error types for programmatic error handling +export { + ProtectError as FfiProtectError, + type ProtectErrorCode, +} from '@cipherstash/protect-ffi' + +/** + * Error object returned by Protect.js operations. + */ export interface ProtectError { + /** The machine-readable error type. */ type: (typeof ProtectErrorTypes)[keyof typeof ProtectErrorTypes] + /** A human-readable description of the error. */ message: string + /** The FFI error code, if available. Useful for programmatic error handling. */ + code?: import('@cipherstash/protect-ffi').ProtectErrorCode } type AtLeastOneCsTable = [T, ...T[]] +/** + * Configuration for initializing the Protect client. + * + * Credentials can be provided directly here, or via environment variables/configuration files. + * Environment variables take precedence. + * + * @see {@link protect} for full configuration details. + */ export type ProtectClientConfig = { + /** One or more table definitions created with `csTable`. At least one is required. */ schemas: AtLeastOneCsTable> + /** The workspace CRN for your CipherStash account. Maps to `CS_WORKSPACE_CRN`. */ workspaceCrn?: string + /** The access key for your account. Maps to `CS_CLIENT_ACCESS_KEY`. Should be kept secret. */ accessKey?: string + /** The client ID for your project. Maps to `CS_CLIENT_ID`. */ clientId?: string + /** The client key for your project. Maps to `CS_CLIENT_KEY`. Should be kept secret. */ clientKey?: string + /** Optional identifier for the keyset to use. */ keyset?: KeysetIdentifier } @@ -33,16 +60,40 @@ function isValidUuid(uuid: string): boolean { return uuidRegex.test(uuid) } -/* Initialize a Protect client with the provided configuration. - - @param config - The configuration object for initializing the Protect client. - - @see {@link ProtectClientConfig} for details on the configuration options. - - @returns A Promise that resolves to an instance of ProtectClient. - - @throws Will throw an error if no schemas are provided or if the keyset ID is not a valid UUID. -*/ +/** + * Initialize the CipherStash Protect client. + * + * The client can be configured in three ways (in order of precedence): + * 1. **Environment Variables**: + * - `CS_CLIENT_ID`: Your client ID. + * - `CS_CLIENT_KEY`: Your client key (secret). + * - `CS_WORKSPACE_CRN`: Your workspace CRN. + * - `CS_CLIENT_ACCESS_KEY`: Your access key (secret). + * - `CS_CONFIG_PATH`: Path for temporary configuration storage (default: `~/.cipherstash`). + * 2. **Configuration Files** (`cipherstash.toml` and `cipherstash.secret.toml` in project root). + * 3. **Direct Configuration**: Passing a {@link ProtectClientConfig} object. + * + * @param config - The configuration object. + * @returns A Promise that resolves to an initialized {@link ProtectClient}. + * + * @example + * **Basic Initialization** + * ```typescript + * import { protect } from "@cipherstash/protect"; + * import { users } from "./schema"; + * + * const protectClient = await protect({ schemas: [users] }); + * ``` + * + * @example + * **Production Deployment (Serverless)** + * In environments like Vercel or AWS Lambda, ensure the user has write permissions: + * ```bash + * export CS_CONFIG_PATH="/tmp/.cipherstash" + * ``` + * + * @throws Will throw if no schemas are provided or if credentials are missing. + */ export const protect = async ( config: ProtectClientConfig, ): Promise => { @@ -98,6 +149,10 @@ export type { DecryptOperation } from './ffi/operations/decrypt' export type { DecryptModelOperation } from './ffi/operations/decrypt-model' export type { EncryptModelOperation } from './ffi/operations/encrypt-model' export type { EncryptOperation } from './ffi/operations/encrypt' +export type { SearchTermsOperation } from './ffi/operations/search-terms' +export type { EncryptQueryOperation } from './ffi/operations/encrypt-query' +export type { QuerySearchTermsOperation } from './ffi/operations/query-search-terms' +export type { BatchEncryptQueryOperation } from './ffi/operations/batch-encrypt-query' export { csTable, csColumn, csValue } from '@cipherstash/schema' export type { @@ -116,4 +171,55 @@ export type { GetLockContextResponse, } from './identify' export * from './helpers' -export * from './types' + +// Explicitly export only the public types (not internal query types) +export type { + Client, + Encrypted, + EncryptedPayload, + EncryptedData, + SearchTerm, + SimpleSearchTerm, + KeysetIdentifier, + EncryptedSearchTerm, + EncryptPayload, + EncryptOptions, + EncryptQueryOptions, + EncryptedFields, + OtherFields, + DecryptedFields, + Decrypted, + BulkEncryptPayload, + BulkEncryptedData, + BulkDecryptPayload, + BulkDecryptedData, + DecryptionResult, + QuerySearchTerm, + JsonSearchTerm, + JsonPath, + JsonPathSearchTerm, + JsonContainmentSearchTerm, + // New unified QueryTerm types + QueryTerm, + ScalarQueryTermBase, + JsonQueryTermBase, + ScalarQueryTerm, + JsonPathQueryTerm, + JsonContainsQueryTerm, + JsonContainedByQueryTerm, + // Query option types (used in ScalarQueryTerm) + QueryTypeName, + QueryOpName, +} from './types' + +// Export queryTypes constant for explicit query type selection +export { queryTypes } from './types' + +// Export type guards +export { + isScalarQueryTerm, + isJsonPathQueryTerm, + isJsonContainsQueryTerm, + isJsonContainedByQueryTerm, +} from './query-term-guards' +export type { JsPlaintext } from '@cipherstash/protect-ffi' \ No newline at end of file