import { type Encrypted as CipherStashEncrypted, type DecryptBulkOptions, type JsPlaintext, decryptBulk, encryptBulk, } from '@cipherstash/protect-ffi' import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' import { isEncryptedPayload } from '../helpers' import type { GetLockContextResponse } from '../identify' import type { Client, Decrypted, Encrypted } from '../types' import type { AuditData } from './operations/base-operation' /** * Helper function to extract encrypted fields from a model */ export function extractEncryptedFields>( model: T, ): Record { const result: Record = {} for (const [key, value] of Object.entries(model)) { if (isEncryptedPayload(value)) { result[key] = value } } return result } /** * Helper function to extract non-encrypted fields from a model */ export function extractOtherFields>( model: T, ): Record { const result: Record = {} for (const [key, value] of Object.entries(model)) { if (!isEncryptedPayload(value)) { result[key] = value } } return result } /** * Helper function to merge encrypted and non-encrypted fields into a model */ export function mergeFields( otherFields: Record, encryptedFields: Record, ): T { return { ...otherFields, ...encryptedFields } as T } /** * Base interface for bulk operation payloads */ interface BulkOperationPayload { id: string [key: string]: unknown } /** * Interface for bulk operation key mapping */ interface BulkOperationKeyMap { modelIndex: number fieldKey: string } /** * Helper function to handle single model bulk operations with mapping */ async function handleSingleModelBulkOperation< T extends BulkOperationPayload, R, >( items: T[], operation: (items: T[]) => Promise, keyMap: Record, ): Promise> { if (items.length === 0) { return {} } const results = await operation(items) const mappedResults: Record = {} results.forEach((result, index) => { const originalKey = keyMap[index.toString()] mappedResults[originalKey] = result }) return mappedResults } /** * Helper function to handle multiple model bulk operations with mapping */ async function handleMultiModelBulkOperation( items: T[], operation: (items: T[]) => Promise, keyMap: Record, ): Promise> { if (items.length === 0) { return {} } const results = await operation(items) const mappedResults: Record = {} results.forEach((result, index) => { const key = index.toString() const { modelIndex, fieldKey } = keyMap[key] mappedResults[`${modelIndex}-${fieldKey}`] = result }) return mappedResults } /** * Helper function to prepare fields for decryption */ function prepareFieldsForDecryption>( model: T, ): { otherFields: Record operationFields: Record keyMap: Record nullFields: Record } { const otherFields = { ...model } as Record const operationFields: Record = {} const nullFields: Record = {} const keyMap: Record = {} let index = 0 const processNestedFields = (obj: Record, prefix = '') => { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key if (value === null || value === undefined) { nullFields[fullKey] = value continue } if (typeof value === 'object' && !isEncryptedPayload(value)) { // Recursively process nested objects processNestedFields(value as Record, fullKey) } else if (isEncryptedPayload(value)) { // This is an encrypted field const id = index.toString() keyMap[id] = fullKey operationFields[fullKey] = value index++ // Remove from otherFields const parts = fullKey.split('.') let current = otherFields for (let i = 0; i < parts.length - 1; i++) { current = current[parts[i]] as Record } delete current[parts[parts.length - 1]] } } } processNestedFields(model) return { otherFields, operationFields, keyMap, nullFields } } /** * Helper function to prepare fields for encryption */ function prepareFieldsForEncryption>( model: T, table: ProtectTable, ): { otherFields: Record operationFields: Record keyMap: Record nullFields: Record } { const otherFields = { ...model } as Record const operationFields: Record = {} const nullFields: Record = {} const keyMap: Record = {} let index = 0 const processNestedFields = ( obj: Record, prefix = '', columnPaths: string[] = [], ) => { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key if (value === null || value === undefined) { nullFields[fullKey] = value continue } if ( typeof value === 'object' && !isEncryptedPayload(value) && !columnPaths.includes(fullKey) ) { // Only process nested objects if they're in the schema if (columnPaths.some((path) => path.startsWith(fullKey))) { processNestedFields( value as Record, fullKey, columnPaths, ) } } else if (columnPaths.includes(fullKey)) { // Only process fields that are explicitly defined in the schema const id = index.toString() keyMap[id] = fullKey operationFields[fullKey] = value index++ // Remove from otherFields const parts = fullKey.split('.') let current = otherFields for (let i = 0; i < parts.length - 1; i++) { current = current[parts[i]] as Record } delete current[parts[parts.length - 1]] } } } // Get all column paths from the table schema const columnPaths = Object.keys(table.build().columns) processNestedFields(model, '', columnPaths) return { otherFields, operationFields, keyMap, nullFields } } /** * Helper function to convert a model with encrypted fields to a decrypted model */ export async function decryptModelFields>( model: T, client: Client, auditData?: AuditData, ): Promise> { if (!client) { throw new Error('Client not initialized') } const { otherFields, operationFields, keyMap, nullFields } = prepareFieldsForDecryption(model) const bulkDecryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, ciphertext: value as CipherStashEncrypted, }), ) const decryptedFields = await handleSingleModelBulkOperation( bulkDecryptPayload, (items) => decryptBulk(client, { ciphertexts: items, unverifiedContext: auditData?.metadata, }), keyMap, ) // Helper function to set a nested value const setNestedValue = ( obj: Record, path: string[], value: unknown, ) => { let current = obj for (let i = 0; i < path.length - 1; i++) { const part = path[i] if (!(part in current)) { current[part] = {} } current = current[part] as Record } current[path[path.length - 1]] = value } // Reconstruct the object with proper nesting const result: Record = { ...otherFields } // First, reconstruct the null/undefined fields for (const [key, value] of Object.entries(nullFields)) { const parts = key.split('.') setNestedValue(result, parts, value) } // Then, reconstruct the decrypted fields for (const [key, value] of Object.entries(decryptedFields)) { const parts = key.split('.') setNestedValue(result, parts, value) } return result as Decrypted } /** * Helper function to convert a decrypted model to a model with encrypted fields */ export async function encryptModelFields>( model: Decrypted, table: ProtectTable, client: Client, auditData?: AuditData, ): Promise { if (!client) { throw new Error('Client not initialized') } const { otherFields, operationFields, keyMap, nullFields } = prepareFieldsForEncryption(model, table) const bulkEncryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, plaintext: value as string, table: table.tableName, column: key, }), ) const encryptedData = await handleSingleModelBulkOperation( bulkEncryptPayload, (items) => encryptBulk(client, { plaintexts: items, unverifiedContext: auditData?.metadata, }), keyMap, ) // Helper function to set a nested value const setNestedValue = ( obj: Record, path: string[], value: unknown, ) => { let current = obj for (let i = 0; i < path.length - 1; i++) { const part = path[i] if (!(part in current)) { current[part] = {} } current = current[part] as Record } current[path[path.length - 1]] = value } // Reconstruct the object with proper nesting const result: Record = { ...otherFields } // First, reconstruct the null/undefined fields for (const [key, value] of Object.entries(nullFields)) { const parts = key.split('.') setNestedValue(result, parts, value) } // Then, reconstruct the encrypted fields for (const [key, value] of Object.entries(encryptedData)) { const parts = key.split('.') setNestedValue(result, parts, value) } return result as T } /** * Helper function to convert a model with encrypted fields to a decrypted model with lock context */ export async function decryptModelFieldsWithLockContext< T extends Record, >( model: T, client: Client, lockContext: GetLockContextResponse, auditData?: AuditData, ): Promise> { if (!client) { throw new Error('Client not initialized') } if (!lockContext) { throw new Error('Lock context is not initialized') } const { otherFields, operationFields, keyMap, nullFields } = prepareFieldsForDecryption(model) const bulkDecryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, ciphertext: value as CipherStashEncrypted, lockContext: lockContext.context, }), ) const decryptedFields = await handleSingleModelBulkOperation( bulkDecryptPayload, (items) => decryptBulk(client, { ciphertexts: items, serviceToken: lockContext.ctsToken, unverifiedContext: auditData?.metadata, }), keyMap, ) // Helper function to set a nested value const setNestedValue = ( obj: Record, path: string[], value: unknown, ) => { let current = obj for (let i = 0; i < path.length - 1; i++) { const part = path[i] if (!(part in current)) { current[part] = {} } current = current[part] as Record } current[path[path.length - 1]] = value } // Reconstruct the object with proper nesting const result: Record = { ...otherFields } // First, reconstruct the null/undefined fields for (const [key, value] of Object.entries(nullFields)) { const parts = key.split('.') setNestedValue(result, parts, value) } // Then, reconstruct the decrypted fields for (const [key, value] of Object.entries(decryptedFields)) { const parts = key.split('.') setNestedValue(result, parts, value) } return result as Decrypted } /** * Helper function to convert a decrypted model to a model with encrypted fields with lock context */ export async function encryptModelFieldsWithLockContext< T extends Record, >( model: Decrypted, table: ProtectTable, client: Client, lockContext: GetLockContextResponse, auditData?: AuditData, ): Promise { if (!client) { throw new Error('Client not initialized') } if (!lockContext) { throw new Error('Lock context is not initialized') } const { otherFields, operationFields, keyMap, nullFields } = prepareFieldsForEncryption(model, table) const bulkEncryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, plaintext: value as string, table: table.tableName, column: key, lockContext: lockContext.context, }), ) const encryptedData = await handleSingleModelBulkOperation( bulkEncryptPayload, (items) => encryptBulk(client, { plaintexts: items, serviceToken: lockContext.ctsToken, unverifiedContext: auditData?.metadata, }), keyMap, ) // Helper function to set a nested value const setNestedValue = ( obj: Record, path: string[], value: unknown, ) => { let current = obj for (let i = 0; i < path.length - 1; i++) { const part = path[i] if (!(part in current)) { current[part] = {} } current = current[part] as Record } current[path[path.length - 1]] = value } // Reconstruct the object with proper nesting const result: Record = { ...otherFields } // First, reconstruct the null/undefined fields for (const [key, value] of Object.entries(nullFields)) { const parts = key.split('.') setNestedValue(result, parts, value) } // Then, reconstruct the encrypted fields for (const [key, value] of Object.entries(encryptedData)) { const parts = key.split('.') setNestedValue(result, parts, value) } return result as T } /** * Helper function to prepare multiple models for bulk operation */ function prepareBulkModelsForOperation>( models: T[], table?: ProtectTable, ): { otherFields: Record[] operationFields: Record[] keyMap: Record nullFields: Record[] } { const otherFields: Record[] = [] const operationFields: Record[] = [] const nullFields: Record[] = [] const keyMap: Record = {} let index = 0 for (let modelIndex = 0; modelIndex < models.length; modelIndex++) { const model = models[modelIndex] const modelOtherFields = { ...model } as Record const modelOperationFields: Record = {} const modelNullFields: Record = {} const processNestedFields = ( obj: Record, prefix = '', columnPaths: string[] = [], ) => { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key if (value === null || value === undefined) { modelNullFields[fullKey] = value continue } if ( typeof value === 'object' && !isEncryptedPayload(value) && !columnPaths.includes(fullKey) ) { // Only process nested objects if they're in the schema if (columnPaths.some((path) => path.startsWith(fullKey))) { processNestedFields( value as Record, fullKey, columnPaths, ) } } else if (columnPaths.includes(fullKey)) { // Only process fields that are explicitly defined in the schema const id = index.toString() keyMap[id] = { modelIndex, fieldKey: fullKey } modelOperationFields[fullKey] = value index++ // Remove from otherFields const parts = fullKey.split('.') let current = modelOtherFields for (let i = 0; i < parts.length - 1; i++) { current = current[parts[i]] as Record } delete current[parts[parts.length - 1]] } } } if (table) { // Get all column paths from the table schema const columnPaths = Object.keys(table.build().columns) processNestedFields(model, '', columnPaths) } else { // For decryption, process all encrypted fields const processEncryptedFields = ( obj: Record, prefix = '', columnPaths: string[] = [], ) => { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key if (value === null || value === undefined) { modelNullFields[fullKey] = value continue } if ( typeof value === 'object' && !isEncryptedPayload(value) && !columnPaths.includes(fullKey) ) { // Recursively process nested objects processEncryptedFields( value as Record, fullKey, columnPaths, ) } else if (isEncryptedPayload(value)) { // This is an encrypted field const id = index.toString() keyMap[id] = { modelIndex, fieldKey: fullKey } modelOperationFields[fullKey] = value index++ // Remove from otherFields const parts = fullKey.split('.') let current = modelOtherFields for (let i = 0; i < parts.length - 1; i++) { current = current[parts[i]] as Record } delete current[parts[parts.length - 1]] } } } processEncryptedFields(model) } otherFields.push(modelOtherFields) operationFields.push(modelOperationFields) nullFields.push(modelNullFields) } return { otherFields, operationFields, keyMap, nullFields } } /** * Helper function to convert multiple decrypted models to models with encrypted fields */ export async function bulkEncryptModels>( models: Decrypted[], table: ProtectTable, client: Client, auditData?: AuditData, ): Promise { if (!client) { throw new Error('Client not initialized') } if (!models || models.length === 0) { return [] } const { otherFields, operationFields, keyMap, nullFields } = prepareBulkModelsForOperation(models, table) const bulkEncryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, plaintext: value as string, table: table.tableName, column: key, })), ) const encryptedData = await handleMultiModelBulkOperation( bulkEncryptPayload, (items) => encryptBulk(client, { plaintexts: items, unverifiedContext: auditData?.metadata, }), keyMap, ) // Helper function to set a nested value const setNestedValue = ( obj: Record, path: string[], value: unknown, ) => { let current = obj for (let i = 0; i < path.length - 1; i++) { const part = path[i] if (!(part in current)) { current[part] = {} } current = current[part] as Record } current[path[path.length - 1]] = value } return models.map((_, modelIndex) => { const result: Record = { ...otherFields[modelIndex] } // First, reconstruct the null/undefined fields for (const [key, value] of Object.entries(nullFields[modelIndex])) { const parts = key.split('.') setNestedValue(result, parts, value) } // Then, reconstruct the encrypted fields const modelData = Object.fromEntries( Object.entries(encryptedData) .filter(([key]) => { const [idx] = key.split('-') return Number.parseInt(idx) === modelIndex }) .map(([key, value]) => { const [_, fieldKey] = key.split('-') return [fieldKey, value] }), ) for (const [key, value] of Object.entries(modelData)) { const parts = key.split('.') setNestedValue(result, parts, value) } return result as T }) } /** * Helper function to convert multiple models with encrypted fields to decrypted models */ export async function bulkDecryptModels>( models: T[], client: Client, auditData?: AuditData, ): Promise[]> { if (!client) { throw new Error('Client not initialized') } if (!models || models.length === 0) { return [] } const { otherFields, operationFields, keyMap, nullFields } = prepareBulkModelsForOperation(models) const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, ciphertext: value as CipherStashEncrypted, })), ) const decryptedFields = await handleMultiModelBulkOperation( bulkDecryptPayload, (items) => decryptBulk(client, { ciphertexts: items, unverifiedContext: auditData?.metadata, }), keyMap, ) // Helper function to set a nested value const setNestedValue = ( obj: Record, path: string[], value: unknown, ) => { let current = obj for (let i = 0; i < path.length - 1; i++) { const part = path[i] if (!(part in current)) { current[part] = {} } current = current[part] as Record } current[path[path.length - 1]] = value } return models.map((_, modelIndex) => { const result: Record = { ...otherFields[modelIndex] } // First, reconstruct the null/undefined fields for (const [key, value] of Object.entries(nullFields[modelIndex])) { const parts = key.split('.') setNestedValue(result, parts, value) } // Then, reconstruct the decrypted fields const modelData = Object.fromEntries( Object.entries(decryptedFields) .filter(([key]) => { const [idx] = key.split('-') return Number.parseInt(idx) === modelIndex }) .map(([key, value]) => { const [_, fieldKey] = key.split('-') return [fieldKey, value] }), ) for (const [key, value] of Object.entries(modelData)) { const parts = key.split('.') setNestedValue(result, parts, value) } return result as Decrypted }) } /** * Helper function to convert multiple models with encrypted fields to decrypted models with lock context */ export async function bulkDecryptModelsWithLockContext< T extends Record, >( models: T[], client: Client, lockContext: GetLockContextResponse, auditData?: AuditData, ): Promise[]> { if (!client) { throw new Error('Client not initialized') } if (!lockContext) { throw new Error('Lock context is not initialized') } const { otherFields, operationFields, keyMap, nullFields } = prepareBulkModelsForOperation(models) const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, ciphertext: value as CipherStashEncrypted, lockContext: lockContext.context, })), ) const decryptedFields = await handleMultiModelBulkOperation( bulkDecryptPayload, (items) => decryptBulk(client, { ciphertexts: items, serviceToken: lockContext.ctsToken, unverifiedContext: auditData?.metadata, }), keyMap, ) // Reconstruct models return models.map((_, modelIndex) => ({ ...otherFields[modelIndex], ...nullFields[modelIndex], ...Object.fromEntries( Object.entries(decryptedFields) .filter(([key]) => { const [idx] = key.split('-') return Number.parseInt(idx) === modelIndex }) .map(([key, value]) => { const [_, fieldKey] = key.split('-') return [fieldKey, value] }), ), })) as Decrypted[] } /** * Helper function to convert multiple decrypted models to models with encrypted fields with lock context */ export async function bulkEncryptModelsWithLockContext< T extends Record, >( models: Decrypted[], table: ProtectTable, client: Client, lockContext: GetLockContextResponse, auditData?: AuditData, ): Promise { if (!client) { throw new Error('Client not initialized') } if (!lockContext) { throw new Error('Lock context is not initialized') } const { otherFields, operationFields, keyMap, nullFields } = prepareBulkModelsForOperation(models, table) const bulkEncryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, plaintext: value as string, table: table.tableName, column: key, lockContext: lockContext.context, })), ) const encryptedData = await handleMultiModelBulkOperation( bulkEncryptPayload, (items) => encryptBulk(client, { plaintexts: items, serviceToken: lockContext.ctsToken, unverifiedContext: auditData?.metadata, }), keyMap, ) // Reconstruct models return models.map((_, modelIndex) => ({ ...otherFields[modelIndex], ...nullFields[modelIndex], ...Object.fromEntries( Object.entries(encryptedData) .filter(([key]) => { const [idx] = key.split('-') return Number.parseInt(idx) === modelIndex }) .map(([key, value]) => { const [_, fieldKey] = key.split('-') return [fieldKey, value] }), ), })) as T[] }