From 504bce04590c3f615213dffb03325cc480471d68 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 2 Nov 2026 12:18:27 +1100 Subject: [PATCH] test(protect-ffi): migrate Protect.js 10.2.1 EQL v2 data to v4 end to end The v3 -> v4 suite covers data Stack 1.x wrote. Customers still on Protect.js hold EQL v2 data written by an older client (protect-ffi 0.18.1, cipherstash-client 0.28), and nothing showed that data can reach v4 and keep answering the same queries. The new suite writes v2 rows with the real @cipherstash/protect@10.2.1 into eql_v2_encrypted columns and queries them with its own search terms. It then runs the same migration as the v3 suite: add a v4 column per v2 column, decrypt with the engine Stack 1.x encrypts through (in-tree protect-ffi, which still reads v2), re-encrypt through the v4 codec, and check equality, ORE range and ordering, and match queries return the same rows from both columns. Every v4 value must decrypt to the original plaintext, and the v4 columns must still answer after the cut-over rename. Protect's dataType('number') encrypted a float64, so that column migrates to DoubleOrdOre rather than an integer type. v2 hazards are pinned too: a v2 payload cast into a v4 column fails the domain CHECK, and relabelling `v` gives a value Stack Encrypt refuses. @cipherstash/protect@10.2.1 is added exact-pinned as a dev dependency of the private integration-tests package. It is loaded through import() because its `types` export condition names ESM declarations. --- .../integration-tests/package.json | 1 + .../tests/migrate-v2-to-v4.test.ts | 476 ++++++++++++++++++ .../integration-tests/tests/v4-codec.ts | 1 + pnpm-lock.yaml | 100 ++++ 4 files changed, 578 insertions(+) create mode 100644 languages/typescript/packages/protect-ffi/integration-tests/tests/migrate-v2-to-v4.test.ts diff --git a/languages/typescript/packages/protect-ffi/integration-tests/package.json b/languages/typescript/packages/protect-ffi/integration-tests/package.json index 41ef13656..2f62f4818 100644 --- a/languages/typescript/packages/protect-ffi/integration-tests/package.json +++ b/languages/typescript/packages/protect-ffi/integration-tests/package.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@cipherstash/eql": "workspace:^", + "@cipherstash/protect": "10.2.1", "@types/pg": "^8.23.1", "@vitest/coverage-v8": "catalog:repo", "dotenv": "17.4.2", diff --git a/languages/typescript/packages/protect-ffi/integration-tests/tests/migrate-v2-to-v4.test.ts b/languages/typescript/packages/protect-ffi/integration-tests/tests/migrate-v2-to-v4.test.ts new file mode 100644 index 000000000..8768c5785 --- /dev/null +++ b/languages/typescript/packages/protect-ffi/integration-tests/tests/migrate-v2-to-v4.test.ts @@ -0,0 +1,476 @@ +import 'dotenv/config' +import { + decryptBulk, + type EncryptConfig, + type EncryptedPayload, + newClient, +} from '@cipherstash/protect-ffi' +import { Client } from 'pg' +import { beforeAll, describe, expect, test } from 'vitest' +import { + CHECK_VIOLATION, + decryptV4, + encryptV4, + type Plaintext, + queryV4, + requireEqlV4, + type V4Type, +} from './v4-codec' + +// EQL v2 -> v4 migration, end to end, against live ZeroKMS and Postgres. +// +// The v2 data is written by the real Protect.js, `@cipherstash/protect@10.2.1` +// (protect-ffi 0.18.1, cipherstash-client 0.28), the last client customers +// used before Stack. Not by today's protect-ffi in v2 mode: whether old +// ciphertext still reads is the question, so the old client writes it. +// +// v2 stores every column as one type, `eql_v2_encrypted`, and decides what a +// column can answer from the payload's index terms. v4 has a domain per +// capability, so the migration picks one per column. The payloads are also +// produced differently (cipherstash-client vs Stack Encrypt), so as with v3 +// every value is decrypted and encrypted again; nothing converts in place. +// +// The steps are the ones migrate-v3-to-v4.test.ts runs, in one table: add a +// v4 column beside each v2 column, backfill by decrypting with the client +// Stack 1.x encrypts through (in-tree protect-ffi, which still reads v2) and +// re-encrypting as v4, check every query answers the same rows, then drop the +// v2 columns and rename the v4 ones into their place. +// +// Requires EQL v2 and v4 both installed (`mise run setup` installs v2, v3 and +// v4) and the v4 codec built (`mise run eql:v4:codec`); see ./v4-codec.ts. + +const TABLE = 'accounts' + +// Stack 1.x's engine needs a config for the table to decrypt it. +const encryptConfig: EncryptConfig = { + v: 1, + tables: { + [TABLE]: { + email: { cast_as: 'text', indexes: { unique: {} } }, + age: { cast_as: 'number', indexes: { ore: {} } }, + bio: { cast_as: 'text', indexes: { match: {} } }, + active: { cast_as: 'boolean', indexes: {} }, + }, + }, +} + +/** + * Build Protect's native client and the customer's Protect.js schema for the + * same workspace. + * + * Through `import()`: Protect 10.2.1's `types` export condition names its + * ESM declarations, so TypeScript refuses a static import from this CommonJS + * suite even though the package ships a `require` build. + * + * cipherstash-client 0.28 does not derive its service hosts from the + * workspace CRN, so point `CS_CTS_HOST` / `CS_ZEROKMS_HOST` at the CRN's + * region while the client is built, then restore them: protect-ffi must keep + * deriving its own. The same workaround as e2e/tests/protect-dynamodb-compat. + */ +async function legacyProtect() { + const { csColumn, csTable, protect } = await import('@cipherstash/protect') + const table = csTable(TABLE, { + email: csColumn('email').equality(), + age: csColumn('age').dataType('number').orderAndRange(), + bio: csColumn('bio').freeTextSearch(), + active: csColumn('active').dataType('boolean'), + }) + const crn = process.env.CS_WORKSPACE_CRN + if (!crn) throw new Error('CS_WORKSPACE_CRN is required') + const region = crn.split(':')[1] + const saved = { + CS_CTS_HOST: process.env.CS_CTS_HOST, + CS_ZEROKMS_HOST: process.env.CS_ZEROKMS_HOST, + } + process.env.CS_CTS_HOST = `https://${region}.cts.cipherstashmanaged.net` + process.env.CS_ZEROKMS_HOST = `https://${region}.viturhosted.net` + try { + const client = await protect({ + schemas: [table], + workspaceCrn: crn, + clientId: process.env.CS_CLIENT_ID, + clientKey: process.env.CS_CLIENT_KEY, + accessKey: process.env.CS_CLIENT_ACCESS_KEY, + }) + return { client, table } + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } +} + +/** Unwrap Protect's `{ data } | { failure }`, failing the test on `failure`. */ +function ok( + result: { data: T; failure?: undefined } | { failure: unknown }, + label: string, +): T { + if ('failure' in result && result.failure !== undefined) { + const failure = result.failure as { message?: unknown } + throw new Error( + `${label} failed: ${String(failure?.message ?? '')} ${JSON.stringify(failure)}`, + ) + } + return (result as { data: T }).data +} + +/** + * Protect 10.2.1's plaintext type (protect-ffi 0.18.1's `JsPlaintext`) leaves + * out `boolean`, though it encrypts one; e2e/tests/protect-dynamodb-compat + * pins that it does. + */ +const asLegacy = (value: Plaintext) => value as string | number + +type Account = { + email: string + age: number + bio: string + active: boolean +} + +const accounts: Account[] = [ + { + email: 'ada@example.com', + age: 36, + bio: 'analytical engine notes', + active: true, + }, + { + email: 'grace@example.com', + age: 85, + bio: 'compiler pioneer and admiral', + active: true, + }, + { + email: 'alan@example.com', + age: 41, + bio: 'computable numbers and machines', + active: false, + }, + { + email: 'edsger@example.com', + age: 72, + bio: 'structured programming advocate', + active: false, + }, + { + email: 'barbara@example.com', + age: 29, + bio: 'abstraction and programming methodology', + active: true, + }, +] + +type Column = keyof Account + +/** + * The v4 type each v2 column migrates to. Protect's `dataType('number')` + * encrypted a float64, so `age` keeps that and becomes `DoubleOrdOre`, not an + * integer type. + */ +const columns: Record = { + email: { v4: 'TextEq', v4Domain: 'public.eql_v4_text_eq' }, + age: { v4: 'DoubleOrdOre', v4Domain: 'public.eql_v4_double_ord_ore' }, + bio: { v4: 'TextMatch', v4Domain: 'public.eql_v4_text_match' }, + active: { v4: 'Boolean', v4Domain: 'public.eql_v4_boolean' }, +} + +const columnNames = Object.keys(columns) as Column[] + +const emails = (rows: Account[]) => rows.map((row) => row.email) + +describe('EQL v2 -> v4 migration', () => { + const pg = new Client() + let legacy: Awaited> + let stackClient: Awaited> + + async function emailsWhere(sql: string, params: unknown[] = []) { + const res = await pg.query<{ id: number }>( + `SELECT id FROM ${TABLE} ${sql}`, + params, + ) + return res.rows.map((row) => accounts[row.id - 1].email) + } + + /** A v2 search term from Protect 10.2.1, as its customers queried. */ + async function v2Term(column: Column, value: Plaintext) { + const [term] = ok( + await legacy.client.createSearchTerms([ + { + value: asLegacy(value), + column: legacy.table[column], + table: legacy.table, + }, + ]), + 'createSearchTerms', + ) + return term + } + + function v4Operand(column: Column, value: Plaintext) { + const [operand] = queryV4([ + { type: columns[column].v4, table: TABLE, column, value }, + ]) + return operand + } + + beforeAll(async () => { + legacy = await legacyProtect() + stackClient = await newClient({ encryptConfig }) + await pg.connect() + await requireEqlV4(pg) + + // The table as Protect.js left it: every column `eql_v2_encrypted`. + await pg.query(`DROP TABLE IF EXISTS ${TABLE}`) + await pg.query(` + CREATE TABLE ${TABLE} ( + id integer PRIMARY KEY, + ${columnNames.map((c) => `${c} eql_v2_encrypted`).join(',\n ')} + ) + `) + for (const column of columnNames) { + await pg.query('SELECT eql_v2.add_encrypted_constraint($1, $2)', [ + TABLE, + column, + ]) + } + + const byColumn = new Map() + for (const column of columnNames) { + const encrypted = ok( + await legacy.client.bulkEncrypt( + accounts.map((account) => ({ plaintext: asLegacy(account[column]) })), + { column: legacy.table[column], table: legacy.table }, + ), + `bulkEncrypt ${column}`, + ) + byColumn.set( + column, + encrypted.map((item) => item.data), + ) + } + for (const index of accounts.keys()) { + await pg.query( + `INSERT INTO ${TABLE} (id, ${columnNames.join(', ')}) + VALUES ($1, ${columnNames.map((_, i) => `$${i + 2}::jsonb`).join(', ')})`, + [index + 1, ...columnNames.map((c) => byColumn.get(c)?.[index])], + ) + } + + return async () => { + await pg.query(`DROP TABLE IF EXISTS ${TABLE}`) + await pg.end() + } + }) + + test('the v2 table answers the queries the migration must preserve', async () => { + const versions = await pg.query<{ v: number }>( + `SELECT DISTINCT (email::jsonb ->> 'v')::int AS v FROM ${TABLE}`, + ) + expect(versions.rows).toEqual([{ v: 2 }]) + + expect( + await emailsWhere('WHERE email = $1::jsonb', [ + await v2Term('email', 'alan@example.com'), + ]), + ).toEqual(['alan@example.com']) + + expect( + await emailsWhere('WHERE age > $1::jsonb ORDER BY id', [ + await v2Term('age', 40), + ]), + ).toEqual(emails(accounts.filter((a) => a.age > 40))) + + expect(await emailsWhere('ORDER BY eql_v2.order_by(age)')).toEqual( + emails([...accounts].sort((a, b) => a.age - b.age)), + ) + + expect( + await emailsWhere('WHERE bio LIKE $1::jsonb ORDER BY id', [ + await v2Term('bio', 'programming'), + ]), + ).toEqual(emails(accounts.filter((a) => a.bio.includes('programming')))) + }) + + test('a v2 payload cannot be cast into a v4 column', async () => { + await pg.query( + `ALTER TABLE ${TABLE} ADD COLUMN probe ${columns.email.v4Domain}`, + ) + try { + await expect( + pg.query( + `UPDATE ${TABLE} SET probe = email::jsonb::${columns.email.v4Domain}`, + ), + ).rejects.toMatchObject({ code: CHECK_VIOLATION }) + } finally { + await pg.query(`ALTER TABLE ${TABLE} DROP COLUMN probe`) + } + }) + + test('relabelling a v2 payload as v4 does not make it a v4 value', async () => { + const res = await pg.query<{ email: Record }>( + `SELECT email::jsonb AS email FROM ${TABLE} WHERE id = 3`, + ) + expect(() => + decryptV4([ + { + type: 'TextEq', + table: TABLE, + column: 'email', + payload: { ...res.rows[0].email, v: 4 }, + }, + ]), + ).toThrowError() + }) + + test('backfill: decrypt every v2 value and re-encrypt it as v4 beside it', async () => { + for (const column of columnNames) { + await pg.query( + `ALTER TABLE ${TABLE} ADD COLUMN ${column}_v4 ${columns[column].v4Domain}`, + ) + } + + const res = await pg.query< + Record & { id: number } + >( + `SELECT id, ${columnNames.map((c) => `${c}::jsonb AS ${c}`).join(', ')} + FROM ${TABLE} ORDER BY id`, + ) + // Stack 1.x's engine reads the payloads Protect 10.2.1 wrote. + const plaintexts = await decryptBulk(stackClient, { + ciphertexts: res.rows.flatMap((row) => + columnNames.map((column) => ({ ciphertext: row[column] })), + ), + }) + expect(plaintexts).toEqual( + accounts.flatMap((account) => columnNames.map((c) => account[c])), + ) + + const v4 = encryptV4( + res.rows.flatMap((_, r) => + columnNames.map((column, c) => ({ + type: columns[column].v4, + table: TABLE, + column, + value: plaintexts[r * columnNames.length + c] as Plaintext, + })), + ), + ) + for (const [r, row] of res.rows.entries()) { + const values = v4.slice( + r * columnNames.length, + (r + 1) * columnNames.length, + ) + await pg.query( + `UPDATE ${TABLE} SET ${columnNames + .map((c, i) => `${c}_v4 = $${i + 2}::jsonb`) + .join(', ')} WHERE id = $1`, + [row.id, ...values], + ) + } + + const versions = await pg.query<{ v: number; n: number }>( + `SELECT (email_v4::jsonb ->> 'v')::int AS v, count(*)::int AS n FROM ${TABLE} GROUP BY 1`, + ) + expect(versions.rows).toEqual([{ v: 4, n: accounts.length }]) + }) + + test('every migrated value decrypts as v4 to the original plaintext', async () => { + const res = await pg.query & { id: number }>( + `SELECT id, ${columnNames.map((c) => `${c}_v4::jsonb AS ${c}`).join(', ')} + FROM ${TABLE} ORDER BY id`, + ) + expect( + decryptV4( + res.rows.flatMap((row) => + columnNames.map((column) => ({ + type: columns[column].v4, + table: TABLE, + column, + payload: row[column], + })), + ), + ), + ).toEqual(accounts.flatMap((account) => columnNames.map((c) => account[c]))) + }) + + test('equality: the v4 column finds the row the v2 column found', async () => { + const v2 = await emailsWhere('WHERE email = $1::jsonb', [ + await v2Term('email', 'alan@example.com'), + ]) + const v4 = await emailsWhere( + 'WHERE email_v4 = $1::jsonb::eql_v4.query_text_eq', + [v4Operand('email', 'alan@example.com')], + ) + expect(v4).toEqual(v2) + expect(v4).toEqual(['alan@example.com']) + }) + + test('range and ordering: ORE agrees across versions', async () => { + const older = emails(accounts.filter((a) => a.age > 40)) + expect( + await emailsWhere('WHERE age > $1::jsonb ORDER BY id', [ + await v2Term('age', 40), + ]), + ).toEqual(older) + expect( + await emailsWhere( + 'WHERE age_v4 > $1::jsonb::eql_v4.query_double_ord_ore ORDER BY id', + [v4Operand('age', 40)], + ), + ).toEqual(older) + + const byAge = emails([...accounts].sort((a, b) => a.age - b.age)) + expect(await emailsWhere('ORDER BY eql_v2.order_by(age)')).toEqual(byAge) + expect(await emailsWhere('ORDER BY eql_v4.ord_term_ore(age_v4)')).toEqual( + byAge, + ) + }) + + test('match: the v4 bloom filter finds the rows the v2 one found', async () => { + const expected = emails( + accounts.filter((a) => a.bio.includes('programming')), + ) + expect( + await emailsWhere('WHERE bio LIKE $1::jsonb ORDER BY id', [ + await v2Term('bio', 'programming'), + ]), + ).toEqual(expected) + expect( + await emailsWhere( + 'WHERE bio_v4 @@ $1::jsonb::eql_v4.query_text_match ORDER BY id', + [v4Operand('bio', 'programming')], + ), + ).toEqual(expected) + }) + + test('cut-over: drop the v2 columns and the v4 values answer under the original names', async () => { + for (const column of columnNames) { + await pg.query(`ALTER TABLE ${TABLE} DROP COLUMN ${column}`) + await pg.query( + `ALTER TABLE ${TABLE} RENAME COLUMN ${column}_v4 TO ${column}`, + ) + } + + expect( + await emailsWhere('WHERE email = $1::jsonb::eql_v4.query_text_eq', [ + v4Operand('email', 'grace@example.com'), + ]), + ).toEqual(['grace@example.com']) + + const res = await pg.query<{ active: unknown }>( + `SELECT active::jsonb AS active FROM ${TABLE} ORDER BY id`, + ) + expect( + decryptV4( + res.rows.map((row) => ({ + type: 'Boolean' as const, + table: TABLE, + column: 'active', + payload: row.active, + })), + ), + ).toEqual(accounts.map((a) => a.active)) + }) +}) diff --git a/languages/typescript/packages/protect-ffi/integration-tests/tests/v4-codec.ts b/languages/typescript/packages/protect-ffi/integration-tests/tests/v4-codec.ts index d930cc126..28a12c462 100644 --- a/languages/typescript/packages/protect-ffi/integration-tests/tests/v4-codec.ts +++ b/languages/typescript/packages/protect-ffi/integration-tests/tests/v4-codec.ts @@ -24,6 +24,7 @@ export const codecPath = /** An EQL v4 type name, e.g. `TextEq`, as `targets::targets()` lists it. */ export type V4Type = | 'Boolean' + | 'DoubleOrdOre' | 'IntegerOrdOpe' | 'IntegerOrdOre' | 'TextEq' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b35115908..fa3a5353c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -486,6 +486,9 @@ importers: '@cipherstash/eql': specifier: workspace:^ version: link:../../../../../packages/eql/packages/eql + '@cipherstash/protect': + specifier: 10.2.1 + version: 10.2.1 '@types/pg': specifier: ^8.23.1 version: 8.23.1 @@ -1068,6 +1071,47 @@ packages: '@cipherstash/eql@3.0.2': resolution: {integrity: sha512-E85o0aoOqgCW6RReLtJ0YLh/ExRlmDJo7LlJGpWPoMTVaw+CW8o11DJ4oJIF1vFtuxSVxNULuPzzBuVmpTvvcA==} + '@cipherstash/protect-ffi-darwin-arm64@0.18.1': + resolution: {integrity: sha512-kTkshWuGG07X9m4Ug0mPXIglfZopVFCJy55/B2yy9Z+hhNP95d4Zjq6AXlRYAHsnxgcp0SnynC8LaVTcXsEx8w==} + cpu: [arm64] + os: [darwin] + + '@cipherstash/protect-ffi-darwin-x64@0.18.1': + resolution: {integrity: sha512-VdoTOIF5hmS7sQUR/w6FQKAj7aJOhOD2lo2wlV3hxG0PNd2pWiB1HvE9wvrGY2VAPqBU97Cp+S8IFkf5ckqQ2A==} + cpu: [x64] + os: [darwin] + + '@cipherstash/protect-ffi-linux-arm64-gnu@0.18.1': + resolution: {integrity: sha512-DcGIgoTkRDaBqqXs3X4fB7XCOMtleSLd+kMAStS1uEq6agVHp2P/9Y+ag30MQ7Ypm0xTJBZVwl8ClDXDSklQcw==} + cpu: [arm64] + os: [linux] + + '@cipherstash/protect-ffi-linux-x64-gnu@0.18.1': + resolution: {integrity: sha512-I5Cg7vlkVX2qL3hKYiYEx1C1lTQ+uGt2YrAxaqSs09gpvpa6xC2/2QMpV9UvKnJ9uLlxVly8jycnzp7TyvS9pA==} + cpu: [x64] + os: [linux] + + '@cipherstash/protect-ffi-linux-x64-musl@0.18.1': + resolution: {integrity: sha512-Ol9dYVNXIaoYrhpsJv90PX5pHg1mmWKqnpnhh2ofA+NNZqEAdolo4BeTsQc10sCE6WogcM8FFJ9OcOrk79N8lA==} + cpu: [x64] + os: [linux] + + '@cipherstash/protect-ffi-win32-x64-msvc@0.18.1': + resolution: {integrity: sha512-j9mqXQDHu3vdLJx93g2lAuKDeOh+XuC1FBvsrr2FTjgzBqrZAHH60QFPXjvFT1/U1oWjV8nAgTZQ4cUMJSDa9Q==} + cpu: [x64] + os: [win32] + + '@cipherstash/protect-ffi@0.18.1': + resolution: {integrity: sha512-rDYIRIWo7EvJ+ytGEwzeb0D/4j3tQ8w+5Kpz089IEhxIVxOzavsR+f5HyMXohAKYkH+19RpD+2X+v1P8rwmiUA==} + + '@cipherstash/protect@10.2.1': + resolution: {integrity: sha512-SCGOGpLWhwa9+hotbA11tPNGhKBZYspa5NWBtwBiHv/EgEKCQvaLDc0MAwzyT5cv7xDwbQESkhxyD9oqHnIMfg==} + deprecated: Deprecated in favour of @cipherstash/stack. Install @cipherstash/stack instead. + + '@cipherstash/schema@2.0.2': + resolution: {integrity: sha512-3DpGvJ3sbhPpAGTQFf1St+WU6AlNR0S/rumSFHG0ghwDGzNV7PjnLHCzzZcMQX8UVw9KYwDz5+n/SAZAuDQczg==} + deprecated: EQL v2-only package, no longer maintained. Use @cipherstash/stack instead. + '@clack/core@1.5.1': resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} engines: {node: '>= 20.12.0'} @@ -1666,6 +1710,9 @@ packages: resolution: {integrity: sha512-SC8xYNOH1dTnDiB2pykFhREl74gs6TF4jr2qFp0h9fwriRSoxic/CnVN2d7ikpc3XatOK1QnwfQCCmyv31x9/A==} hasBin: true + '@neon-rs/load@0.1.82': + resolution: {integrity: sha512-H4Gu2o5kPp+JOEhRrOQCnJnf7X6sv9FBLttM/wSbb4efsgFWeHzfU/ItZ01E5qqEk+U6QGdeVO7lxXIAtYHr5A==} + '@neon-rs/load@0.2.6': resolution: {integrity: sha512-7p8j+guEhGTJNJISgmHO0homqGYAc0SDfmgGZfluMcQnimcceMY87b4hdqufp+7YmsOVC4W/JvT3/Tpi3/gy+Q==} @@ -1986,6 +2033,12 @@ packages: os: [linux] libc: [glibc] + '@rollup/rollup-linux-x64-gnu@4.24.0': + resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] @@ -4222,6 +4275,48 @@ snapshots: '@cipherstash/eql@3.0.2': {} + '@cipherstash/protect-ffi-darwin-arm64@0.18.1': + optional: true + + '@cipherstash/protect-ffi-darwin-x64@0.18.1': + optional: true + + '@cipherstash/protect-ffi-linux-arm64-gnu@0.18.1': + optional: true + + '@cipherstash/protect-ffi-linux-x64-gnu@0.18.1': + optional: true + + '@cipherstash/protect-ffi-linux-x64-musl@0.18.1': + optional: true + + '@cipherstash/protect-ffi-win32-x64-msvc@0.18.1': + optional: true + + '@cipherstash/protect-ffi@0.18.1': + dependencies: + '@neon-rs/load': 0.1.82 + optionalDependencies: + '@cipherstash/protect-ffi-darwin-arm64': 0.18.1 + '@cipherstash/protect-ffi-darwin-x64': 0.18.1 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.18.1 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.18.1 + '@cipherstash/protect-ffi-linux-x64-musl': 0.18.1 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.18.1 + + '@cipherstash/protect@10.2.1': + dependencies: + '@byteslice/result': 0.2.0 + '@cipherstash/protect-ffi': 0.18.1 + '@cipherstash/schema': 2.0.2 + zod: 3.25.76 + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': 4.24.0 + + '@cipherstash/schema@2.0.2': + dependencies: + zod: 3.25.76 + '@clack/core@1.5.1': dependencies: fast-wrap-ansi: 0.2.2 @@ -4636,6 +4731,8 @@ snapshots: '@neon-rs/cli@0.2.6': {} + '@neon-rs/load@0.1.82': {} + '@neon-rs/load@0.2.6': {} '@next/env@15.5.28': {} @@ -4943,6 +5040,9 @@ snapshots: '@rollup/rollup-linux-s390x-gnu@4.64.12': optional: true + '@rollup/rollup-linux-x64-gnu@4.24.0': + optional: true + '@rollup/rollup-linux-x64-gnu@4.60.4': optional: true