From 939f6023c196b650885ee9bbfb3d68efc9deac1c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 29 Jan 2026 20:25:54 +1100 Subject: [PATCH] feat(protect): add QueryTerm types and search-terms JSON support Add foundational types for the new encryptQuery API: - QueryTerm union types (ScalarQueryTerm, JsonPathQueryTerm, etc.) - QueryTypeName mapping to schema builder methods - Type guards for QueryTerm variants - JSON path utilities for path conversion Update search-terms operation to support JSON path and containment queries alongside simple value searches. Add searchableJson() method to schema builder for SteVec indexing. Upgrade protect-ffi to 0.20.1 for encryptQueryBulk support. --- .../__tests__/query-term-guards.test.ts | 363 ++++++++++++++++++ packages/protect/package.json | 2 +- .../src/ffi/operations/json-path-utils.ts | 46 +++ .../src/ffi/operations/search-terms.ts | 281 ++++++++++++-- packages/protect/src/query-term-guards.ts | 64 +++ packages/protect/src/types.ts | 306 ++++++++++++++- packages/schema/src/index.ts | 165 +++++++- pnpm-lock.yaml | 58 +-- 8 files changed, 1206 insertions(+), 79 deletions(-) create mode 100644 packages/protect/__tests__/query-term-guards.test.ts create mode 100644 packages/protect/src/ffi/operations/json-path-utils.ts create mode 100644 packages/protect/src/query-term-guards.ts diff --git a/packages/protect/__tests__/query-term-guards.test.ts b/packages/protect/__tests__/query-term-guards.test.ts new file mode 100644 index 00000000..283b29af --- /dev/null +++ b/packages/protect/__tests__/query-term-guards.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it } from 'vitest' +import { + isScalarQueryTerm, + isJsonPathQueryTerm, + isJsonContainsQueryTerm, + isJsonContainedByQueryTerm, +} from '../src/query-term-guards' + +describe('query-term-guards', () => { + describe('isScalarQueryTerm', () => { + it('should return true when both value and queryType are present', () => { + const term = { + value: 'test', + queryType: 'equality', + column: {}, + table: {}, + } + expect(isScalarQueryTerm(term)).toBe(true) + }) + + it('should return true with all properties including optional ones', () => { + const term = { + value: 'test', + queryType: 'orderAndRange', + column: {}, + table: {}, + queryOp: 'default', + returnType: 'eql', + } + expect(isScalarQueryTerm(term)).toBe(true) + }) + + it('should return false when value is missing', () => { + const term = { + queryType: 'equality', + column: {}, + table: {}, + } + expect(isScalarQueryTerm(term)).toBe(false) + }) + + it('should return true when queryType is missing (optional - auto-inferred)', () => { + const term = { + value: 'test', + column: {}, + table: {}, + } + // queryType is now optional - terms without it use auto-inference + expect(isScalarQueryTerm(term)).toBe(true) + }) + + it('should return false when both value and queryType are missing', () => { + const term = { + column: {}, + table: {}, + } + expect(isScalarQueryTerm(term)).toBe(false) + }) + + it('should return false for empty object', () => { + const term = {} + expect(isScalarQueryTerm(term)).toBe(false) + }) + + it('should return true with extra properties present', () => { + const term = { + value: 'test', + queryType: 'freeTextSearch', + column: {}, + table: {}, + extraProp: 'extra', + anotherProp: 123, + } + expect(isScalarQueryTerm(term)).toBe(true) + }) + + it('should return true even when value is null (property exists)', () => { + const term = { + value: null, + queryType: 'equality', + column: {}, + table: {}, + } + expect(isScalarQueryTerm(term)).toBe(true) + }) + + it('should return true even when queryType is null (property exists)', () => { + const term = { + value: 'test', + queryType: null, + column: {}, + table: {}, + } + expect(isScalarQueryTerm(term)).toBe(true) + }) + + it('should return true even when value is undefined (property exists)', () => { + const term = { + value: undefined, + queryType: 'equality', + column: {}, + table: {}, + } + expect(isScalarQueryTerm(term)).toBe(true) + }) + + it('should return true even when queryType is undefined (property exists)', () => { + const term = { + value: 'test', + queryType: undefined, + column: {}, + table: {}, + } + expect(isScalarQueryTerm(term)).toBe(true) + }) + }) + + describe('isJsonPathQueryTerm', () => { + it('should return true when path property exists', () => { + const term = { + path: 'user.email', + column: {}, + table: {}, + } + expect(isJsonPathQueryTerm(term)).toBe(true) + }) + + it('should return true with all properties including optional ones', () => { + const term = { + path: 'user.name', + value: 'John', + column: {}, + table: {}, + } + expect(isJsonPathQueryTerm(term)).toBe(true) + }) + + it('should return true with extra properties', () => { + const term = { + path: 'data.nested.field', + column: {}, + table: {}, + extraProp: 'extra', + anotherField: 42, + } + expect(isJsonPathQueryTerm(term)).toBe(true) + }) + + it('should return false when path property is missing', () => { + const term = { + column: {}, + table: {}, + value: 'test', + } + expect(isJsonPathQueryTerm(term)).toBe(false) + }) + + it('should return false for empty object', () => { + const term = {} + expect(isJsonPathQueryTerm(term)).toBe(false) + }) + + it('should return true even when path is null', () => { + const term = { + path: null, + column: {}, + table: {}, + } + expect(isJsonPathQueryTerm(term)).toBe(true) + }) + + it('should return true even when path is undefined', () => { + const term = { + path: undefined, + column: {}, + table: {}, + } + expect(isJsonPathQueryTerm(term)).toBe(true) + }) + + it('should return false when path-like property with different name', () => { + const term = { + pathName: 'user.email', + column: {}, + table: {}, + } + expect(isJsonPathQueryTerm(term)).toBe(false) + }) + }) + + describe('isJsonContainsQueryTerm', () => { + it('should return true when contains property exists', () => { + const term = { + contains: { key: 'value' }, + column: {}, + table: {}, + } + expect(isJsonContainsQueryTerm(term)).toBe(true) + }) + + it('should return true with empty object as contains', () => { + const term = { + contains: {}, + column: {}, + table: {}, + } + expect(isJsonContainsQueryTerm(term)).toBe(true) + }) + + it('should return true with complex nested object as contains', () => { + const term = { + contains: { + user: { + email: 'test@example.com', + roles: ['admin', 'user'], + }, + }, + column: {}, + table: {}, + } + expect(isJsonContainsQueryTerm(term)).toBe(true) + }) + + it('should return true with extra properties', () => { + const term = { + contains: { status: 'active' }, + column: {}, + table: {}, + extraProp: 'extra', + anotherField: 42, + } + expect(isJsonContainsQueryTerm(term)).toBe(true) + }) + + it('should return false when contains property is missing', () => { + const term = { + column: {}, + table: {}, + data: { key: 'value' }, + } + expect(isJsonContainsQueryTerm(term)).toBe(false) + }) + + it('should return false for empty object', () => { + const term = {} + expect(isJsonContainsQueryTerm(term)).toBe(false) + }) + + it('should return true even when contains is null', () => { + const term = { + contains: null, + column: {}, + table: {}, + } + expect(isJsonContainsQueryTerm(term)).toBe(true) + }) + + it('should return true even when contains is undefined', () => { + const term = { + contains: undefined, + column: {}, + table: {}, + } + expect(isJsonContainsQueryTerm(term)).toBe(true) + }) + + it('should return false when contains-like property with different name', () => { + const term = { + containsData: { key: 'value' }, + column: {}, + table: {}, + } + expect(isJsonContainsQueryTerm(term)).toBe(false) + }) + }) + + describe('isJsonContainedByQueryTerm', () => { + it('should return true when containedBy property exists', () => { + const term = { + containedBy: { key: 'value' }, + column: {}, + table: {}, + } + expect(isJsonContainedByQueryTerm(term)).toBe(true) + }) + + it('should return true with empty object as containedBy', () => { + const term = { + containedBy: {}, + column: {}, + table: {}, + } + expect(isJsonContainedByQueryTerm(term)).toBe(true) + }) + + it('should return true with complex nested object as containedBy', () => { + const term = { + containedBy: { + permissions: { + read: true, + write: false, + admin: true, + }, + }, + column: {}, + table: {}, + } + expect(isJsonContainedByQueryTerm(term)).toBe(true) + }) + + it('should return true with extra properties', () => { + const term = { + containedBy: { status: 'active' }, + column: {}, + table: {}, + extraProp: 'extra', + anotherField: 42, + } + expect(isJsonContainedByQueryTerm(term)).toBe(true) + }) + + it('should return false when containedBy property is missing', () => { + const term = { + column: {}, + table: {}, + data: { key: 'value' }, + } + expect(isJsonContainedByQueryTerm(term)).toBe(false) + }) + + it('should return false for empty object', () => { + const term = {} + expect(isJsonContainedByQueryTerm(term)).toBe(false) + }) + + it('should return true even when containedBy is null', () => { + const term = { + containedBy: null, + column: {}, + table: {}, + } + expect(isJsonContainedByQueryTerm(term)).toBe(true) + }) + + it('should return true even when containedBy is undefined', () => { + const term = { + containedBy: undefined, + column: {}, + table: {}, + } + expect(isJsonContainedByQueryTerm(term)).toBe(true) + }) + + it('should return false when containedBy-like property with different name', () => { + const term = { + containedByData: { key: 'value' }, + column: {}, + table: {}, + } + expect(isJsonContainedByQueryTerm(term)).toBe(false) + }) + }) +}) diff --git a/packages/protect/package.json b/packages/protect/package.json index 0b2267a0..38cde34a 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -68,7 +68,7 @@ }, "dependencies": { "@byteslice/result": "^0.2.0", - "@cipherstash/protect-ffi": "0.19.0", + "@cipherstash/protect-ffi": "0.20.1", "@cipherstash/schema": "workspace:*", "@stricli/core": "^1.2.5", "dotenv": "16.4.7", diff --git a/packages/protect/src/ffi/operations/json-path-utils.ts b/packages/protect/src/ffi/operations/json-path-utils.ts new file mode 100644 index 00000000..6f674455 --- /dev/null +++ b/packages/protect/src/ffi/operations/json-path-utils.ts @@ -0,0 +1,46 @@ +import type { JsonPath } from '../../types' + +/** + * Converts a path to JSON Path format: $.path.to.key + */ +export function toDollarPath(path: JsonPath): string { + const pathArray = Array.isArray(path) ? path : path.split('.') + // Handle special characters in keys if needed, but for now simple dot notation or bracket notation + // If keys contain dots or other special chars, they should be quoted in bracket notation + // But standard ste_vec implementation might expect simple dot notation for now or handle quoting. + // Let's assume simple dot notation is sufficient or keys are simple. + // Actually, to be safe, maybe we should just join with dots. + // But if a key is "a.b", dot join makes "a.b", which is 2 segments. + // Valid JSON path should be $['a.b'] + // Let's try to construct a robust JSON path. + // For now, let's use the simple implementation: $.a.b + // The error message `expected root selector '$'` suggests it parses standard JSON path. + + // Update: Construct valid JSONPath. + const selector = pathArray.map(seg => { + if (/^[a-zA-Z0-9_]+$/.test(seg)) { + return `.${seg}` + } + return `["${seg.replace(/"/g, '\\"')}"]` + }).join('') + + return `\$${selector}` +} + +/** + * Build a nested JSON object from a path array and a leaf value. + * E.g., ['user', 'role'], 'admin' => { user: { role: 'admin' } } + */ +export function buildNestedObject( + path: string[], + value: unknown, +): Record { + if (path.length === 0) { + return value as Record + } + if (path.length === 1) { + return { [path[0]]: value } + } + const [first, ...rest] = path + return { [first]: buildNestedObject(rest, value) } +} diff --git a/packages/protect/src/ffi/operations/search-terms.ts b/packages/protect/src/ffi/operations/search-terms.ts index 3949ee2e..f0413f82 100644 --- a/packages/protect/src/ffi/operations/search-terms.ts +++ b/packages/protect/src/ffi/operations/search-terms.ts @@ -1,11 +1,253 @@ import { type Result, withResult } from '@byteslice/result' -import { encryptBulk } from '@cipherstash/protect-ffi' +import { + encryptBulk, + encryptQueryBulk, + ProtectError as FfiProtectError, +} from '@cipherstash/protect-ffi' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' -import type { Client, EncryptedSearchTerm, SearchTerm } from '../../types' +import type { + Client, + Encrypted, + EncryptedSearchTerm, + JsPlaintext, + JsonContainmentSearchTerm, + JsonPathSearchTerm, + QueryOpName, + SearchTerm, + SimpleSearchTerm, +} from '../../types' +import { queryTypeToFfi } from '../../types' import { noClientError } from '../index' +import { buildNestedObject, toDollarPath } from './json-path-utils' import { ProtectOperation } from './base-operation' +/** + * Type guard to check if a search term is a JSON path search term + */ +function isJsonPathTerm(term: SearchTerm): term is JsonPathSearchTerm { + return 'path' in term +} + +/** + * Type guard to check if a search term is a JSON containment search term + */ +function isJsonContainmentTerm( + term: SearchTerm, +): term is JsonContainmentSearchTerm { + return 'containmentType' in term +} + +/** + * Type guard to check if a search term is a simple value search term + */ +function isSimpleSearchTerm(term: SearchTerm): term is SimpleSearchTerm { + return !isJsonPathTerm(term) && !isJsonContainmentTerm(term) +} + +/** 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 function to encrypt search terms + * Shared logic between SearchTermsOperation and SearchTermsOperationWithLockContext + * @param client The client to use for encryption + * @param terms The search terms to encrypt + * @param metadata Audit metadata for encryption + */ +async function encryptSearchTermsHelper( + client: Client, + terms: SearchTerm[], + metadata: Record | undefined, +): Promise { + if (!client) { + throw noClientError() + } + + // Partition terms by type + const simpleTermsWithIndex: Array<{ term: SimpleSearchTerm; 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 (isSimpleSearchTerm(term)) { + simpleTermsWithIndex.push({ term, index: i }) + } else if (isJsonContainmentTerm(term)) { + // Containment query - 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.value, + column: term.column.getName(), + table: term.table.tableName, + }) + } else if (isJsonPathTerm(term)) { + // Path query - 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) { + // Path query with value - wrap in nested object + 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 simple terms with encryptBulk + const simpleEncrypted = + simpleTermsWithIndex.length > 0 + ? await encryptBulk(client, { + plaintexts: simpleTermsWithIndex.map(({ 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 simpleIdx = 0 + let containmentIdx = 0 + let pathIdx = 0 + let selectorOnlyIdx = 0 + + for (let i = 0; i < terms.length; i++) { + const term = terms[i] + + if (isSimpleSearchTerm(term)) { + const encrypted = simpleEncrypted[simpleIdx] + simpleIdx++ + + // Apply return type formatting + 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 (isJsonContainmentTerm(term)) { + // FFI returns complete { sv: [...] } structure - use directly + results[i] = jsonContainmentEncrypted[containmentIdx] as Encrypted + containmentIdx++ + } else if (isJsonPathTerm(term)) { + if (term.value !== undefined) { + // FFI returns complete { sv: [...] } structure for path+value queries + results[i] = jsonPathEncrypted[pathIdx] as Encrypted + pathIdx++ + } else { + // Path-only (no value comparison) + results[i] = selectorOnlyEncrypted[selectorOnlyIdx] + selectorOnlyIdx++ + } + } + } + + return results +} + export class SearchTermsOperation extends ProtectOperation< EncryptedSearchTerm[] > { @@ -25,37 +267,26 @@ export class SearchTermsOperation extends ProtectOperation< return await withResult( async () => { - if (!this.client) { - throw noClientError() - } - const { metadata } = this.getAuditData() - const encryptedSearchTerms = await encryptBulk(this.client, { - plaintexts: this.terms.map((term) => ({ - plaintext: term.value, - column: term.column.getName(), - table: term.table.tableName, - })), - unverifiedContext: metadata, - }) - - return this.terms.map((term, index) => { - if (term.returnType === 'composite-literal') { - return `(${JSON.stringify(JSON.stringify(encryptedSearchTerms[index]))})` - } - - if (term.returnType === 'escaped-composite-literal') { - return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encryptedSearchTerms[index]))})`)}` - } + // Call helper with no lock context + const results = await encryptSearchTermsHelper( + this.client, + this.terms, + metadata, + ) - return encryptedSearchTerms[index] - }) + return results }, (error) => ({ type: ProtectErrorTypes.EncryptionError, message: error.message, + code: error instanceof FfiProtectError ? error.code : undefined, }), ) } + + public getOperation() { + return { client: this.client, terms: this.terms } + } } diff --git a/packages/protect/src/query-term-guards.ts b/packages/protect/src/query-term-guards.ts new file mode 100644 index 00000000..b313ddd6 --- /dev/null +++ b/packages/protect/src/query-term-guards.ts @@ -0,0 +1,64 @@ +import type { + JsonContainedByQueryTerm, + JsonContainsQueryTerm, + JsonPathQueryTerm, + QueryTerm, + ScalarQueryTerm, +} from './types' + +/** + * Type guard for scalar query terms. + * Scalar terms have 'value' but not JSON-specific properties (path, contains, containedBy). + * Note: queryType is now optional for scalar terms (auto-inferred when omitted). + */ +export function isScalarQueryTerm(term: QueryTerm): term is ScalarQueryTerm { + return ( + 'value' in term && + !('path' in term) && + !('contains' in term) && + !('containedBy' in term) + ) +} + +/** + * Type guard for JSON path query terms (have path) + */ +export function isJsonPathQueryTerm( + term: QueryTerm, +): term is JsonPathQueryTerm { + return 'path' in term +} + +/** + * Type guard for JSON contains query terms (have contains) + */ +export function isJsonContainsQueryTerm( + term: QueryTerm, +): term is JsonContainsQueryTerm { + return 'contains' in term +} + +/** + * Type guard for JSON containedBy query terms (have containedBy) + */ +export function isJsonContainedByQueryTerm( + term: QueryTerm, +): term is JsonContainedByQueryTerm { + return 'containedBy' in term +} + +/** + * Type guard to check if an array contains QueryTerm objects. + * Checks for QueryTerm-specific properties (column/table) to distinguish + * from JsPlaintext[] which can also be an array of objects. + */ +export function isQueryTermArray( + arr: readonly unknown[], +): arr is readonly QueryTerm[] { + return ( + arr.length > 0 && + typeof arr[0] === 'object' && + arr[0] !== null && + ('column' in arr[0] || 'table' in arr[0]) + ) +} diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts index 7dc15705..c00b9b24 100644 --- a/packages/protect/src/types.ts +++ b/packages/protect/src/types.ts @@ -1,8 +1,68 @@ import type { Encrypted as CipherStashEncrypted, - JsPlaintext, + JsPlaintext as FfiJsPlaintext, newClient, } from '@cipherstash/protect-ffi' + +export type { JsPlaintext } from '@cipherstash/protect-ffi' + +/** + * Query type for query encryption operations. + * Matches the schema builder methods: .orderAndRange(), .freeTextSearch(), .equality(), .searchableJson() + * + * - `'orderAndRange'`: Order-Revealing Encryption for range queries (<, >, BETWEEN) + * {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/range | Range Queries} + * - `'freeTextSearch'`: Fuzzy/substring search + * {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/match | Match Queries} + * - `'equality'`: Exact equality matching + * {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/exact | Exact Queries} + * - `'searchableJson'`: Structured Text Encryption Vector for JSON path/containment queries + * {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/json | JSON Queries} + */ +export type QueryTypeName = 'orderAndRange' | 'freeTextSearch' | 'equality' | 'searchableJson' + +/** + * Internal FFI index type names. + * @internal + */ +export type FfiIndexTypeName = 'ore' | 'match' | 'unique' | 'ste_vec' + +/** + * Query type constants for use with encryptQuery(). + * + * @example + * import { queryTypes } from '@cipherstash/protect' + * await protectClient.encryptQuery('value', { + * column: users.email, + * table: users, + * queryType: queryTypes.freeTextSearch, + * }) + */ +export const queryTypes = { + orderAndRange: 'orderAndRange', + freeTextSearch: 'freeTextSearch', + equality: 'equality', + searchableJson: 'searchableJson', +} as const satisfies Record + +/** + * Maps user-friendly query type names to FFI index type names. + * @internal + */ +export const queryTypeToFfi: Record = { + orderAndRange: 'ore', + freeTextSearch: 'match', + equality: 'unique', + searchableJson: 'ste_vec', +} + +/** + * Query operation type for ste_vec index. + * - 'default': Standard JSON query using column's cast_type + * - 'ste_vec_selector': JSON path selection ($.user.email) + * - 'ste_vec_term': JSON containment (@>) + */ +export type QueryOpName = 'default' | 'ste_vec_selector' | 'ste_vec_term' import type { ProtectColumn, ProtectTable, @@ -33,15 +93,245 @@ export type EncryptedPayload = Encrypted | null export type EncryptedData = Encrypted | null /** - * Represents a value that will be encrypted and used in a search + * Simple search term for basic value encryption (original SearchTerm behavior) */ -export type SearchTerm = { - value: JsPlaintext +export type SimpleSearchTerm = { + value: FfiJsPlaintext column: ProtectColumn table: ProtectTable returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' } +/** + * Represents a value that will be encrypted and used in a search. + * Can be a simple value search, JSON path search, or JSON containment search. + */ +export type SearchTerm = + | SimpleSearchTerm + | JsonPathSearchTerm + | JsonContainmentSearchTerm + +/** + * Options for encrypting a query term with encryptQuery(). + * + * When queryType is omitted, the query type is auto-inferred from the column configuration. + * When queryType is provided, it explicitly controls which index to use. + */ +export type EncryptQueryOptions = { + /** The column definition from the schema */ + column: ProtectColumn | ProtectValue + /** The table definition from the schema */ + table: ProtectTable + /** Which query type to use for the query (optional - auto-inferred if omitted) */ + queryType?: QueryTypeName + /** Query operation (defaults to 'default') */ + queryOp?: QueryOpName +} + +/** + * Individual query payload for bulk query operations. + * Used with createQuerySearchTerms() for batch query encryption. + */ +export type QuerySearchTerm = { + /** The value to encrypt for querying */ + value: FfiJsPlaintext + /** The column definition */ + column: ProtectColumn | ProtectValue + /** The table definition */ + table: ProtectTable + /** Which query type to use */ + queryType: QueryTypeName + /** Query operation (optional, defaults to 'default') */ + queryOp?: QueryOpName + /** Return format for the encrypted result */ + returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' +} + +/** + * Base type for scalar query terms (accepts ProtectColumn | ProtectValue) + */ +export type ScalarQueryTermBase = { + /** The column definition (can be ProtectColumn or ProtectValue) */ + column: ProtectColumn | ProtectValue + /** The table definition */ + table: ProtectTable + /** Return format for the encrypted result */ + returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' +} + +/** + * Base type for JSON query terms (requires ProtectColumn for .build() access) + * Note: returnType is not supported for JSON terms as they return structured objects + */ +export type JsonQueryTermBase = { + /** The column definition (must be ProtectColumn with .searchableJson()) */ + column: ProtectColumn + /** The table definition */ + table: ProtectTable +} + +/** + * Scalar query term for standard column queries (equality, orderAndRange, freeTextSearch indexes). + * + * When queryType is omitted, the query type is auto-inferred from the column configuration. + * When queryType is provided, it explicitly controls which index to use. + * + * @example + * **Explicit Equality Match** + * ```typescript + * const term: ScalarQueryTerm = { + * value: 'admin@example.com', + * column: users.email, + * table: users, + * queryType: 'equality', + * returnType: 'composite-literal' // Required for PostgreSQL composite types + * } + * ``` + * + * **PostgreSQL Integration** + * ```sql + * SELECT * FROM users WHERE email = $1 + * -- Binds: [term] + * ``` + */ +export type ScalarQueryTerm = ScalarQueryTermBase & { + /** The value to encrypt for querying */ + value: FfiJsPlaintext + /** Which query type to use (optional - auto-inferred if omitted) */ + queryType?: QueryTypeName + /** Query operation (optional, defaults to 'default') */ + queryOp?: QueryOpName +} + +/** + * JSON path query term for searchableJson indexed columns. + * + * Used for finding records where a specific path in the JSON matches a value. + * Equivalent to `WHERE data->'user'->>'email' = 'alice@example.com'`. + * + * @example + * ```typescript + * const term: JsonPathQueryTerm = { + * path: 'user.email', + * value: 'alice@example.com', + * column: metadata, + * table: documents, + * } + * ``` + * + * **PostgreSQL Integration** + * ```sql + * SELECT * FROM users + * WHERE eql_ste_vec_u64_8_128_access(metadata, $1) = $2 + * -- Binds: [term.s, term.c] + * ``` + */ +export type JsonPathQueryTerm = JsonQueryTermBase & { + /** The path to navigate to in the JSON */ + path: JsonPath + /** The value to compare at the path (optional, for WHERE clauses) */ + value?: FfiJsPlaintext +} + +/** + * JSON containment query term for PostgreSQL `@>` operator. + * + * Find records where the JSON column contains the specified structure. + * Equivalent to `WHERE metadata @> '{"roles": ["admin"]}'`. + * + * @example + * ```typescript + * const term: JsonContainsQueryTerm = { + * contains: { roles: ['admin'] }, + * column: metadata, + * table: documents, + * } + * ``` + * + * **PostgreSQL Integration** + * ```sql + * SELECT * FROM users + * WHERE eql_ste_vec_u64_8_128_contains(metadata, $1) + * -- Binds: [JSON.stringify(term.sv)] + * ``` + */ +export type JsonContainsQueryTerm = JsonQueryTermBase & { + /** The JSON object to search for (PostgreSQL @> operator) */ + contains: Record +} + +/** + * JSON containment query term for PostgreSQL `<@` operator. + * + * Find records where the JSON column is contained by the specified structure. + * Equivalent to `WHERE metadata <@ '{"permissions": ["read", "write"]}'`. + * + * @example + * ```typescript + * const term: JsonContainedByQueryTerm = { + * containedBy: { permissions: ['read', 'write', 'admin'] }, + * column: metadata, + * table: documents, + * } + * ``` + * + * **PostgreSQL Integration** + * ```sql + * SELECT * FROM users + * WHERE eql_ste_vec_u64_8_128_contained_by(metadata, $1) + * -- Binds: [JSON.stringify(term.sv)] + * ``` + */ +export type JsonContainedByQueryTerm = JsonQueryTermBase & { + /** The JSON object to be contained by (PostgreSQL <@ operator) */ + containedBy: Record +} + +/** + * Union type for all query term variants in batch encryptQuery operations. + */ +export type QueryTerm = + | ScalarQueryTerm + | JsonPathQueryTerm + | JsonContainsQueryTerm + | JsonContainedByQueryTerm + +/** + * JSON path - either dot-notation string ('user.email') or array of keys (['user', 'email']) + */ +export type JsonPath = string | string[] + +/** + * Search term for JSON containment queries (@> / <@) + */ +export type JsonContainmentSearchTerm = { + /** The JSON object or partial object to search for */ + value: Record + column: ProtectColumn + table: ProtectTable + /** Type of containment: 'contains' for @>, 'contained_by' for <@ */ + containmentType: 'contains' | 'contained_by' + returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' +} + +/** + * Search term for JSON path access queries (-> / ->>) + */ +export type JsonPathSearchTerm = { + /** The path to navigate to in the JSON */ + path: JsonPath + /** The value to compare at the path (optional, for WHERE clauses) */ + value?: FfiJsPlaintext + column: ProtectColumn + table: ProtectTable + returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' +} + +/** + * Union type for JSON search operations + */ +export type JsonSearchTerm = JsonContainmentSearchTerm | JsonPathSearchTerm + export type KeysetIdentifier = | { name: string @@ -61,7 +351,7 @@ export type EncryptedSearchTerm = Encrypted | string /** * Represents a payload to be encrypted using the `encrypt` function */ -export type EncryptPayload = JsPlaintext | null +export type EncryptPayload = FfiJsPlaintext | null /** * Represents the options for encrypting a payload using the `encrypt` function @@ -102,12 +392,12 @@ export type Decrypted = OtherFields & DecryptedFields */ export type BulkEncryptPayload = Array<{ id?: string - plaintext: JsPlaintext | null + plaintext: FfiJsPlaintext | null }> export type BulkEncryptedData = Array<{ id?: string; data: Encrypted }> export type BulkDecryptPayload = Array<{ id?: string; data: Encrypted }> -export type BulkDecryptedData = Array> +export type BulkDecryptedData = Array> type DecryptionSuccess = { error?: never @@ -121,4 +411,4 @@ type DecryptionError = { data?: never } -export type DecryptionResult = DecryptionSuccess | DecryptionError +export type DecryptionResult = DecryptionSuccess | DecryptionError \ No newline at end of file diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index b12b30de..36be28a7 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -78,6 +78,9 @@ const tableSchema = z.record(columnSchema).default({}) const tablesSchema = z.record(tableSchema).default({}) +/** + * Schema for the full encryption configuration object. + */ export const encryptConfigSchema = z.object({ v: z.number(), tables: tablesSchema, @@ -101,6 +104,9 @@ export type UniqueIndexOpts = z.infer export type OreIndexOpts = z.infer export type ColumnSchema = z.infer +/** + * Represents the structure of columns in a table, supporting both flat columns and nested objects. + */ export type ProtectTableColumn = { [key: string]: | ProtectColumn @@ -121,6 +127,28 @@ export type EncryptConfig = z.infer // ------------------------ // Interface definitions // ------------------------ + +/** + * Represents a value in a nested object within a Protect.js schema. + * + * Nested objects are useful for data stores with less structure, like NoSQL databases. + * Use {@link csValue} to define these. + * + * @remarks + * - Searchable encryption is **not supported** on nested `csValue` objects. + * - For searchable JSON data in SQL databases, use `.searchableJson()` on a {@link ProtectColumn} instead. + * - Maximum nesting depth is 3 levels. + * + * @example + * ```typescript + * profile: { + * name: csValue("profile.name"), + * address: { + * street: csValue("profile.address.street"), + * } + * } + * ``` + */ export class ProtectValue { private valueName: string private castAsValue: CastAs @@ -131,13 +159,17 @@ export class ProtectValue { } /** - * Set or override the cast_as value. + * Set or override the unencrypted data type for this value. + * Defaults to `'string'`. */ dataType(castAs: CastAs) { this.castAsValue = castAs return this } + /** + * @internal + */ build() { return { cast_as: this.castAsValue, @@ -145,11 +177,25 @@ export class ProtectValue { } } + /** + * Get the internal name of the value. + */ getName() { return this.valueName } } +/** + * Represents a database column in a Protect.js schema. + * Use {@link csColumn} to define these. + * + * Chaining index methods enables searchable encryption for this column. + * + * @example + * ```typescript + * email: csColumn("email").equality().freeTextSearch() + * ``` + */ export class ProtectColumn { private columnName: string private castAsValue: CastAs @@ -166,7 +212,8 @@ export class ProtectColumn { } /** - * Set or override the cast_as value. + * Set or override the unencrypted data type for this column. + * Defaults to `'string'`. */ dataType(castAs: CastAs) { this.castAsValue = castAs @@ -174,7 +221,11 @@ export class ProtectColumn { } /** - * Enable ORE indexing (Order-Revealing Encryption). + * Enable ORE indexing (Order-Revealing Encryption) for range queries (`<`, `>`, `BETWEEN`). + * + * SQL Equivalent: `ORDER BY column ASC` or `WHERE column > 10` + * + * @see {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/range | Range Queries} */ orderAndRange() { this.indexesValue.ore = {} @@ -182,7 +233,12 @@ export class ProtectColumn { } /** - * Enable an Exact index. Optionally pass tokenFilters. + * Enable an Exact index for equality matching. + * + * SQL Equivalent: `WHERE column = 'value'` + * + * @param tokenFilters Optional filters like downcasing. + * @see {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/exact | Exact Queries} */ equality(tokenFilters?: TokenFilter[]) { this.indexesValue.unique = { @@ -192,7 +248,12 @@ export class ProtectColumn { } /** - * Enable a Match index. Allows passing of custom match options. + * Enable a Match index for free-text search (fuzzy/substring matching). + * + * SQL Equivalent: `WHERE column LIKE '%substring%'` + * + * @param opts Custom match options for tokenizer, k, m, etc. + * @see {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/match | Match Queries} */ freeTextSearch(opts?: MatchIndexOpts) { // Provide defaults @@ -211,14 +272,29 @@ export class ProtectColumn { } /** - * Enable a STE Vec index, uses the column name for the index. + * Enable a Structured Text Encryption Vector (STE Vec) index for searchable JSON columns. + * + * This automatically sets the column data type to `'json'` and configures the index + * required for path selection (`->`, `->>`) and containment (`@>`, `<@`) queries. + * + * @remarks + * **Mutual Exclusivity:** `searchableJson()` cannot be combined with `equality()`, + * `freeTextSearch()`, or `orderAndRange()` on the same column. + * + * SQL Equivalent: `WHERE data->'user'->>'email' = '...'` + * + * @see {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/json | JSON Queries} */ - // NOTE: Leaving this commented out until stevec indexing for JSON is supported. - /*searchableJson() { + searchableJson() { + this.castAsValue = 'json' + // Use column name as temporary prefix; will be replaced with table/column during table build this.indexesValue.ste_vec = { prefix: this.columnName } return this - }*/ + } + /** + * @internal + */ build() { return { cast_as: this.castAsValue, @@ -226,6 +302,9 @@ export class ProtectColumn { } } + /** + * Get the database column name. + */ getName() { return this.columnName } @@ -236,6 +315,10 @@ interface TableDefinition { columns: Record } +/** + * Represents a database table in a Protect.js schema. + * Collections of columns are mapped here. + */ export class ProtectTable { constructor( public readonly tableName: string, @@ -243,7 +326,8 @@ export class ProtectTable { ) {} /** - * Build a TableDefinition object: tableName + built column configs. + * Build the final table definition used for configuration. + * @internal */ build(): TableDefinition { const builtColumns: Record = {} @@ -265,11 +349,8 @@ export class ProtectTable { if (builder instanceof ProtectColumn) { const builtColumn = builder.build() - // Hanlde building the ste_vec index for JSON columns so users don't have to pass the prefix. - if ( - builtColumn.cast_as === 'json' && - builtColumn.indexes.ste_vec?.prefix === 'enabled' - ) { + // Set ste_vec prefix to table/column (overwriting any temporary prefix) + if (builtColumn.indexes.ste_vec) { builtColumns[colName] = { ...builtColumn, indexes: { @@ -307,6 +388,23 @@ export class ProtectTable { // ------------------------ // User facing functions // ------------------------ + +/** + * Define a database table and its columns for encryption and indexing. + * + * @param tableName The name of the table in your database. + * @param columns An object mapping TypeScript property names to database columns or nested objects. + * + * @example + * ```typescript + * export const users = csTable("users", { + * email: csColumn("email").equality(), + * profile: { + * name: csValue("profile.name"), + * } + * }); + * ``` + */ export function csTable( tableName: string, columns: T, @@ -321,10 +419,29 @@ export function csTable( return tableBuilder } +/** + * Define a database column for encryption. Use method chaining to enable indexes. + * + * @param columnName The name of the column in your database. + * + * @example + * ```typescript + * csColumn("email").equality().orderAndRange() + * ``` + */ export function csColumn(columnName: string) { return new ProtectColumn(columnName) } +/** + * Define a value within a nested object. + * + * @param valueName A dot-separated string representing the path, e.g., "profile.name". + * + * @remarks + * Nested objects defined with `csValue` are encrypted as part of the parent but are **not searchable**. + * For searchable JSON, use `.searchableJson()` on a {@link csColumn}. + */ export function csValue(valueName: string) { return new ProtectValue(valueName) } @@ -332,6 +449,13 @@ export function csValue(valueName: string) { // ------------------------ // Internal functions // ------------------------ + +/** + * Build the full encryption configuration from one or more tables. + * Used internally during Protect client initialization. + * + * @param protectTables One or more table definitions created with {@link csTable}. + */ export function buildEncryptConfig( ...protectTables: Array> ): EncryptConfig { @@ -342,7 +466,16 @@ export function buildEncryptConfig( for (const tb of protectTables) { const tableDef = tb.build() - config.tables[tableDef.tableName] = tableDef.columns + const tableName = tableDef.tableName + + // Set ste_vec prefix to table/column (overwriting any temporary prefix) + for (const [columnName, columnConfig] of Object.entries(tableDef.columns)) { + if (columnConfig.indexes.ste_vec) { + columnConfig.indexes.ste_vec.prefix = `${tableName}/${columnName}` + } + } + + config.tables[tableName] = tableDef.columns } return config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 904f13a4..7998546d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,8 +512,8 @@ importers: specifier: ^0.2.0 version: 0.2.2 '@cipherstash/protect-ffi': - specifier: 0.19.0 - version: 0.19.0 + specifier: 0.20.1 + version: 0.20.1 '@cipherstash/schema': specifier: workspace:* version: link:../schema @@ -1061,38 +1061,38 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/protect-ffi-darwin-arm64@0.19.0': - resolution: {integrity: sha512-0U/paHskpD2SCiy4T4s5Ery112n5MfT3cXudCZ0m82x03SiK5sU9SNtD2tI0tJhcUlDP9TsFUKnYEEZPFR8pUA==} + '@cipherstash/protect-ffi-darwin-arm64@0.20.1': + resolution: {integrity: sha512-2a24tijXFCbalkPqWNoIa6yjGAFvvyZJl17IcJpMU2HYICQbuKvDjA8oqOlj3JuGHlikJRjDLnLo/AWEmBeoBA==} cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.19.0': - resolution: {integrity: sha512-gbPomTjvBCO7eZsMLGzMVv0Al/TZQ3SOfLWCRzRdWzff3BIC+wPrqJJBbpxIb/WRG7Ak8ceRSdMkrnhQnlsYHA==} + '@cipherstash/protect-ffi-darwin-x64@0.20.1': + resolution: {integrity: sha512-BKtb+aev4x/UwiIs+cgRHj7sONGdE/GJBdoQD2s5e2ImGA4a4Q6+Bt/2ba839/wmyatTZcCiZqknjVXhvD1rYA==} cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.19.0': - resolution: {integrity: sha512-z4ZFJGrmxlsZM5arFyLeeiod8z5SONPYLYnQnO+HG9CH+ra2jRhCvA5qvPjF1+/7vL/zpuV+9MhVJGTt7Vo38A==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.20.1': + resolution: {integrity: sha512-AATWV+AebX2vt5TC4BujjJRbsEQsu9eMA2bXxymH3wJvvI0b1xv0GZjpdnkjxRnzAMjzZwiYxMxL7gdttb0rPA==} cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.19.0': - resolution: {integrity: sha512-ZD3YSzGdgtN7Elsp4rKGBREvbhsYNIt5ywnme8JEgVID7UFENQK5WmsLr20ZbMT1C37TaMRS5ZuIS+loSZvE5Q==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.20.1': + resolution: {integrity: sha512-O13Hq4bcb/arorfO60ohHR+5zX/aXEtGteynb8z0Gop7dXpAdbOLm49QaGrCGwvuAZ4TWVnjp0DyzM+XFcvkPQ==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-musl@0.19.0': - resolution: {integrity: sha512-dngMn6EP2016fwJMg8yeZiJJ/lDOiZ5lkA8fMrVxkr/pv6t7x8m1pdbh4TuLA4OSozm2MLXFu/SZInPwdWZu/w==} + '@cipherstash/protect-ffi-linux-x64-musl@0.20.1': + resolution: {integrity: sha512-tTa2fPToDseikYCf1FRuDj1fHVtpjeRFUioP8LYmFRA2g4r4OaHqNcQpx8NMFuTtnbCIllxTyEaTMZ09YLbHxQ==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-win32-x64-msvc@0.19.0': - resolution: {integrity: sha512-A0WaKj+8WtO+synaMUbOy4a34/s7urJemXj5nC/8EKS8ppGcAJR5pZqV4+RV57j0pQSSR52BAvAenuQEGyKZPA==} + '@cipherstash/protect-ffi-win32-x64-msvc@0.20.1': + resolution: {integrity: sha512-+EmjUzUr9AcFUWaAFdxwv2LCdG7X079Pwotx+D+kIFHfWPtHoVQfKpPHjSnLATEdcgVnGkNAgkpci0rgerf1ng==} cpu: [x64] os: [win32] - '@cipherstash/protect-ffi@0.19.0': - resolution: {integrity: sha512-UfPwO2axmi4O18Wwv87wDg1aGU1RHIEZoWtb/nEYWQgXDOhYtKmWcKQic0MMednBeHAF972pNsrw9Dxhs0ZxXw==} + '@cipherstash/protect-ffi@0.20.1': + resolution: {integrity: sha512-bq+e6XRCSB9km8KTLwGAZaP2N12J6WeHTrb0kfUdlIeYeJR/Lexmb9ho4LNUUiEsJ/tCRFOWgjeC44arFYmaUA==} '@clerk/backend@2.28.0': resolution: {integrity: sha512-rd0hWrU7VES/CEYwnyaXDDHzDXYIaSzI5G03KLUfxLyOQSChU0ZUeViDYyXEsjZgAQqiUP1TFykh9JU2YlaNYg==} @@ -7436,34 +7436,34 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@cipherstash/protect-ffi-darwin-arm64@0.19.0': + '@cipherstash/protect-ffi-darwin-arm64@0.20.1': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.19.0': + '@cipherstash/protect-ffi-darwin-x64@0.20.1': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.19.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.20.1': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.19.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.20.1': optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.19.0': + '@cipherstash/protect-ffi-linux-x64-musl@0.20.1': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.19.0': + '@cipherstash/protect-ffi-win32-x64-msvc@0.20.1': optional: true - '@cipherstash/protect-ffi@0.19.0': + '@cipherstash/protect-ffi@0.20.1': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.19.0 - '@cipherstash/protect-ffi-darwin-x64': 0.19.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.19.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.19.0 - '@cipherstash/protect-ffi-linux-x64-musl': 0.19.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.19.0 + '@cipherstash/protect-ffi-darwin-arm64': 0.20.1 + '@cipherstash/protect-ffi-darwin-x64': 0.20.1 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.20.1 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.20.1 + '@cipherstash/protect-ffi-linux-x64-musl': 0.20.1 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.20.1 '@clerk/backend@2.28.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: