diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba7affc68..469a13596 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -407,8 +407,9 @@ jobs: CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} - CS_ZEROKMS_HOST: https://ap-southeast-2.aws.zerokms.cipherstashmanaged.net - CS_CTS_HOST: https://ap-southeast-2.aws.cts.cipherstashmanaged.net + # No CS_ZEROKMS_HOST / CS_CTS_HOST: current clients derive both from the + # CRN, and a pinned region points the shared credentials at the wrong + # deployment (see integration-protect-ffi.yml). steps: - name: Checkout Repo @@ -444,15 +445,13 @@ jobs: client-key: ${{ secrets.CS_CLIENT_KEY }} client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} - # `e2e/vitest.config.ts` includes `tests/**/*.e2e.test.ts` and the step - # below applies no file filter, so this job runs - # `tests/prisma-example-readme.e2e.test.ts` — whose - # `describe.skipIf(!authConfigured)` un-skips the moment CS_CLIENT_ID and - # CS_CLIENT_KEY are set, which the job env above does. That walkthrough's - # `pnpm start` step encrypts against the live service through - # `@cipherstash/stack`, so it needs `index.node`; turbo's `^build` only - # gets as far as protect-ffi's `build` (tsc → `lib/`), which is the half - # that does not encrypt anything. + # The protect-dynamodb compatibility step at the end of this job + # encrypts against the live service through `@cipherstash/stack`, so it + # needs `index.node`; turbo's `^build` only gets as far as protect-ffi's + # `build` (tsc → `lib/`), which is the half that does not encrypt + # anything. (`tests/prisma-example-readme.e2e.test.ts` would need it too, + # but under turbo's strict env mode the job-level CS_* never reach it, + # so it skips.) # # After the pre-flight above, per # scripts/__tests__/ffi-binding-step-order.test.mjs. No `wasm: true` — @@ -471,6 +470,19 @@ jobs: - name: Run E2E tests run: pnpm exec turbo run test:e2e --filter @cipherstash/e2e + # The step above runs under turbo's strict env mode, which filters out + # every env var `turbo.json` does not declare, so the job-level CS_* + # never reach vitest there and auth-gated suites skip. This suite proves + # a migration claim made to customers, so it runs again here, outside + # turbo, with the credentials, and `CS_E2E_REQUIRE_AUTH` makes it throw + # rather than skip if they are missing. The step above already built + # everything it imports. + - name: Run the protect-dynamodb compatibility suite + working-directory: e2e + env: + CS_E2E_REQUIRE_AUTH: '1' + run: pnpm exec vitest run tests/protect-dynamodb-compat.e2e.test.ts + # Verifies @cipherstash/stack/wasm-inline works under Deno — i.e. the # WASM build of protect-ffi 0.26+ and auth 0.40+ can round-trip an # encryption against ZeroKMS / CTS in a runtime with no native diff --git a/e2e/package.json b/e2e/package.json index 0e2ef5b9f..c942cc8f0 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,12 +9,16 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "stash": "workspace:*", "@cipherstash/stack": "workspace:*", - "@cipherstash/wizard": "workspace:*" + "@cipherstash/wizard": "workspace:*", + "stash": "workspace:*" }, "devDependencies": { + "@cipherstash/protect": "10.2.1", + "@cipherstash/protect-dynamodb": "6.0.1", + "@cipherstash/stack-0.19": "npm:@cipherstash/stack@0.19.0", "@types/semver": "7.8.0", + "dotenv": "17.4.2", "semver": "^7.8.0", "typescript": "catalog:repo", "vitest": "catalog:repo", diff --git a/e2e/tests/protect-dynamodb-compat.e2e.test.ts b/e2e/tests/protect-dynamodb-compat.e2e.test.ts new file mode 100644 index 000000000..57f536044 --- /dev/null +++ b/e2e/tests/protect-dynamodb-compat.e2e.test.ts @@ -0,0 +1,860 @@ +/** + * Cross-version proof: DynamoDB items written by the REAL Protect.js stack a + * customer runs today read back, and stay queryable, under Stack 1.x. + * + * Writer: `@cipherstash/protect@10.2.1` + `@cipherstash/protect-dynamodb@6.0.1` + * (→ `@cipherstash/protect-ffi@0.18.1` → cipherstash-client 0.28), installed + * from npm as pinned devDependencies of this package. Reader: the in-tree + * `@cipherstash/stack` (→ the in-tree protect-ffi → cipherstash-client 0.42). + * + * A final, DEPRECATED block also pins `@cipherstash/stack@0.19.0` (aliased as + * `@cipherstash/stack-0.19`) to show that the old Protect → 0.19 → 1.x route + * still lands in the same place. That hop is no longer required. + * + * Every other v2 test in the repo mints its "v2" fixtures with TODAY's + * protect-ffi in v2 mode, so compatibility with ciphertext written by the old + * native client rested on the record format being unchanged — argued from + * source, pinned by nothing. This file pins it. + * + * Items are minted at test time, never committed: ciphertext and HMACs are bound + * to the workspace keyset, so a committed fixture would decrypt in exactly one + * workspace (the trap #1200 hit with pinned SteVec selectors). No DynamoDB is + * involved either — both adapters' `encryptModel` / `decryptModel` are pure + * transforms between a model and the item that would be `PutItem`'d. + * + * Needs the four `CS_*` credential variables. A `stash auth login` profile is + * not enough: Protect 10.2.1 is configured from explicit credentials. + * The `e2e-tests` job in `tests.yml` runs this file in its own step with + * `CS_E2E_REQUIRE_AUTH=1`, which makes it throw rather than skip when they are + * missing, so the `skipIf` below cannot hide a regression in CI. + */ +import { fileURLToPath } from 'node:url' +import { csColumn, csTable, protect } from '@cipherstash/protect' +import { protectDynamoDB } from '@cipherstash/protect-dynamodb' +import { encryptedDynamoDB } from '@cipherstash/stack/dynamodb' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' +import { Encryption as Encryption019 } from '@cipherstash/stack-0.19' +import { encryptedDynamoDB as encryptedDynamoDB019 } from '@cipherstash/stack-0.19/dynamodb' +import { + encryptedColumn as encryptedColumn019, + encryptedTable as encryptedTable019, +} from '@cipherstash/stack-0.19/schema' +import { config } from 'dotenv' +import { beforeAll, beforeEach, describe, expect, it } from 'vitest' + +// A local `.env` in `e2e/` or at the repo root, as AGENTS.md describes. CI +// injects the variables directly; an existing value always wins. +config({ + path: ['../.env', '../../.env'].map((p) => + fileURLToPath(new URL(p, import.meta.url)), + ), + quiet: true, +}) + +const authConfigured = Boolean( + process.env.CS_WORKSPACE_CRN && + process.env.CS_CLIENT_ID && + process.env.CS_CLIENT_KEY && + process.env.CS_CLIENT_ACCESS_KEY, +) + +/** + * Build Protect's native client (cipherstash-client 0.28) for the same + * workspace Stack uses. + * + * Unlike today's clients, 0.28 does not derive its service hosts from the + * workspace CRN: with no override it defaults to the legacy + * `ap-southeast-2.aws.auth.viturhosted.net`, so a workspace in another region + * gets a 401 from CTS. So point `CS_CTS_HOST` / `CS_ZEROKMS_HOST` at the CRN's + * region while the client is built, using the URLs current clients derive by + * service discovery (cts-common's `CtsServiceDiscovery` and + * `ZeroKmsServiceDiscovery`), then restore them: the hosts are read at + * construction, and Stack's client must keep deriving its own. Credentials + * are passed explicitly, as a Protect app configured from these variables + * would. + */ +async function legacyProtect( + schemas: Parameters[0]['schemas'], +) { + const crn = process.env.CS_WORKSPACE_CRN as string + 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 { + return await protect({ + schemas, + workspaceCrn: crn, + clientId: process.env.CS_CLIENT_ID, + clientKey: process.env.CS_CLIENT_KEY, + accessKey: process.env.CS_CLIENT_ACCESS_KEY, + }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } +} + +/** Unwrap a `{ data } | { failure }` result, 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 } + // An Error's message is not enumerable, so JSON.stringify alone drops it. + throw new Error( + `${label} failed: ${String(failure?.message ?? '')} ${JSON.stringify(failure)}`, + ) + } + return (result as { data: T }).data +} + +// --------------------------------------------------------------------------- +// The schemas: the customer's v2 table, and its v3 mapping. +// +// Property keys and DB column names are identical on both sides, as the +// migration guidance requires: Stack finds `__source` by property key. +// --------------------------------------------------------------------------- + +const legacyUsers = csTable('users', { + email: csColumn('email').equality(), + emailLower: csColumn('emailLower').equality([{ kind: 'downcase' }]), + age: csColumn('age').dataType('number').equality(), + balance: csColumn('balance').dataType('bigint').equality(), + birthday: csColumn('birthday').dataType('date').equality(), + active: csColumn('active').dataType('boolean').equality(), + notes: csColumn('notes'), +}) + +/** The domain mapping the migration doc recommends, one v3 domain per column. */ +const users = encryptedTable('users', { + email: types.TextEq('email'), + emailLower: types.TextEq('emailLower'), + age: types.IntegerEq('age'), + balance: types.BigintEq('balance'), + active: types.Boolean('active'), + notes: types.Text('notes'), +}) + +/** + * The same columns under DIFFERENT v3 domains, to pin which choices keep the + * stored `__hmac` and which lose it. Only used for `encryptQuery`. + */ +const alternates = encryptedTable('users_alt', { + age: types.DoubleEq('age'), +}) + +/** A v3 table may not name one DB column twice, so the *Ord domain lives apart. */ +const ordered = encryptedTable('users_ord', { + age: types.IntegerOrd('age'), +}) + +/** + * Renamed table AND renamed DB column names, same property keys. The doc claims + * neither name is needed to decrypt: the column name is bound into the record + * as AEAD associated data, but it travels inside the ciphertext. + */ +const renamed = encryptedTable('people_v3', { + email: types.TextEq('email_address'), + notes: types.Text('free_text'), +}) + +/** Protect's `dataType('json')` column, and the v3 domain it backfills to. */ +const legacyDocs = csTable('docs', { + profile: csColumn('profile').dataType('json'), +}) +const docs = encryptedTable('docs', { profile: types.Json('profile') }) + +const model = { + pk: 'user#1', + email: 'ada@example.com', + emailLower: 'Ada@Example.com', + age: 36, + // protect 10.2.1 takes a bigint column's value only as a JS number (a JS + // bigint or a string is rejected), so stay inside Number.MAX_SAFE_INTEGER. + balance: 1_234_567_890_123, + active: true, + notes: 'first programmer', +} + +// Where CI promises credentials, a skip would read as a pass: their absence is +// a wiring bug (turbo's strict env mode filters them out, for one). +if (process.env.CS_E2E_REQUIRE_AUTH === '1' && !authConfigured) { + throw new Error( + 'protect-dynamodb-compat: CS_E2E_REQUIRE_AUTH is set but CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY and CS_CLIENT_ACCESS_KEY did not all reach the test process', + ) +} + +describe.skipIf(!authConfigured)( + 'protect-dynamodb 6.0.1 items under Stack 1.x', + () => { + let legacyClient: Awaited> + let legacy: ReturnType + let stack: ReturnType + let client: Awaited> + /** One item exactly as protect-dynamodb 6.0.1 would `PutItem` it. */ + let item: Record + + /** The `hm` term Stack 1.x mints for a value, or undefined if none. */ + const hmOf = async ( + value: unknown, + table: typeof users | typeof alternates | typeof ordered | typeof renamed, + column: unknown, + ): Promise => { + const term = ok( + await client.encryptQuery( + value as never, + { + table, + column, + } as never, + ), + 'encryptQuery', + ) + return (term as { hm?: string } | null)?.hm + } + + beforeAll(async () => { + legacyClient = await legacyProtect([legacyUsers, legacyDocs]) + legacy = protectDynamoDB({ protectClient: legacyClient }) + + client = await Encryption({ + schemas: [users, alternates, ordered, renamed, docs], + }) + stack = encryptedDynamoDB({ encryptionClient: client }) + + item = ok( + await legacy.encryptModel(model, legacyUsers), + 'protect-dynamodb encryptModel', + ) + }, 60_000) + + it('stores only __source and __hmac — no EQL version on disk', () => { + expect(Object.keys(item).sort()).toEqual( + [ + 'pk', + 'email__source', + 'email__hmac', + 'emailLower__source', + 'emailLower__hmac', + 'age__source', + 'age__hmac', + 'balance__source', + 'balance__hmac', + 'active__source', + 'active__hmac', + 'notes__source', + ].sort(), + ) + for (const [key, value] of Object.entries(item)) { + if (key === 'pk') continue + expect(typeof value, key).toBe('string') + } + }) + + // ------------------------------------------------------------------------- + // Q1 — does v2 ciphertext decrypt under v3 tooling? + // ------------------------------------------------------------------------- + + describe('Q1: decrypt', () => { + it('reads the item with storedEqlVersion: 2', async () => { + const decrypted = ok( + await stack.decryptModel(item, users, { storedEqlVersion: 2 }), + 'stack decryptModel (v2)', + ) + expect(decrypted).toMatchObject({ + pk: 'user#1', + email: model.email, + emailLower: model.emailLower, + age: model.age, + active: true, + notes: model.notes, + }) + expect(decrypted.balance).toBe(BigInt(model.balance)) + }) + + it('reads the same item with no storedEqlVersion at all (scalars carry no version)', async () => { + const asV2 = ok( + await stack.decryptModel(item, users, { storedEqlVersion: 2 }), + 'v2 read', + ) + const asV3 = ok(await stack.decryptModel(item, users), 'v3 read') + expect(asV3).toEqual(asV2) + // No leftover `*__source` key: nothing was silently passed through. + expect(Object.keys(asV3).filter((k) => k.endsWith('__source'))).toEqual( + [], + ) + }) + + it('agrees with what protect-dynamodb itself decrypts', async () => { + const fromLegacy = ok( + await legacy.decryptModel(item, legacyUsers), + 'protect-dynamodb decryptModel', + ) + const fromStack = ok( + await stack.decryptModel(item, users, { storedEqlVersion: 2 }), + 'stack decryptModel', + ) + for (const key of [ + 'email', + 'emailLower', + 'age', + 'active', + 'notes', + ] as const) { + expect(fromStack[key], key).toEqual(fromLegacy[key]) + } + }) + + it('changes the JS type of a bigint column, but not its value', async () => { + const fromLegacy = ok( + await legacy.decryptModel(item, legacyUsers), + 'protect-dynamodb decryptModel', + ) + const fromStack = ok( + await stack.decryptModel(item, users, { storedEqlVersion: 2 }), + 'stack decryptModel', + ) + + // Stack 1.x: a native bigint. Protect: a number. Same value. + expect(typeof fromStack.balance).toBe('bigint') + expect(typeof fromLegacy.balance).not.toBe('bigint') + expect(BigInt(fromLegacy.balance as number | string)).toBe( + fromStack.balance, + ) + }) + + it('passes raw ciphertext through, with no failure, when a property key was renamed', async () => { + const drifted = encryptedTable('users', { + emailAddress: types.TextEq('email'), + }) + const driftClient = await Encryption({ schemas: [drifted] }) + const decrypted = ok( + await encryptedDynamoDB({ + encryptionClient: driftClient, + }).decryptModel( + { pk: item.pk, email__source: item.email__source }, + drifted, + { storedEqlVersion: 2 }, + ), + 'drifted decryptModel', + ) + expect(decrypted).not.toHaveProperty('emailAddress') + expect(decrypted.email__source).toBe(item.email__source) + }) + + it('reads on the bulk path too', async () => { + const [decrypted] = ok( + await stack.bulkDecryptModels([item], users, { storedEqlVersion: 2 }), + 'stack bulkDecryptModels', + ) + expect(decrypted).toMatchObject({ email: model.email, age: model.age }) + }) + + it('fails, never returns wrong plaintext, when the ciphertext is tampered with', async () => { + const source = item.email__source as string + // Flip one character well inside the Base85 body. + const at = Math.floor(source.length / 2) + const flipped = source[at] === 'A' ? 'B' : 'A' + const tampered = { + ...item, + email__source: source.slice(0, at) + flipped + source.slice(at + 1), + } + const result = await stack.decryptModel(tampered, users, { + storedEqlVersion: 2, + }) + expect(result.failure).toBeDefined() + }) + }) + + // ------------------------------------------------------------------------- + // Q2 — are the HMACs the same? + // ------------------------------------------------------------------------- + + describe('Q2: __hmac', () => { + it.each([ + ['email', 'TextEq', model.email, users.email], + ['age', 'IntegerEq', model.age, users.age], + ['balance', 'BigintEq', BigInt(model.balance), users.balance], + ] as const)( + '%s: Stack %s encryptQuery reproduces the stored __hmac', + async (attr, _domain, value, column) => { + const stored = item[`${attr}__hmac`] + expect(stored).toMatch(/^[0-9a-f]{64}$/) + expect(await hmOf(value, users, column)).toBe(stored) + }, + ) + + it('matches protect-dynamodb createSearchTerms for the same value', async () => { + const [legacyTerm] = ok( + await legacy.createSearchTerms([ + { + value: model.email, + column: legacyUsers.email, + table: legacyUsers, + }, + ]), + 'createSearchTerms', + ) + expect(legacyTerm).toBe(item.email__hmac) + expect(await hmOf(model.email, users, users.email)).toBe(legacyTerm) + }) + + it('every numeric *Eq domain gives the same HMAC (all cast to f64)', async () => { + expect(await hmOf(model.age, alternates, alternates.age)).toBe( + item.age__hmac, + ) + }) + + it('a numeric *Ord domain emits no HMAC at all', async () => { + expect(await hmOf(model.age, ordered, ordered.age)).toBe(undefined) + }) + + it('protect 10.2.1 could not encrypt a date column at all, so no legacy date HMAC exists', async () => { + for (const value of [ + '1990-04-05', + '1990-04-05T00:00:00Z', + new Date('1990-04-05'), + Date.UTC(1990, 3, 5), + ]) { + const result = await legacyClient.encrypt(value as never, { + table: legacyUsers, + column: legacyUsers.birthday, + }) + expect( + (result as { failure?: { message?: string } }).failure?.message, + String(value), + ).toMatch(/Unsupported conversion from "(String|Number)" to Date/) + } + }) + + it('a v3 Boolean writes no __hmac, though protect-dynamodb wrote one', async () => { + expect(item.active__hmac).toMatch(/^[0-9a-f]{64}$/) + const written = ok( + await stack.encryptModel({ pk: 'user#4', active: true }, users), + 'stack encryptModel (boolean)', + ) + expect(written.active__source).toBeDefined() + expect(written).not.toHaveProperty('active__hmac') + }) + + it('a v2 downcase filter is reproduced only by lowercasing first', async () => { + const stored = item.emailLower__hmac + expect(await hmOf(model.emailLower, users, users.emailLower)).not.toBe( + stored, + ) + expect( + await hmOf(model.emailLower.toLowerCase(), users, users.emailLower), + ).toBe(stored) + }) + }) + + describe('downcase on the write path', () => { + it('a Stack write keeps mixed-case plaintext only by giving up the legacy HMAC', async () => { + const stored = item.emailLower__hmac + const mixed = ok( + await stack.encryptModel( + { pk: 'user#5', emailLower: model.emailLower }, + users, + ), + 'stack encryptModel (mixed case)', + ) + expect(mixed.emailLower__hmac).not.toBe(stored) + + const lowered = ok( + await stack.encryptModel( + { pk: 'user#5', emailLower: model.emailLower.toLowerCase() }, + users, + ), + 'stack encryptModel (lowercased)', + ) + expect(lowered.emailLower__hmac).toBe(stored) + // ...and the original case is gone from the stored plaintext. + const read = ok( + await stack.decryptModel(lowered, users), + 'stack decryptModel (lowercased)', + ) + expect(read.emailLower).toBe(model.emailLower.toLowerCase()) + }) + }) + + // ------------------------------------------------------------------------- + // Q3 — is table / column bound into the encryption? + // ------------------------------------------------------------------------- + + describe('Q3: binding', () => { + it('decrypts under a renamed table and renamed DB column names', async () => { + const subset = { + pk: item.pk, + email__source: item.email__source, + email__hmac: item.email__hmac, + notes__source: item.notes__source, + } + const decrypted = ok( + await stack.decryptModel(subset, renamed, { storedEqlVersion: 2 }), + 'renamed decryptModel', + ) + expect(decrypted).toMatchObject({ + email: model.email, + notes: model.notes, + }) + }) + + it('the HMAC is bound to neither table nor column', async () => { + expect(await hmOf(model.email, renamed, renamed.email)).toBe( + item.email__hmac, + ) + }) + }) + + // ------------------------------------------------------------------------- + // Rollback — Stack 1.x writes, the old reader reads. + // ------------------------------------------------------------------------- + + describe('rollback', () => { + it('an item written by Stack 1.x reads back through protect-dynamodb 6.0.1', async () => { + const written = ok( + await stack.encryptModel( + { + pk: 'user#2', + email: 'grace@example.com', + age: 85, + notes: 'compiler', + }, + users, + ), + 'stack encryptModel', + ) + const read = ok( + await legacy.decryptModel(written, legacyUsers), + 'protect-dynamodb decryptModel', + ) + expect(read).toMatchObject({ + pk: 'user#2', + email: 'grace@example.com', + age: 85, + notes: 'compiler', + }) + }) + + it('Stack 1.x writes the same __hmac protect-dynamodb wrote', async () => { + const written = ok( + await stack.encryptModel({ pk: 'user#3', email: model.email }, users), + 'stack encryptModel', + ) + expect(written.email__hmac).toBe(item.email__hmac) + }) + }) + + // ------------------------------------------------------------------------- + // Acceptance: the migration a customer runs, end to end. The "table" is a + // Map: both adapters' items are flat string attributes, so PutItem / + // UpdateItem persistence is a copy, and an equality query on an encrypted + // attribute is a match on its `__hmac`. + // ------------------------------------------------------------------------- + + describe('acceptance: legacy write → mixed update → backfill → query → rollback', () => { + // Fresh per test: a row left by one test would add a match to another's + // exact-count query, so results would depend on test order. + const table = new Map>() + beforeEach(() => table.clear()) + const queryBy = (attr: string, hm: string | undefined) => + [...table.values()].filter((row) => row[`${attr}__hmac`] === hm) + + it('runs the migration and keeps every generation readable and queryable', async () => { + // 1. Legacy write, as protect-dynamodb 6.0.1 left it. + table.set('user#1', { ...item }) + + // 2. Mixed-generation item: Stack 1.x updates two attributes in place + // (UpdateItem SET), the rest stay as protect-dynamodb wrote them. + const update = ok( + await stack.encryptModel( + { pk: 'user#1', email: 'ada.lovelace@example.com', age: 37 }, + users, + ), + 'stack encryptModel (partial update)', + ) + table.set('user#1', { ...table.get('user#1'), ...update }) + + // One read call, no per-attribute version routing: scalars carry no + // version, so the same call reads both generations. + const mixed = ok( + await stack.decryptModel( + table.get('user#1') as Record, + users, + ), + 'stack decryptModel (mixed)', + ) + expect(mixed).toMatchObject({ + email: 'ada.lovelace@example.com', + age: 37, + emailLower: model.emailLower, + active: true, + notes: model.notes, + balance: BigInt(model.balance), + }) + + // Queries hit both generations' HMACs. + expect( + queryBy( + 'email', + await hmOf('ada.lovelace@example.com', users, users.email), + ), + ).toHaveLength(1) + expect( + queryBy( + 'emailLower', + await hmOf(model.emailLower.toLowerCase(), users, users.emailLower), + ), + ).toHaveLength(1) + + // 3. Backfill: read, normalise what v3 no longer filters, re-encrypt + // with Stack 1.x, persist (PutItem replaces the whole item). + const before = table.get('user#1') as Record + const rewritten = ok( + await stack.encryptModel( + { ...mixed, emailLower: String(mixed.emailLower).toLowerCase() }, + users, + ), + 'stack encryptModel (backfill)', + ) as Record + table.set('user#1', rewritten) + + // 4. Verify the persisted item: values back, and every query a caller + // could run before the backfill still finds it. + const after = ok( + await stack.decryptModel(rewritten, users), + 'stack decryptModel (backfilled)', + ) + expect(after).toMatchObject({ + email: 'ada.lovelace@example.com', + age: 37, + emailLower: model.emailLower.toLowerCase(), + active: true, + notes: model.notes, + balance: BigInt(model.balance), + }) + for (const attr of ['email', 'emailLower', 'age', 'balance']) { + expect(rewritten[`${attr}__hmac`], attr).toBe(before[`${attr}__hmac`]) + } + // The one query the backfill cannot keep: a v3 Boolean has no HMAC. + expect(rewritten).not.toHaveProperty('active__hmac') + expect(queryBy('active', before.active__hmac as string)).toHaveLength(0) + + // 5. Rollback: the old reader still reads the backfilled item. + const rolledBack = ok( + await legacy.decryptModel(rewritten, legacyUsers), + 'protect-dynamodb decryptModel (backfilled)', + ) + expect(rolledBack).toMatchObject({ + email: 'ada.lovelace@example.com', + age: 37, + emailLower: model.emailLower.toLowerCase(), + active: true, + notes: model.notes, + }) + expect(Number(rolledBack.balance)).toBe(model.balance) + }) + + it('a Boolean partial update must REMOVE the stale legacy __hmac', async () => { + const legacyTrue = item.active__hmac as string + expect(legacyTrue).toMatch(/^[0-9a-f]{64}$/) + + const update = ok( + await stack.encryptModel({ pk: 'user#6', active: false }, users), + 'stack encryptModel (boolean update)', + ) as Record + expect(update).not.toHaveProperty('active__hmac') + + // SET only: the new ciphertext says false, the old HMAC still says + // true, so an equality query for `true` finds an item that is false. + table.set('user#6', { ...item, pk: 'user#6', ...update }) + const setOnly = ok( + await stack.decryptModel( + table.get('user#6') as Record, + users, + ), + 'stack decryptModel (SET only)', + ) + expect(setOnly.active).toBe(false) + expect(queryBy('active', legacyTrue).map((row) => row.pk)).toContain( + 'user#6', + ) + + // SET + REMOVE active__hmac: the stale match is gone. + const { active__hmac: _stale, ...removed } = table.get( + 'user#6', + ) as Record + table.set('user#6', removed) + expect( + queryBy('active', legacyTrue).map((row) => row.pk), + ).not.toContain('user#6') + }) + }) + + // ------------------------------------------------------------------------- + // The legacy JSON gap. + // ------------------------------------------------------------------------- + + describe('legacy dataType("json")', () => { + const doc = { roles: ['admin', 'eng'], tz: 'Australia/Sydney' } + let docItem: Record + /** The v2 scalar envelope Protect's `profile__source` came from. */ + let legacyEnvelope: { + v: 2 + k: 'ct' + i: { t: string; c: string } + c: string + } + + beforeAll(async () => { + docItem = ok( + await legacy.encryptModel({ pk: 'doc#1', profile: doc }, legacyDocs), + 'protect-dynamodb encryptModel (json)', + ) + legacyEnvelope = { + v: 2, + k: 'ct', + i: { t: 'docs', c: 'profile' }, + c: docItem.profile__source as string, + } + }) + + it('was stored as a scalar ciphertext string, not a SteVec document', () => { + expect(typeof docItem.profile__source).toBe('string') + }) + + it('fails to read through encryptedDynamoDB with types.Json (known gap)', async () => { + const result = await stack.decryptModel(docItem, docs, { + storedEqlVersion: 2, + }) + expect(result.failure).toBeDefined() + }) + + it('reads through client.decrypt() on a rebuilt v2 scalar envelope', async () => { + const decrypted = ok( + await client.decrypt(legacyEnvelope), + 'client.decrypt (legacy json)', + ) + expect(decrypted).toEqual(doc) + }) + + it('backfills to a types.Json item that Stack 1.x reads', async () => { + const decrypted = ok( + await client.decrypt(legacyEnvelope), + 'client.decrypt (legacy json)', + ) + const rewritten = ok( + await stack.encryptModel( + { pk: 'doc#1', profile: decrypted as typeof doc }, + docs, + ), + 'stack encryptModel (json backfill)', + ) + const read = ok( + await stack.decryptModel(rewritten, docs), + 'stack decryptModel (json backfill)', + ) + expect(read.profile).toEqual(doc) + }) + }) + + // ------------------------------------------------------------------------- + // DEPRECATED — NO LONGER REQUIRED: the Stack 0.19 bridge. + // + // Earlier migration guidance routed Protect apps through + // `@cipherstash/stack@0.19.0` (the last release that writes EQL v2) before + // Stack 1.x. Everything above proves the direct path — Protect 10.2.1 → + // Stack 1.x — on its own, so that intermediate step is unnecessary. Do not + // recommend it. + // + // This block exists only so the answer for anyone who already took the hop + // is pinned rather than argued: a 0.19 deployment reads Protect's items, + // writes items Stack 1.x reads, and writes the SAME `__hmac` values Protect + // did, so the hop adds nothing and loses nothing. Delete the block, and the + // `@cipherstash/stack-0.19` devDependency, when no customer is on 0.19. + // ------------------------------------------------------------------------- + + describe('DEPRECATED (no longer required): Protect 10.2.1 → Stack 0.19 → Stack 1.x', () => { + /** The customer's v2 table, as a Stack 0.19 app declares it. */ + const bridgeUsers = encryptedTable019('users', { + email: encryptedColumn019('email').equality(), + emailLower: encryptedColumn019('emailLower').equality([ + { kind: 'downcase' }, + ]), + age: encryptedColumn019('age').dataType('number').equality(), + balance: encryptedColumn019('balance').dataType('bigint').equality(), + active: encryptedColumn019('active').dataType('boolean').equality(), + notes: encryptedColumn019('notes'), + }) + /** Protect's item as Stack 0.19 reads it. */ + let read: Record + /** That read, re-written by Stack 0.19. */ + let bridged: Record + + beforeAll(async () => { + const bridgeClient = await Encryption019({ schemas: [bridgeUsers] }) + const bridge = encryptedDynamoDB019({ encryptionClient: bridgeClient }) + read = ok( + await bridge.decryptModel(item, bridgeUsers), + 'stack 0.19 decryptModel (protect item)', + ) + bridged = ok( + await bridge.encryptModel(read, bridgeUsers), + 'stack 0.19 encryptModel', + ) + }, 60_000) + + it('Stack 0.19 reads the Protect 10.2.1 item', () => { + expect(read).toMatchObject({ + pk: 'user#1', + email: model.email, + emailLower: model.emailLower, + age: model.age, + active: true, + notes: model.notes, + }) + expect(BigInt(read.balance as number | bigint)).toBe( + BigInt(model.balance), + ) + }) + + it('Stack 0.19 re-writes the same attributes and the same __hmac values Protect wrote', () => { + expect(Object.keys(bridged).sort()).toEqual(Object.keys(item).sort()) + for (const key of Object.keys(item).filter((k) => + k.endsWith('__hmac'), + )) { + expect(bridged[key], key).toBe(item[key]) + } + }) + + it('Stack 1.x reads the 0.19-written item exactly as it reads the Protect item directly', async () => { + const viaBridge = ok( + await stack.decryptModel(bridged, users, { storedEqlVersion: 2 }), + 'stack decryptModel (0.19 item)', + ) + const direct = ok( + await stack.decryptModel(item, users, { storedEqlVersion: 2 }), + 'stack decryptModel (protect item)', + ) + expect(viaBridge).toEqual(direct) + }) + + it('Stack 1.x queries find the 0.19-written item', async () => { + expect(await hmOf(model.email, users, users.email)).toBe( + bridged.email__hmac, + ) + expect(await hmOf(model.age, users, users.age)).toBe(bridged.age__hmac) + expect(await hmOf(BigInt(model.balance), users, users.balance)).toBe( + bridged.balance__hmac, + ) + }) + }) + }, +) diff --git a/languages/typescript/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts b/languages/typescript/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts index 73e5e5a52..bac4867d2 100644 --- a/languages/typescript/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts +++ b/languages/typescript/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts @@ -641,6 +641,87 @@ describe('a stored v2 JSON document', () => { }) }) +/** + * What protect-dynamodb ACTUALLY wrote for a JSON attribute — and why Stack 1.x + * cannot read it. + * + * The `{ h, sv }` item above is the shape a SteVec document takes on DynamoDB, + * but `@cipherstash/schema@2.0.2` (the schema package protect-dynamodb 6.x was + * authored against) never enabled `searchableJson`: a `dataType('json')` column + * carried no `ste_vec` index, so protect-ffi stored the document as an ordinary + * scalar record and the adapter wrote its `c` — a Base85 STRING — to + * `__source`, exactly like a text column. + * + * EQL v3 has no index-less JSON domain: `types.Json` always carries `ste_vec`, + * so the reader takes the SteVec branch and reads `h` / `sv` off a string. Both + * are `undefined`, the stored ciphertext is never forwarded, and the read fails. + * + * This pins that GAP, not a contract. A legacy scalar JSON attribute has to be + * read some other way (e.g. `client.decrypt()` on a rebuilt `{ v: 2, k: 'ct' }` + * envelope). When a reader for it lands, this case should flip to asserting the + * document comes back. + */ +describe('a legacy scalar JSON attribute, as protect-dynamodb wrote it', () => { + const docs = encryptedTable('docs', { meta: types.Json('meta') }) + + it('drops the stored ciphertext and fails the read', async () => { + const { dynamo, received } = ffiStub({ + // A trap: were the stored ciphertext forwarded, this fixture would make + // the decrypt succeed and fail the test. Today the rebuilt envelope + // carries none, so the stub's missing-fixture guard raises the failure. + wire: { 'scalar-json-ct': { user: 'ada@example.com' } }, + tables: [docs], + }) + + const result = await dynamo.decryptModel( + { pk: 'doc#1', meta__source: 'scalar-json-ct' }, + docs, + { storedEqlVersion: 2 }, + ) + + // The envelope handed down carries neither the stored `c` nor a usable + // `h` / `sv`: the ciphertext is lost before it reaches the FFI. + expect(received[0]).toEqual({ + pk: 'doc#1', + meta: { i: { c: 'meta', t: 'docs' }, v: 2, k: 'sv' }, + }) + expect(JSON.stringify(received[0])).not.toContain('scalar-json-ct') + expect(result.failure).toBeDefined() + }) +}) + +/** + * The one way a legacy read returns gibberish instead of failing: a stored + * `__source` whose stem no longer matches a property key in the v3 + * `encryptedTable` (a rename) is not an envelope, so it is passed through as its + * raw ciphertext string inside a SUCCESSFUL `{ data }`. `helpers-v3.test.ts` + * pins the debug log at the helper level; this pins what the caller sees. + */ +describe('a renamed property key on a legacy read', () => { + const users = encryptedTable('users', { + givenName: types.TextEq('first_name'), + }) + + it('returns the raw ciphertext under the old attribute name, with no failure', async () => { + const { dynamo, received } = ffiStub({ wire: {}, tables: [users] }) + const stored = { + pk: 'user#1', + firstName__source: 'raw-base85-ct', + firstName__hmac: 'stored-hmac', + } + + const decrypted = unwrapResult( + await dynamo.decryptModel(stored, users, { storedEqlVersion: 2 }), + ) + + // Nothing was recognised as encrypted, so nothing reached the FFI as one, + // and the item comes back exactly as stored. + expect(received[0]).toEqual(stored) + expect(decrypted).toEqual(stored) + expect(decrypted).not.toHaveProperty('givenName') + }) +}) + // --------------------------------------------------------------------------- // bigint // --------------------------------------------------------------------------- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3566b63a..a703ae3a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,9 +87,21 @@ importers: specifier: workspace:* version: link:../languages/typescript/packages/cli devDependencies: + '@cipherstash/protect': + specifier: 10.2.1 + version: 10.2.1 + '@cipherstash/protect-dynamodb': + specifier: 6.0.1 + version: 6.0.1(@cipherstash/protect@10.2.1) + '@cipherstash/stack-0.19': + specifier: npm:@cipherstash/stack@0.19.0 + version: '@cipherstash/stack@0.19.0(@supabase/supabase-js@2.112.3)(drizzle-orm@0.45.4(@types/pg@8.23.1)(gel@2.2.0)(pg@8.23.1)(postgres@3.4.9))(next@15.5.28(@types/node@26.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)' '@types/semver': specifier: 7.8.0 version: 7.8.0 + dotenv: + specifier: 17.4.2 + version: 17.4.2 semver: specifier: ^7.8.0 version: 7.8.5 @@ -1065,9 +1077,157 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@cipherstash/auth-darwin-arm64@0.41.0': + resolution: {integrity: sha512-2B+J/bPblfiWQcxm3Z9voLhV66TZcmDpzVz5aBNS4y/B7Ke55jGH6m3H9g+c1xHZbe62q7zwDUEatveOy69f2A==} + cpu: [arm64] + os: [darwin] + + '@cipherstash/auth-darwin-x64@0.41.0': + resolution: {integrity: sha512-XrmFRSdQaoed7tqJ+RJLiBvuQr/v7qiSu7LDA3PCVbegHVcsjYQ9D0sd49zDZEEFN4zZlcwmeh80eGAEJiyhKg==} + cpu: [x64] + os: [darwin] + + '@cipherstash/auth-linux-arm64-gnu@0.41.0': + resolution: {integrity: sha512-NQJK++HzV7XCQPuNbVmIJcC5T4QuflOySYmQtP2PoTg0p+V7WK/3yruZZKgjbbCU6v8tek/D3T8hk02snq/8sQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@cipherstash/auth-linux-x64-gnu@0.41.0': + resolution: {integrity: sha512-9NMiMs2p8p5NBcir8Cx90zBzOlkM0EWbIZOeCIjokT9X4d6UJy3+JhXi5VRn0gWpqAA7i7+BZD+puZ+fmBzJLQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@cipherstash/auth-linux-x64-musl@0.41.0': + resolution: {integrity: sha512-V8QwrxXLoqO0ED02hnvQJNBid/iDZUawt3zobUL+DqieN3QZxDxI5/viFIkA4bTg7CV6oSmy2b0rINGqiHvkig==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@cipherstash/auth-win32-x64-msvc@0.41.0': + resolution: {integrity: sha512-MkC04kTuyUd7J2/dRWIzdER1if6V+g6tVDRoZI/dql/Uq/+FXtV9lBXDcS1iATDyE/1APLvMMvuAqJeELGrGag==} + cpu: [x64] + os: [win32] + + '@cipherstash/auth@0.41.0': + resolution: {integrity: sha512-jhhCthk+vilCQAvOfKYPSnIOZR2J3/+0JEYLNY3+M19rEzZ2ZmTLZDqzu4c+y1P73LgzTX+oWL6ESUoKsMd0TQ==} + peerDependencies: + '@cipherstash/auth-darwin-arm64': 0.41.0 + '@cipherstash/auth-darwin-x64': 0.41.0 + '@cipherstash/auth-linux-arm64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-musl': 0.41.0 + '@cipherstash/auth-win32-x64-msvc': 0.41.0 + peerDependenciesMeta: + '@cipherstash/auth-darwin-arm64': + optional: true + '@cipherstash/auth-darwin-x64': + optional: true + '@cipherstash/auth-linux-arm64-gnu': + optional: true + '@cipherstash/auth-linux-x64-gnu': + optional: true + '@cipherstash/auth-linux-x64-musl': + optional: true + '@cipherstash/auth-win32-x64-msvc': + optional: true + '@cipherstash/eql@3.0.2': resolution: {integrity: sha512-E85o0aoOqgCW6RReLtJ0YLh/ExRlmDJo7LlJGpWPoMTVaw+CW8o11DJ4oJIF1vFtuxSVxNULuPzzBuVmpTvvcA==} + '@cipherstash/protect-dynamodb@6.0.1': + resolution: {integrity: sha512-AjlV0jLbD3DivFMiEtS+En3457pH7Ywrnk8S6f4l35F/NnXt1PQer033G/G8SqvVTAymrPysgoENKi5ZxrVnTA==} + deprecated: EQL v2-only package, no longer maintained. Use @cipherstash/stack/dynamodb instead. + peerDependencies: + '@cipherstash/protect': 10.2.1 + + '@cipherstash/protect-ffi-darwin-arm64@0.18.1': + resolution: {integrity: sha512-kTkshWuGG07X9m4Ug0mPXIglfZopVFCJy55/B2yy9Z+hhNP95d4Zjq6AXlRYAHsnxgcp0SnynC8LaVTcXsEx8w==} + cpu: [arm64] + os: [darwin] + + '@cipherstash/protect-ffi-darwin-arm64@0.28.0': + resolution: {integrity: sha512-QRwiovEdgjstbaCONZGYXA82NG4Gwz9mJOZeAzthUmNIQfnVErO9UQAfJ+I1T/YG1Ww2FCWcHltXMl/qjyeK4A==} + 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-darwin-x64@0.28.0': + resolution: {integrity: sha512-H8qB2z3drJSc2bR5ITsJSFrOoNKvORbauTdHmaS1e5OleXsLl/F+56Zvghz8NWs+bXs/2aVzHxpYte8FgV8AAQ==} + 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-arm64-gnu@0.28.0': + resolution: {integrity: sha512-h7vZxB/wako6j1jspkGl41gptTN1dBTWYqAR6jhVnvKJK0TBlyUKuSJwO1NeqyNfFQ/b1KmqJ11JGvY95uRZOA==} + 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-gnu@0.28.0': + resolution: {integrity: sha512-PgHn0Og0PiCwFdV4yKhipGKfqkCXP6Xchyy6ykTaISabmSlkkbRNkyNF1/tZEQYF65sm5gmqSpwSYzKODaAsbg==} + 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-linux-x64-musl@0.28.0': + resolution: {integrity: sha512-fMu3k1Y3wYMcGbGpVtrlCMNRLd1//5c9ZHcyWFjKFqimOCbBO5akgCQij4zcvj4kYDHtveu9dOdOKVhaG8k6hQ==} + 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-win32-x64-msvc@0.28.0': + resolution: {integrity: sha512-tF0y7TcyBtgN/jHodYMnrJ4etLkEI/klzDivW8Q8eIuIdoJ3q5muZKFIhNX0MbDDdh2O4GsRdNhXRubs7BiFpA==} + cpu: [x64] + os: [win32] + + '@cipherstash/protect-ffi@0.18.1': + resolution: {integrity: sha512-rDYIRIWo7EvJ+ytGEwzeb0D/4j3tQ8w+5Kpz089IEhxIVxOzavsR+f5HyMXohAKYkH+19RpD+2X+v1P8rwmiUA==} + + '@cipherstash/protect-ffi@0.28.0': + resolution: {integrity: sha512-R2L/8HwMREkVKlR5KCcuELIWz4QNButSBQzY+nRDHl1PUXjRqWG1h265FkKVtTXKNKUup7rB4mswu+M+t9KF3A==} + + '@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. + + '@cipherstash/stack@0.19.0': + resolution: {integrity: sha512-n/jzMEiEU3AObG2isu1uMCQ34k2SqKSReVd63j63uaMU2UCHwmXZWtg9WeXF+IdS1w6G6VVj/DZCuygv3kzxWg==} + engines: {node: '>=22'} + peerDependencies: + '@supabase/supabase-js': '>=2' + drizzle-orm: '>=0.33' + peerDependenciesMeta: + '@supabase/supabase-js': + optional: true + drizzle-orm: + optional: true + '@clack/core@1.5.1': resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} engines: {node: '>= 20.12.0'} @@ -1666,6 +1826,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 +2149,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] @@ -3814,6 +3983,10 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + uuid@14.0.3: resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==} hasBin: true @@ -4224,8 +4397,139 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@cipherstash/auth-darwin-arm64@0.41.0': + optional: true + + '@cipherstash/auth-darwin-x64@0.41.0': + optional: true + + '@cipherstash/auth-linux-arm64-gnu@0.41.0': + optional: true + + '@cipherstash/auth-linux-x64-gnu@0.41.0': + optional: true + + '@cipherstash/auth-linux-x64-musl@0.41.0': + optional: true + + '@cipherstash/auth-win32-x64-msvc@0.41.0': + optional: true + + '@cipherstash/auth@0.41.0(@cipherstash/auth-darwin-arm64@0.41.0)(@cipherstash/auth-darwin-x64@0.41.0)(@cipherstash/auth-linux-arm64-gnu@0.41.0)(@cipherstash/auth-linux-x64-gnu@0.41.0)(@cipherstash/auth-linux-x64-musl@0.41.0)(@cipherstash/auth-win32-x64-msvc@0.41.0)': + dependencies: + '@byteslice/result': 0.3.0 + optionalDependencies: + '@cipherstash/auth-darwin-arm64': 0.41.0 + '@cipherstash/auth-darwin-x64': 0.41.0 + '@cipherstash/auth-linux-arm64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-musl': 0.41.0 + '@cipherstash/auth-win32-x64-msvc': 0.41.0 + '@cipherstash/eql@3.0.2': {} + '@cipherstash/protect-dynamodb@6.0.1(@cipherstash/protect@10.2.1)': + dependencies: + '@byteslice/result': 0.2.0 + '@cipherstash/protect': 10.2.1 + + '@cipherstash/protect-ffi-darwin-arm64@0.18.1': + optional: true + + '@cipherstash/protect-ffi-darwin-arm64@0.28.0': + optional: true + + '@cipherstash/protect-ffi-darwin-x64@0.18.1': + optional: true + + '@cipherstash/protect-ffi-darwin-x64@0.28.0': + optional: true + + '@cipherstash/protect-ffi-linux-arm64-gnu@0.18.1': + optional: true + + '@cipherstash/protect-ffi-linux-arm64-gnu@0.28.0': + optional: true + + '@cipherstash/protect-ffi-linux-x64-gnu@0.18.1': + optional: true + + '@cipherstash/protect-ffi-linux-x64-gnu@0.28.0': + optional: true + + '@cipherstash/protect-ffi-linux-x64-musl@0.18.1': + optional: true + + '@cipherstash/protect-ffi-linux-x64-musl@0.28.0': + optional: true + + '@cipherstash/protect-ffi-win32-x64-msvc@0.18.1': + optional: true + + '@cipherstash/protect-ffi-win32-x64-msvc@0.28.0': + 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-ffi@0.28.0': + dependencies: + '@neon-rs/load': 0.1.82 + optionalDependencies: + '@cipherstash/protect-ffi-darwin-arm64': 0.28.0 + '@cipherstash/protect-ffi-darwin-x64': 0.28.0 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.28.0 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.28.0 + '@cipherstash/protect-ffi-linux-x64-musl': 0.28.0 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.28.0 + + '@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 + + '@cipherstash/stack@0.19.0(@supabase/supabase-js@2.112.3)(drizzle-orm@0.45.4(@types/pg@8.23.1)(gel@2.2.0)(pg@8.23.1)(postgres@3.4.9))(next@15.5.28(@types/node@26.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + dependencies: + '@byteslice/result': 0.2.0 + '@cipherstash/auth': 0.41.0(@cipherstash/auth-darwin-arm64@0.41.0)(@cipherstash/auth-darwin-x64@0.41.0)(@cipherstash/auth-linux-arm64-gnu@0.41.0)(@cipherstash/auth-linux-x64-gnu@0.41.0)(@cipherstash/auth-linux-x64-musl@0.41.0)(@cipherstash/auth-win32-x64-msvc@0.41.0) + '@cipherstash/protect-ffi': 0.28.0 + evlog: 1.11.0(next@15.5.28(@types/node@26.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + uuid: 14.0.0 + zod: 3.25.76 + optionalDependencies: + '@cipherstash/auth-darwin-arm64': 0.41.0 + '@cipherstash/auth-darwin-x64': 0.41.0 + '@cipherstash/auth-linux-arm64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-musl': 0.41.0 + '@cipherstash/auth-win32-x64-msvc': 0.41.0 + '@supabase/supabase-js': 2.112.3 + drizzle-orm: 0.45.4(@types/pg@8.23.1)(gel@2.2.0)(pg@8.23.1)(postgres@3.4.9) + transitivePeerDependencies: + - '@nuxt/kit' + - h3 + - next + - nitro + - nitropack + - ofetch + - react + '@clack/core@1.5.1': dependencies: fast-wrap-ansi: 0.2.2 @@ -4640,6 +4944,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': {} @@ -4947,6 +5253,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 @@ -6646,6 +6955,8 @@ snapshots: unpipe@1.0.0: {} + uuid@14.0.0: {} + uuid@14.0.3: {} vary@1.1.2: {}