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