From 6f83fa359b42c10677a8f6a049bc51d4c99996a1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 29 May 2026 18:44:23 +1000 Subject: [PATCH 1/6] chore(stack): migrate to @cipherstash/protect-ffi 0.25.0 protect-ffi 0.25.0 is a breaking release for both entries: WASM (@cipherstash/stack/wasm-inline): - newClient(strategy, opts) -> newClient(opts) with strategy nested. - Config takes a workspaceCrn instead of region; the AccessKeyStrategy region is derived from the CRN (crn::). CS_REGION is no longer consulted; set CS_WORKSPACE_CRN. Node: - serviceToken removed from the encrypt/decrypt/query option types (and the CtsToken export). The per-operation CTS token is no longer forwarded; lock contexts still travel as lockContext.identityClaim. Public LockContext/identify() API is unchanged. Adds offline lock-context wiring tests (mock protect-ffi) asserting every operation forwards identityClaim and never sends serviceToken, plus extractRegionFromCrn unit tests. Updates the Deno e2e test, Supabase example, and wasm-e2e CI job to CS_WORKSPACE_CRN. --- .../stack-protect-ffi-0-25-wasm-inline.md | 12 + .github/workflows/tests.yml | 11 +- e2e/wasm/deno.json | 2 +- e2e/wasm/roundtrip.test.ts | 15 +- examples/supabase-worker/.env.example | 8 +- examples/supabase-worker/README.md | 2 +- .../functions/cipherstash-roundtrip/index.ts | 5 +- .../__tests__/lock-context-wiring.test.ts | 202 +++++++++++++++++ .../__tests__/wasm-inline-normalize.test.ts | 28 ++- packages/stack/package.json | 2 +- .../src/encryption/helpers/model-helpers.ts | 4 - .../operations/batch-encrypt-query.ts | 3 +- .../src/encryption/operations/bulk-decrypt.ts | 1 - .../src/encryption/operations/bulk-encrypt.ts | 1 - .../src/encryption/operations/decrypt.ts | 1 - .../encryption/operations/encrypt-query.ts | 3 +- .../src/encryption/operations/encrypt.ts | 1 - packages/stack/src/wasm-inline.ts | 82 ++++--- pnpm-lock.yaml | 205 ++++++++++++------ 19 files changed, 456 insertions(+), 132 deletions(-) create mode 100644 .changeset/stack-protect-ffi-0-25-wasm-inline.md create mode 100644 packages/stack/__tests__/lock-context-wiring.test.ts diff --git a/.changeset/stack-protect-ffi-0-25-wasm-inline.md b/.changeset/stack-protect-ffi-0-25-wasm-inline.md new file mode 100644 index 00000000..b4f03ea4 --- /dev/null +++ b/.changeset/stack-protect-ffi-0-25-wasm-inline.md @@ -0,0 +1,12 @@ +--- +"@cipherstash/stack": minor +--- + +Bump `@cipherstash/protect-ffi` to `0.25.0` and align the WASM-inline path with its API. + +protect-ffi `0.25.0` is a breaking release for both entries: + +- **WASM (`@cipherstash/stack/wasm-inline`)**: `newClient` now takes a single options object with the auth strategy nested under `strategy` (was a separate first argument). The WASM `Encryption()` config now takes a **`workspaceCrn`** instead of a `region` — the CRN is the single source of truth for workspace identity, and the `AccessKeyStrategy` region is derived from it (`crn::`). `CS_REGION` is no longer consulted; set `CS_WORKSPACE_CRN`. This matches protect-ffi `0.25`, which dropped `CS_REGION` in favour of `CS_WORKSPACE_CRN`. +- **Node**: `serviceToken` was removed from the encrypt / decrypt / query option types (and the `CtsToken` export). The per-operation CTS token is no longer forwarded — auth flows through the client's strategy / credentials, while lock contexts continue to travel as `lockContext.identityClaim`. The public `LockContext` / `identify()` API is unchanged. + +Also adds an optional **`config.strategy`** to `Encryption()` (Node): pass an `AuthStrategy` — any `{ getToken(): Promise<{ token }> }`-shaped object, e.g. `AccessKeyStrategy` from `@cipherstash/auth` — and its `getToken()` is invoked on every ZeroKMS request, taking precedence over the credentials-derived default (the `clientKey` is still used for encryption). Omitting it preserves the existing credentials / env behaviour. `AuthStrategy` is re-exported from `@cipherstash/stack`. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8fd4543c..26b874b6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -157,7 +157,7 @@ jobs: run: pnpm exec turbo run test:e2e --filter @cipherstash/e2e # Verifies @cipherstash/stack/wasm-inline works under Deno — i.e. the - # WASM build of protect-ffi 0.24+ and auth 0.37+ can round-trip an + # WASM build of protect-ffi 0.25+ and auth 0.38+ can round-trip an # encryption against ZeroKMS / CTS in a runtime with no native # bindings available. The deno.json deliberately omits --allow-ffi so # a silent fallback to the NAPI module is impossible. @@ -168,10 +168,11 @@ jobs: permissions: contents: read - # CS_WORKSPACE_CRN deliberately not exposed here: the WASM client - # doesn't read it. A separate ticket tracks adding parity with the - # Node entry, at which point the CRN should be re-added. + # CS_WORKSPACE_CRN is the single source of truth for workspace + # identity and region — the stack /wasm-inline config requires it and + # derives the AccessKeyStrategy region from it. env: + CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} @@ -222,7 +223,7 @@ jobs: # Fail loudly instead. - name: Assert CS_* secrets are present run: | - for v in CS_CLIENT_ID CS_CLIENT_KEY CS_CLIENT_ACCESS_KEY; do + for v in CS_WORKSPACE_CRN CS_CLIENT_ID CS_CLIENT_KEY CS_CLIENT_ACCESS_KEY; do if [ -z "${!v}" ]; then echo "::error::Required secret $v is not set on this runner — the WASM smoke test would silently skip." exit 1 diff --git a/e2e/wasm/deno.json b/e2e/wasm/deno.json index 24bb913a..6e3b00d7 100644 --- a/e2e/wasm/deno.json +++ b/e2e/wasm/deno.json @@ -9,7 +9,7 @@ }, "imports": { "@cipherstash/stack/wasm-inline": "../../packages/stack/dist/wasm-inline.js", - "@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.24.0/wasm-inline", + "@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.25.0/wasm-inline", "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.38.0/wasm-inline" } } diff --git a/e2e/wasm/roundtrip.test.ts b/e2e/wasm/roundtrip.test.ts index 7ee9088a..729a985e 100644 --- a/e2e/wasm/roundtrip.test.ts +++ b/e2e/wasm/roundtrip.test.ts @@ -22,11 +22,12 @@ import { isEncrypted, } from '@cipherstash/stack/wasm-inline' -// `CS_WORKSPACE_CRN` is intentionally not in this list — the WASM -// client doesn't read it (workspace identity comes from the access-key -// token). A separate ticket tracks adding parity with the Node entry, -// at which point CRN should be added back here. +// `CS_WORKSPACE_CRN` is the single source of truth for workspace +// identity and region — the stack `/wasm-inline` config requires it and +// derives the `AccessKeyStrategy` region from it. `CS_REGION` is not +// consulted. const REQUIRED_ENV = [ + 'CS_WORKSPACE_CRN', 'CS_CLIENT_ACCESS_KEY', 'CS_CLIENT_ID', 'CS_CLIENT_KEY', @@ -69,9 +70,9 @@ Deno.test({ const client = await Encryption({ schemas: [users], config: { - // Default region in the stack is ap-southeast-2.aws; the WASM - // entry needs an explicit region for AccessKeyStrategy. - region: 'ap-southeast-2.aws', + // CRN is the single source of truth — the region the + // AccessKeyStrategy needs is derived from it. + workspaceCrn: env!.CS_WORKSPACE_CRN, accessKey: env!.CS_CLIENT_ACCESS_KEY, clientId: env!.CS_CLIENT_ID, clientKey: env!.CS_CLIENT_KEY, diff --git a/examples/supabase-worker/.env.example b/examples/supabase-worker/.env.example index d4ec9784..dc474a6f 100644 --- a/examples/supabase-worker/.env.example +++ b/examples/supabase-worker/.env.example @@ -4,8 +4,8 @@ CS_CLIENT_ACCESS_KEY= CS_CLIENT_ID= CS_CLIENT_KEY= -CS_REGION=ap-southeast-2.aws -# `CS_WORKSPACE_CRN` is intentionally omitted: the WASM client derives -# workspace identity from the access-key token, not from the CRN. This -# is a known parity gap with the Node entry — tracked separately. +# Workspace CRN, e.g. crn:ap-southeast-2.aws:your-workspace-id. The +# single source of truth for workspace identity and region — the +# AccessKeyStrategy region is derived from it. CS_REGION is not used. +CS_WORKSPACE_CRN= diff --git a/examples/supabase-worker/README.md b/examples/supabase-worker/README.md index d02775fe..99701b72 100644 --- a/examples/supabase-worker/README.md +++ b/examples/supabase-worker/README.md @@ -22,7 +22,7 @@ pnpm install ```sh cp .env.example .env.local -# fill in CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY (and optionally CS_REGION) +# fill in CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY supabase functions serve --env-file .env.local cipherstash-roundtrip ``` diff --git a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts index cf7b8281..603e0939 100644 --- a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts +++ b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts @@ -28,9 +28,10 @@ Deno.serve(async (_req: Request) => { const accessKey = Deno.env.get('CS_CLIENT_ACCESS_KEY') const clientId = Deno.env.get('CS_CLIENT_ID') const clientKey = Deno.env.get('CS_CLIENT_KEY') - const region = Deno.env.get('CS_REGION') ?? 'ap-southeast-2.aws' + const workspaceCrn = Deno.env.get('CS_WORKSPACE_CRN') const missing = Object.entries({ + CS_WORKSPACE_CRN: workspaceCrn, CS_CLIENT_ACCESS_KEY: accessKey, CS_CLIENT_ID: clientId, CS_CLIENT_KEY: clientKey, @@ -52,7 +53,7 @@ Deno.serve(async (_req: Request) => { const client = await Encryption({ schemas: [users], config: { - region, + workspaceCrn: workspaceCrn!, accessKey: accessKey!, clientId: clientId!, clientKey: clientKey!, diff --git a/packages/stack/__tests__/lock-context-wiring.test.ts b/packages/stack/__tests__/lock-context-wiring.test.ts new file mode 100644 index 00000000..201ce2e4 --- /dev/null +++ b/packages/stack/__tests__/lock-context-wiring.test.ts @@ -0,0 +1,202 @@ +/** + * Offline wiring tests for the lock-context path. + * + * protect-ffi 0.25 removed the per-operation `serviceToken` option — the + * CTS token is no longer forwarded; lock contexts travel as + * `lockContext.identityClaim` only. The live `lock-context.test.ts` + * exercises a real CTS round-trip but skips without a `USER_JWT`, so it + * can't guard this wiring in CI. These tests mock `@cipherstash/protect-ffi` + * and assert, for every operation, that: + * 1. the lock context's `identityClaim` reaches protect-ffi, and + * 2. no `serviceToken` is ever passed (the removed field must not creep + * back in). + */ +import { Encryption } from '@/index' +import { LockContext } from '@/identity' +import { encryptedColumn, encryptedTable } from '@/schema' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// A protect-ffi-shaped encrypted payload (passes the SDK's +// `isEncryptedPayload` check so model decrypt detects encrypted fields). +const enc = () => ({ v: 2, i: { t: 'users', c: 'email' }, c: 'ciphertext' }) + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), + encrypt: vi.fn(async () => enc()), + decrypt: vi.fn(async () => 'decrypted'), + encryptBulk: vi.fn(async (_c: unknown, opts: { plaintexts: unknown[] }) => + opts.plaintexts.map(enc), + ), + decryptBulk: vi.fn(async (_c: unknown, opts: { ciphertexts: unknown[] }) => + opts.ciphertexts.map(() => 'decrypted'), + ), + decryptBulkFallible: vi.fn( + async (_c: unknown, opts: { ciphertexts: unknown[] }) => + opts.ciphertexts.map(() => ({ data: 'decrypted' })), + ), + encryptQuery: vi.fn(async () => enc()), + encryptQueryBulk: vi.fn(async (_c: unknown, opts: { queries: unknown[] }) => + opts.queries.map(enc), + ), +})) + +import * as ffi from '@cipherstash/protect-ffi' + +const users = encryptedTable('users', { + email: encryptedColumn('email').equality(), +}) + +const IDENTITY_CLAIM = { identityClaim: ['sub'] } + +// Build a lock context without a network round-trip: a pre-supplied CTS +// token short-circuits `identify()`, and the default context is +// `{ identityClaim: ['sub'] }`. +const lockCtx = () => + new LockContext({ + ctsToken: { accessToken: 'test-cts-token', expiry: 9_999_999_999 }, + }) + +/** Deep scan for a `serviceToken` key anywhere in a value. */ +function hasServiceToken(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasServiceToken) + if (value && typeof value === 'object') { + if ('serviceToken' in value) return true + return Object.values(value).some(hasServiceToken) + } + return false +} + +// biome-ignore lint/suspicious/noExplicitAny: test helper unwraps Result +function unwrap(result: any) { + if (result.failure) { + throw new Error(`operation failed: ${result.failure.message}`) + } + return result.data +} + +/** Options the operation was last called with (second arg to the ffi fn). */ +// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args +const lastOpts = (fn: any) => fn.mock.calls.at(-1)[1] + +let client: Awaited> + +beforeEach(async () => { + vi.clearAllMocks() + process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' + client = await Encryption({ schemas: [users] }) +}) + +describe('lock-context wiring: identityClaim forwarded, serviceToken never sent', () => { + it('encrypt forwards lockContext and omits serviceToken', async () => { + unwrap( + await client + .encrypt('alice@example.com', { column: users.email, table: users }) + .withLockContext(lockCtx()), + ) + + const opts = lastOpts(ffi.encrypt) + expect(opts.lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) + + it('encrypt without a lock context sends neither lockContext nor serviceToken', async () => { + unwrap( + await client.encrypt('alice@example.com', { + column: users.email, + table: users, + }), + ) + + const opts = lastOpts(ffi.encrypt) + expect(opts.lockContext).toBeUndefined() + expect(hasServiceToken(opts)).toBe(false) + }) + + it('decrypt forwards lockContext and omits serviceToken', async () => { + unwrap(await client.decrypt(enc()).withLockContext(lockCtx())) + + const opts = lastOpts(ffi.decrypt) + expect(opts.lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) + + it('bulkEncrypt forwards per-payload lockContext and omits serviceToken', async () => { + unwrap( + await client + .bulkEncrypt([{ id: '1', plaintext: 'alice@example.com' }], { + column: users.email, + table: users, + }) + .withLockContext(lockCtx()), + ) + + const opts = lastOpts(ffi.encryptBulk) + expect(opts.plaintexts[0].lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) + + it('bulkDecrypt forwards per-payload lockContext and omits serviceToken', async () => { + unwrap( + await client + .bulkDecrypt([{ id: '1', data: enc() }]) + .withLockContext(lockCtx()), + ) + + const opts = lastOpts(ffi.decryptBulkFallible) + expect(opts.ciphertexts[0].lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) + + it('encryptQuery (single) forwards lockContext and omits serviceToken', async () => { + unwrap( + await client + .encryptQuery('alice@example.com', { + column: users.email, + table: users, + }) + .withLockContext(lockCtx()), + ) + + const opts = lastOpts(ffi.encryptQuery) + expect(opts.lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) + + it('encryptQuery (batch) forwards per-query lockContext and omits serviceToken', async () => { + unwrap( + await client + .encryptQuery([ + { value: 'alice@example.com', column: users.email, table: users }, + ]) + .withLockContext(lockCtx()), + ) + + const opts = lastOpts(ffi.encryptQueryBulk) + expect(opts.queries[0].lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) + + it('encryptModel forwards per-payload lockContext and omits serviceToken', async () => { + unwrap( + await client + .encryptModel({ id: '1', email: 'alice@example.com' }, users) + .withLockContext(lockCtx()), + ) + + const opts = lastOpts(ffi.encryptBulk) + expect(opts.plaintexts[0].lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) + + it('decryptModel forwards per-payload lockContext and omits serviceToken', async () => { + unwrap( + await client + .decryptModel({ id: '1', email: enc() }) + .withLockContext(lockCtx()), + ) + + const opts = lastOpts(ffi.decryptBulk) + expect(opts.ciphertexts[0].lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-normalize.test.ts b/packages/stack/__tests__/wasm-inline-normalize.test.ts index 77afbb5e..fcc5dc7a 100644 --- a/packages/stack/__tests__/wasm-inline-normalize.test.ts +++ b/packages/stack/__tests__/wasm-inline-normalize.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { castAsEnum, toEqlCastAs } from '../src/schema' -import { normalizeCastAs } from '../src/wasm-inline' +import { extractRegionFromCrn, normalizeCastAs } from '../src/wasm-inline' // Exhaustive mapping the wasm-inline normaliser is expected to produce. // If you add a variant to `castAsEnum`, add the corresponding EQL value @@ -94,3 +94,29 @@ describe('wasm-inline normalizeCastAs', () => { ) }) }) + +describe('wasm-inline extractRegionFromCrn', () => { + it('pulls the region out of a well-formed workspace CRN', () => { + expect( + extractRegionFromCrn('crn:ap-southeast-2.aws:my-workspace-id'), + ).toBe('ap-southeast-2.aws') + }) + + it('handles a plain region segment (no .aws suffix)', () => { + expect(extractRegionFromCrn('crn:us-east-1:envWorkspace')).toBe( + 'us-east-1', + ) + }) + + it('throws on a CRN missing the workspace-id segment', () => { + expect(() => extractRegionFromCrn('crn:ap-southeast-2.aws')).toThrowError( + /invalid workspace CRN/, + ) + }) + + it('throws on a value that is not a CRN at all', () => { + expect(() => extractRegionFromCrn('ap-southeast-2.aws')).toThrowError( + /invalid workspace CRN/, + ) + }) +}) diff --git a/packages/stack/package.json b/packages/stack/package.json index 22be2377..bce96133 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -214,7 +214,7 @@ "dependencies": { "@byteslice/result": "0.2.0", "@cipherstash/auth": "catalog:repo", - "@cipherstash/protect-ffi": "0.24.0", + "@cipherstash/protect-ffi": "0.25.0", "evlog": "1.11.0", "uuid": "14.0.0", "zod": "3.25.76" diff --git a/packages/stack/src/encryption/helpers/model-helpers.ts b/packages/stack/src/encryption/helpers/model-helpers.ts index 78e5fcb6..710b5bdc 100644 --- a/packages/stack/src/encryption/helpers/model-helpers.ts +++ b/packages/stack/src/encryption/helpers/model-helpers.ts @@ -409,7 +409,6 @@ export async function decryptModelFieldsWithLockContext< (items) => decryptBulk(client, { ciphertexts: items, - serviceToken: lockContext.ctsToken, unverifiedContext: auditData?.metadata, }), keyMap, @@ -469,7 +468,6 @@ export async function encryptModelFieldsWithLockContext( (items) => encryptBulk(client, { plaintexts: items, - serviceToken: lockContext.ctsToken, unverifiedContext: auditData?.metadata, }), keyMap, @@ -791,7 +789,6 @@ export async function bulkDecryptModelsWithLockContext< (items) => decryptBulk(client, { ciphertexts: items, - serviceToken: lockContext.ctsToken, unverifiedContext: auditData?.metadata, }), keyMap, @@ -865,7 +862,6 @@ export async function bulkEncryptModelsWithLockContext( (items) => encryptBulk(client, { plaintexts: items, - serviceToken: lockContext.ctsToken, unverifiedContext: auditData?.metadata, }), keyMap, diff --git a/packages/stack/src/encryption/operations/batch-encrypt-query.ts b/packages/stack/src/encryption/operations/batch-encrypt-query.ts index 0e2b674b..7eb770b0 100644 --- a/packages/stack/src/encryption/operations/batch-encrypt-query.ts +++ b/packages/stack/src/encryption/operations/batch-encrypt-query.ts @@ -214,7 +214,7 @@ export class BatchEncryptQueryOperationWithLockContext extends EncryptionOperati return { failure: lockContextResult.failure } } - const { ctsToken, context } = lockContextResult.data + const { context } = lockContextResult.data const result = await withResult( async () => { @@ -228,7 +228,6 @@ export class BatchEncryptQueryOperationWithLockContext extends EncryptionOperati const encrypted = await ffiEncryptQueryBulk(this.client, { queries, - serviceToken: ctsToken, unverifiedContext: metadata, }) diff --git a/packages/stack/src/encryption/operations/bulk-decrypt.ts b/packages/stack/src/encryption/operations/bulk-decrypt.ts index 4f443e2d..0cfc5204 100644 --- a/packages/stack/src/encryption/operations/bulk-decrypt.ts +++ b/packages/stack/src/encryption/operations/bulk-decrypt.ts @@ -170,7 +170,6 @@ export class BulkDecryptOperationWithLockContext extends EncryptionOperation { diff --git a/packages/stack/src/encryption/operations/encrypt-query.ts b/packages/stack/src/encryption/operations/encrypt-query.ts index f013d1f7..38eca7e1 100644 --- a/packages/stack/src/encryption/operations/encrypt-query.ts +++ b/packages/stack/src/encryption/operations/encrypt-query.ts @@ -160,7 +160,7 @@ export class EncryptQueryOperationWithLockContext extends EncryptionOperation { @@ -188,7 +188,6 @@ export class EncryptQueryOperationWithLockContext extends EncryptionOperation:` split this module uses — and + * pass the strategy via `config.strategy`. */ import { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' @@ -115,30 +117,29 @@ export type WasmPlaintext = /** * Config for {@link Encryption} on the WASM entry point. * - * Unlike the Node entry, the WASM path needs the region passed - * explicitly today (no default — workspace deployment region is a - * caller concern). For service-to-service / CI use, pass `accessKey` - * plus the workspace `clientId` / `clientKey` and we construct an - * `AccessKeyStrategy` for you. To plug in a custom token store - * (cookies on Supabase Edge, KV on Cloudflare Workers, …) build the - * strategy with `AccessKeyStrategy.create(region, accessKey, { store })` - * and hand it to `config.strategy` instead. + * The workspace CRN is the single source of truth for workspace + * identity and deployment region — matching the Node entry and + * protect-ffi 0.25+, which read `CS_WORKSPACE_CRN` and no longer + * consult a separate `CS_REGION`. The region the underlying + * `AccessKeyStrategy` needs is derived from the CRN's + * `crn::` form, so there is no `region` field to + * keep in sync. * - * NOTE: `region` will be removed in a future release. The strategy - * will then take a `workspaceCrn` and derive the region from it — - * single source of truth, with the bearer token's workspace asserted - * against the CRN. Plan accordingly; the field is required for now - * because the underlying `@cipherstash/auth/wasm-inline` - * `AccessKeyStrategy.create()` still takes a region argument. + * For service-to-service / CI use, pass `accessKey` plus the workspace + * `clientId` / `clientKey` and we construct an `AccessKeyStrategy` for + * you. To plug in a custom token store (cookies on Supabase Edge, KV on + * Cloudflare Workers, …) build the strategy with + * `AccessKeyStrategy.create(region, accessKey, { store })` and hand it + * to `config.strategy` instead. */ export type WasmClientConfig = { /** - * CipherStash region, e.g. `"ap-southeast-2.aws"`. Required for now. - * @deprecated will be replaced by `workspaceCrn` once - * `@cipherstash/auth` switches `AccessKeyStrategy.create()` to derive - * region from a CRN. + * CipherStash workspace CRN, e.g. + * `"crn:ap-southeast-2.aws:my-workspace-id"`. Required — it is the + * single source of truth for workspace identity, and the region the + * `AccessKeyStrategy` needs is derived from it. */ - region: string + workspaceCrn: string /** Workspace client identifier — required by the WASM client. */ clientId: string /** Workspace client key — required by the WASM client. */ @@ -250,7 +251,11 @@ export async function Encryption( const strategy = resolveStrategy(clientConfig) - const client = await wasmNewClient(strategy as never, { + // protect-ffi 0.25 takes a single options object with the strategy + // nested under `strategy` (0.24 passed the strategy as a separate + // first argument). + const client = await wasmNewClient({ + strategy, encryptConfig: normalizeCastAs(encryptConfig), clientId: clientConfig.clientId, clientKey: clientConfig.clientKey, @@ -314,5 +319,30 @@ function getColumnName( function resolveStrategy(cfg: WasmClientConfig): AccessKeyStrategy { if (cfg.strategy) return cfg.strategy // Discriminated union guarantees this branch implies `accessKey` is set. - return AccessKeyStrategy.create(cfg.region, cfg.accessKey as string) + // `AccessKeyStrategy.create` still takes a bare region string, so derive + // it from the CRN — keeping the CRN as the single source of truth. + return AccessKeyStrategy.create( + extractRegionFromCrn(cfg.workspaceCrn), + cfg.accessKey as string, + ) +} + +/** + * Pull the region out of a workspace CRN (`crn::`, + * e.g. `crn:ap-southeast-2.aws:my-workspace` → `ap-southeast-2.aws`). + * + * Defined locally rather than imported from `@/utils/config` so the + * WASM entry stays free of that module's Node-only `fs` / `path` + * imports. + * + * @internal exported for unit-test coverage. + */ +export function extractRegionFromCrn(crn: string): string { + const match = crn.match(/^crn:([^:]+):[^:]+$/) + if (!match) { + throw new Error( + `[encryption]: invalid workspace CRN "${crn}" — expected the form "crn::".`, + ) + } + return match[1] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 304e7815..ec0c3e5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,25 +240,6 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 - optionalDependencies: - '@cipherstash/auth-darwin-arm64': - specifier: catalog:repo - version: 0.38.0 - '@cipherstash/auth-darwin-x64': - specifier: catalog:repo - version: 0.38.0 - '@cipherstash/auth-linux-arm64-gnu': - specifier: catalog:repo - version: 0.38.0 - '@cipherstash/auth-linux-x64-gnu': - specifier: catalog:repo - version: 0.38.0 - '@cipherstash/auth-linux-x64-musl': - specifier: catalog:repo - version: 0.38.0 - '@cipherstash/auth-win32-x64-msvc': - specifier: catalog:repo - version: 0.38.0 devDependencies: '@cipherstash/stack': specifier: workspace:* @@ -284,6 +265,25 @@ importers: vitest: specifier: catalog:repo version: 3.2.4(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) + optionalDependencies: + '@cipherstash/auth-darwin-arm64': + specifier: catalog:repo + version: 0.38.0 + '@cipherstash/auth-darwin-x64': + specifier: catalog:repo + version: 0.38.0 + '@cipherstash/auth-linux-arm64-gnu': + specifier: catalog:repo + version: 0.38.0 + '@cipherstash/auth-linux-x64-gnu': + specifier: catalog:repo + version: 0.38.0 + '@cipherstash/auth-linux-x64-musl': + specifier: catalog:repo + version: 0.38.0 + '@cipherstash/auth-win32-x64-msvc': + specifier: catalog:repo + version: 0.38.0 packages/drizzle: dependencies: @@ -361,10 +361,6 @@ importers: next: specifier: ^14 || ^15 version: 15.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': - specifier: 4.60.4 - version: 4.60.4 devDependencies: '@clerk/nextjs': specifier: catalog:security @@ -381,6 +377,10 @@ importers: vitest: specifier: catalog:repo version: 3.2.4(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': + specifier: 4.60.4 + version: 4.60.4 packages/prisma-next: dependencies: @@ -478,10 +478,6 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': - specifier: 4.60.4 - version: 4.60.4 devDependencies: '@supabase/supabase-js': specifier: ^2.105.4 @@ -507,6 +503,10 @@ importers: vitest: specifier: catalog:repo version: 3.2.4(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': + specifier: 4.60.4 + version: 4.60.4 packages/protect-dynamodb: dependencies: @@ -558,8 +558,8 @@ importers: specifier: catalog:repo version: 0.38.0(@cipherstash/auth-darwin-arm64@0.38.0)(@cipherstash/auth-darwin-x64@0.38.0)(@cipherstash/auth-linux-arm64-gnu@0.38.0)(@cipherstash/auth-linux-x64-gnu@0.38.0)(@cipherstash/auth-linux-x64-musl@0.38.0)(@cipherstash/auth-win32-x64-msvc@0.38.0) '@cipherstash/protect-ffi': - specifier: 0.24.0 - version: 0.24.0 + specifier: 0.25.0 + version: 0.25.0 evlog: specifier: 1.11.0 version: 1.11.0(next@15.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) @@ -633,6 +633,22 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 + devDependencies: + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + tsup: + specifier: catalog:repo + version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.22.1)(typescript@5.9.3)(yaml@2.9.0) + tsx: + specifier: catalog:repo + version: 4.22.1 + typescript: + specifier: catalog:repo + version: 5.9.3 + vitest: + specifier: catalog:repo + version: 3.2.4(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) optionalDependencies: '@cipherstash/auth-darwin-arm64': specifier: catalog:repo @@ -652,22 +668,6 @@ importers: '@cipherstash/auth-win32-x64-msvc': specifier: catalog:repo version: 0.38.0 - devDependencies: - '@types/pg': - specifier: ^8.20.0 - version: 8.20.0 - tsup: - specifier: catalog:repo - version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.22.1)(typescript@5.9.3)(yaml@2.9.0) - tsx: - specifier: catalog:repo - version: 4.22.1 - typescript: - specifier: catalog:repo - version: 5.9.3 - vitest: - specifier: catalog:repo - version: 3.2.4(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) packages: @@ -685,21 +685,25 @@ packages: resolution: {integrity: sha512-9UeV1W2vjOVwJSJrq9aw3UeMo82Ir59FfJ5mchh7OXZEaevkANvHYn25bTCnIpqfqOx7qFEosJW2ELIoV1nprg==} cpu: [arm64] os: [linux] + libc: [musl] '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.143': resolution: {integrity: sha512-/9oP/FCewrPnwVN+QUS5rlO3kMa07w+hOrpWrz24aEpBYhcHzr0zoNMBriPDAkTr3ao/z1k40UZ2dHmgsSODzA==} cpu: [arm64] os: [linux] + libc: [glibc] '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.143': resolution: {integrity: sha512-rr4334GOLl9caYDeyWsbwMaVJCiNvKHE9nLdey8opIkq7/FHHu712U6tDk0tcoCdsGU/S3/BBaZParOgF+s5qw==} cpu: [x64] os: [linux] + libc: [musl] '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.143': resolution: {integrity: sha512-kwqnbHo4Zj6TzO1V/83uLhsTt0xBp/BN5V/aHIX+khM4UuNO6NOKNaZvr8Int3sF0ARF95Hjr4l/hMKxry6DhQ==} cpu: [x64] os: [linux] + libc: [glibc] '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.143': resolution: {integrity: sha512-q5UaLZ9ABbqQN8UXpqHUqjW6akI1zMrV5Jvtq0yueKP4nIRbBBZBQ80M4bpdrc0+SiRmjVRV3p8lsCCAd8azgg==} @@ -764,24 +768,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.15': resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.15': resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.15': resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.15': resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} @@ -867,16 +875,19 @@ packages: resolution: {integrity: sha512-ZF167YZRIl4+Geqi0+diShyV2VdWG14UfAsvP1ZPfrLOsNJn5wCK3tL9Mw90Q526zr6Yik/smbfrUrS69rHU6A==} cpu: [arm64] os: [linux] + libc: [glibc] '@cipherstash/auth-linux-x64-gnu@0.38.0': resolution: {integrity: sha512-xl1zMuANCtHMhfC77QBKULlfsbGMsGOqWTl5zD6NPn8lrM4tqDpaOdLwEbIo4EjbLSoA38IY9jxYB0qvlV0QQA==} cpu: [x64] os: [linux] + libc: [glibc] '@cipherstash/auth-linux-x64-musl@0.38.0': resolution: {integrity: sha512-rN4E+sOjZH7xLCV/NFOixceTMYqivnF+CyFqxJaUpmqW36vwwuTAuv8S93A+wOzn+A6W8HPwfkBWMmZenNUznQ==} cpu: [x64] os: [linux] + libc: [musl] '@cipherstash/auth-win32-x64-msvc@0.38.0': resolution: {integrity: sha512-cvnqgRL4sKeuJ7HvdLyLkwS59TW4FI9z/Fdreyv8Q78TEhjmG0HMXKdNeTW7AAATFYmzqJlmZX2RRa+QnUfhfQ==} @@ -911,8 +922,8 @@ packages: cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-arm64@0.24.0': - resolution: {integrity: sha512-86OyhIciDfLtiJN3+L7jdaXBf+V2XvM3NKYzGWoal2wv3mJKuUbdyTo27kmVd14+NeEoTmx6xYYtLevbCUTJGg==} + '@cipherstash/protect-ffi-darwin-arm64@0.25.0': + resolution: {integrity: sha512-VP19LCpaNG2mGlvyAVOjS4x+ldUiCw2MbUw2AunWzduxth5dsRgWv4XVTikeRTtPpVlMf7aIMm0T/J+ioU+O5g==} cpu: [arm64] os: [darwin] @@ -921,8 +932,8 @@ packages: cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.24.0': - resolution: {integrity: sha512-lDXBCUeGKO2bDTqIIqGhRasip5LBC0lQIn2QepwEuByugkXMGtmYMQmmwvMUhXKkp2keG7HAkeTniFSfcj6pYg==} + '@cipherstash/protect-ffi-darwin-x64@0.25.0': + resolution: {integrity: sha512-1AjRv8+uCfWVweC0tyqSm4EhGiETPYICD1hoZi9NUouWeMOaelYPy3/oC3GAhadbzSrCj+cESu8EJdqEgS6zFg==} cpu: [x64] os: [darwin] @@ -931,8 +942,8 @@ packages: cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.24.0': - resolution: {integrity: sha512-i6ufKc4vcVpMBuR9sdW09acULEg5FaTvpvbN5MCQ6XEF0iMkXM4/D7W4VnE1jHIJf1qgU2jE+T5oIlqp8Hnj3w==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.25.0': + resolution: {integrity: sha512-H1tTwSS0ow5T05uWzidNVgPDB2luJO6r/LC2AxX6Vveu472P3fbelANpBOzIB3RjJ5Q9Tje/UMkF4umIWg2JRA==} cpu: [arm64] os: [linux] @@ -941,8 +952,8 @@ packages: cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.24.0': - resolution: {integrity: sha512-zTa0IosxIo1qZInWSwCTns+TRjzOeuBGnMJg3OyK/q90I+RybaRROhMpbeWUV5QY7pLNwt6uICoghFJqI/Vh3Q==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.25.0': + resolution: {integrity: sha512-sfc8hTh8QPLw4oBM+LmEnRukgtrGRjLYG+dg4SuOlDCKFh0cFXrR42N3zz5ZV1WdNyjXay3pLo+sCpIg02bo9Q==} cpu: [x64] os: [linux] @@ -951,8 +962,8 @@ packages: cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-musl@0.24.0': - resolution: {integrity: sha512-OEs12fukiVgOxr/1Hn/23x4Tahl1gQG4pac9Uzr7Zz/O45365dK/4gazdmBpSsQYelESBiIEILGtw01QsmzLtw==} + '@cipherstash/protect-ffi-linux-x64-musl@0.25.0': + resolution: {integrity: sha512-QXlt+q+KvxR1WsRyryJLqagfyc26Edw6IXZkJJdc5kd9ZEN7IvmN3ZyRKKQzxwCqEf24SMeQKh9Ug+UZg4i4zA==} cpu: [x64] os: [linux] @@ -961,16 +972,16 @@ packages: cpu: [x64] os: [win32] - '@cipherstash/protect-ffi-win32-x64-msvc@0.24.0': - resolution: {integrity: sha512-g4JVGK2DYPc5zdzgBtYYgH5H8zQoV/DEfILKXLPlFfQFnGoDR8CQktXAdivgVwKWIXvOrmci+uuG6n8+tPZxSw==} + '@cipherstash/protect-ffi-win32-x64-msvc@0.25.0': + resolution: {integrity: sha512-9DD/Mik8nyVKBmIYKNafX+LZXnR2WAWB2518KE40CTeibY9NuO6sm6n9+eDbkot7E/VvSwMdDAAMj1q301mrrg==} cpu: [x64] os: [win32] '@cipherstash/protect-ffi@0.23.0': resolution: {integrity: sha512-Ca8MKLrrumC561VoPDOhuUZcF8C8YenqO1Ig9hSJSRUB+jFeIJXeyn7glExsvKYWtxOx/pRub9FV8A0RyuPHMg==} - '@cipherstash/protect-ffi@0.24.0': - resolution: {integrity: sha512-duYmf4kZsSJvdAjKuacXSO9qF9PFqaV9TU+2Yr0uy5FHdOw3G9dUCasZCnnnrDfnu92gJPxrsvZW6DMm0dbx+w==} + '@cipherstash/protect-ffi@0.25.0': + resolution: {integrity: sha512-TR4kJcIStAjfukBrRkMDxyPXUNY6ZkYdJg5QBQFCw+Sph9C1qEisq27yGu98yIAMTVgfn6473g16gAx/dHIqXQ==} '@clack/core@1.3.0': resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} @@ -1657,89 +1668,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1831,24 +1858,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.7': resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.7': resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.7': resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.7': resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} @@ -2055,131 +2086,157 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.60.4': resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.60.4': resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.60.4': resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-musl@4.60.4': resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-gnu@4.60.4': resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-musl@4.60.4': resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.60.4': resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-musl@4.60.4': resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.60.4': resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-musl@4.60.4': resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.60.4': resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-linux-x64-musl@4.60.4': resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -3167,24 +3224,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -4356,37 +4417,37 @@ snapshots: '@cipherstash/protect-ffi-darwin-arm64@0.23.0': optional: true - '@cipherstash/protect-ffi-darwin-arm64@0.24.0': + '@cipherstash/protect-ffi-darwin-arm64@0.25.0': optional: true '@cipherstash/protect-ffi-darwin-x64@0.23.0': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.24.0': + '@cipherstash/protect-ffi-darwin-x64@0.25.0': optional: true '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.24.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.25.0': optional: true '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.24.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.25.0': optional: true '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.24.0': + '@cipherstash/protect-ffi-linux-x64-musl@0.25.0': optional: true '@cipherstash/protect-ffi-win32-x64-msvc@0.23.0': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.24.0': + '@cipherstash/protect-ffi-win32-x64-msvc@0.25.0': optional: true '@cipherstash/protect-ffi@0.23.0': @@ -4400,16 +4461,16 @@ snapshots: '@cipherstash/protect-ffi-linux-x64-musl': 0.23.0 '@cipherstash/protect-ffi-win32-x64-msvc': 0.23.0 - '@cipherstash/protect-ffi@0.24.0': + '@cipherstash/protect-ffi@0.25.0': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.24.0 - '@cipherstash/protect-ffi-darwin-x64': 0.24.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.24.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.24.0 - '@cipherstash/protect-ffi-linux-x64-musl': 0.24.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.24.0 + '@cipherstash/protect-ffi-darwin-arm64': 0.25.0 + '@cipherstash/protect-ffi-darwin-x64': 0.25.0 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.25.0 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.25.0 + '@cipherstash/protect-ffi-linux-x64-musl': 0.25.0 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.25.0 '@clack/core@1.3.0': dependencies: From 2b9d376ce26bbc7bcb9ad07d4922ccae47daa3d7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 29 May 2026 18:44:32 +1000 Subject: [PATCH 2/6] feat(stack): add optional auth strategy to Encryption() protect-ffi 0.25 lets newClient take an AuthStrategy (any { getToken(): Promise<{ token }> } object). Expose it on the Node Encryption client via config.strategy: when supplied, getToken() is invoked on every ZeroKMS request, taking precedence over the credentials-derived default (clientKey is still used for encryption). Omitting it preserves existing credentials/env behaviour. Kept on init (rather than a separate initWithStrategy) so a future keyProvider option can land in the same config. AuthStrategy is re-exported from @cipherstash/stack for consumers to type their own. --- .../stack/__tests__/init-strategy.test.ts | 74 +++++++++++++++++++ packages/stack/src/encryption/index.ts | 12 +++ packages/stack/src/types-public.ts | 1 + packages/stack/src/types.ts | 28 +++++++ 4 files changed, 115 insertions(+) create mode 100644 packages/stack/__tests__/init-strategy.test.ts diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts new file mode 100644 index 00000000..5e397900 --- /dev/null +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -0,0 +1,74 @@ +/** + * Tests for the optional `config.strategy` auth strategy. + * + * protect-ffi 0.25 lets `newClient` take an `AuthStrategy` (any + * `{ getToken(): Promise<{ token }> }` object). `Encryption` exposes it + * via `config.strategy`; when provided it must reach `newClient` as + * `opts.strategy`, and when omitted the option must be absent so the + * default credentials-derived strategy is used. + */ +import { Encryption } from '@/index' +import { encryptedColumn, encryptedTable } from '@/schema' +import type { AuthStrategy } from '@/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), +})) + +import * as ffi from '@cipherstash/protect-ffi' + +const users = encryptedTable('users', { + email: encryptedColumn('email'), +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('Encryption config.strategy', () => { + it('forwards a supplied strategy to newClient', async () => { + const strategy: AuthStrategy = { + getToken: vi.fn(async () => ({ token: 'service-token' })), + } + + await Encryption({ schemas: [users], config: { strategy } }) + + // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args + const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any + expect(opts.strategy).toBe(strategy) + }) + + it('passes the strategy alongside the credential clientOpts', async () => { + const strategy: AuthStrategy = { + getToken: vi.fn(async () => ({ token: 'service-token' })), + } + + await Encryption({ + schemas: [users], + config: { + strategy, + workspaceCrn: 'crn:ap-southeast-2.aws:test-workspace', + clientId: 'client-id', + clientKey: 'client-key', + }, + }) + + // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args + const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any + expect(opts.strategy).toBe(strategy) + // clientKey is still required even when a strategy is supplied. + expect(opts.clientOpts.clientKey).toBe('client-key') + expect(opts.clientOpts.workspaceCrn).toBe( + 'crn:ap-southeast-2.aws:test-workspace', + ) + }) + + it('leaves strategy undefined when none is supplied', async () => { + await Encryption({ schemas: [users] }) + + // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args + const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any + expect(opts.strategy).toBeUndefined() + }) +}) diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index bad58542..1d223984 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -7,6 +7,7 @@ import { encryptConfigSchema, } from '@/schema' import type { + AuthStrategy, BulkDecryptPayload, BulkEncryptPayload, Client, @@ -62,6 +63,7 @@ export class EncryptionClient { clientId?: string clientKey?: string keyset?: KeysetIdentifier + strategy?: AuthStrategy }): Promise> { return await withResult( async () => { @@ -78,6 +80,11 @@ export class EncryptionClient { // newClient handles env var fallback internally via withEnvCredentials, // so we pass config values through without manual fallback here. + // When `strategy` is supplied, protect-ffi invokes its getToken() + // on every ZeroKMS request instead of building an AutoStrategy + // from the credentials in clientOpts (the clientKey is still used + // for encryption). Passing `strategy: undefined` is equivalent to + // omitting it, so the default credentials path is unaffected. this.client = await newClient({ encryptConfig: validated, clientOpts: { @@ -87,6 +94,7 @@ export class EncryptionClient { clientKey: config.clientKey, keyset: toFfiKeysetIdentifier(config.keyset), }, + strategy: config.strategy, }) this.encryptConfig = validated @@ -603,6 +611,10 @@ export class EncryptionClient { * columns to use. Credentials are read from the optional `config` or from the environment * (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`). * + * For custom token acquisition (service-to-service, edge runtimes, …) pass a `config.strategy` + * (e.g. `AccessKeyStrategy` from `@cipherstash/auth`); its `getToken()` is then used for every + * ZeroKMS request in place of the credentials-derived default. See {@link ClientConfig.strategy}. + * * @param config - Initialization options. Must include `schemas`; optionally include `config` for * workspace/keys. Logging is configured via the `STASH_STACK_LOG` environment variable * (`debug | info | error`, default: `error`). diff --git a/packages/stack/src/types-public.ts b/packages/stack/src/types-public.ts index 80b28985..ee42d5a6 100644 --- a/packages/stack/src/types-public.ts +++ b/packages/stack/src/types-public.ts @@ -15,6 +15,7 @@ export type { // Client configuration export type { + AuthStrategy, KeysetIdentifier, ClientConfig, EncryptionClientConfig, diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 87a71dea..2b1037ae 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -5,6 +5,7 @@ import type { EncryptedTableColumn, } from '@/schema' import type { + AuthStrategy, Encrypted as CipherStashEncrypted, EncryptedQuery as CipherStashEncryptedQuery, JsPlaintext, @@ -12,6 +13,17 @@ import type { newClient, } from '@cipherstash/protect-ffi' +/** + * A pluggable authentication strategy for ZeroKMS requests. Any object + * with a `getToken(): Promise<{ token: string }>` method satisfies it — + * notably `AccessKeyStrategy` from `@cipherstash/auth`. When supplied to + * {@link ClientConfig.strategy}, `getToken()` is invoked on every ZeroKMS + * request, taking precedence over the credentials-derived default. + * + * @see ClientConfig.strategy + */ +export type { AuthStrategy } + // --------------------------------------------------------------------------- // Branded type utilities // --------------------------------------------------------------------------- @@ -87,6 +99,22 @@ export type ClientConfig = { * Keysets are created and managed in the CipherStash dashboard. */ keyset?: KeysetIdentifier + + /** + * An optional authentication strategy for ZeroKMS requests, e.g. + * `AccessKeyStrategy` from `@cipherstash/auth`. When provided, its + * `getToken()` is invoked on every ZeroKMS request and takes + * precedence over the credentials-derived default strategy (the + * `clientKey` is still required). Use this to plug in custom token + * acquisition / caching (service-to-service, edge runtimes, …). + * + * Leave unset to let the client build its default strategy from + * `workspaceCrn` / `accessKey` / `clientId` / `clientKey` (or the + * corresponding `CS_*` environment variables). + * + * @see {@link AuthStrategy} + */ + strategy?: AuthStrategy } type AtLeastOneCsTable = [T, ...T[]] From f1cae338b9d0ae3821b65b12bffdc86d8bb0103c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 8 Jun 2026 16:19:43 +0800 Subject: [PATCH 3/6] feat(stack): protect-ffi 0.26 + auth 0.39 OidcFederationStrategy; replace lock-context ceremony MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the 0.25.0 bump with protect-ffi 0.26.0 (API-identical; internal fixes only) and @cipherstash/auth 0.39.0, and uses the new OidcFederationStrategy to replace the lock-context token ceremony with a strategy-based approach for identity-bound encryption. - bump @cipherstash/protect-ffi 0.25.0 -> 0.26.0; @cipherstash/auth catalog (and platform entries) 0.38.0 -> 0.39.0; e2e/wasm/deno.json pins; lockfile - .withLockContext() now accepts a plain { identityClaim } (or a LockContext) and resolves the claim synchronously — no CTS token, no identify() call - deprecate LockContext.identify() / getLockContext(); the client strategy (OidcFederationStrategy) now handles user token acquisition - re-export OidcFederationStrategy/AccessKeyStrategy/AutoStrategy/ DeviceSessionStrategy from @cipherstash/stack, and the strategies from @cipherstash/stack/wasm-inline - broaden the wasm-inline config strategy type to accept OidcFederationStrategy - declare @cipherstash/auth platform optionalDependencies (auth ships them as optional peer deps, not auto-installed) so the re-exported Node strategies resolve their native binding for consumers - update wiring/init/live tests, JSDoc, AGENTS.md, README, changeset --- .../stack-protect-ffi-0-25-wasm-inline.md | 12 -- .../stack-protect-ffi-0-26-oidc-strategy.md | 32 +++ AGENTS.md | 2 +- README.md | 2 +- e2e/wasm/deno.json | 4 +- .../stack/__tests__/init-strategy.test.ts | 34 +++- .../__tests__/lock-context-wiring.test.ts | 45 +++-- packages/stack/__tests__/lock-context.test.ts | 173 +++++++---------- packages/stack/package.json | 11 +- .../src/encryption/helpers/model-helpers.ts | 18 +- packages/stack/src/encryption/index.ts | 26 ++- .../operations/batch-encrypt-query.ts | 20 +- .../operations/bulk-decrypt-models.ts | 16 +- .../src/encryption/operations/bulk-decrypt.ts | 19 +- .../operations/bulk-encrypt-models.ts | 16 +- .../src/encryption/operations/bulk-encrypt.ts | 19 +- .../encryption/operations/decrypt-model.ts | 19 +- .../src/encryption/operations/decrypt.ts | 16 +- .../encryption/operations/encrypt-model.ts | 19 +- .../encryption/operations/encrypt-query.ts | 14 +- .../src/encryption/operations/encrypt.ts | 16 +- packages/stack/src/identity/index.ts | 103 ++++++---- packages/stack/src/index.ts | 13 ++ packages/stack/src/types.ts | 27 ++- packages/stack/src/wasm-inline.ts | 73 +++++-- pnpm-lock.yaml | 182 +++++++++--------- pnpm-workspace.yaml | 14 +- 27 files changed, 534 insertions(+), 411 deletions(-) delete mode 100644 .changeset/stack-protect-ffi-0-25-wasm-inline.md create mode 100644 .changeset/stack-protect-ffi-0-26-oidc-strategy.md diff --git a/.changeset/stack-protect-ffi-0-25-wasm-inline.md b/.changeset/stack-protect-ffi-0-25-wasm-inline.md deleted file mode 100644 index b4f03ea4..00000000 --- a/.changeset/stack-protect-ffi-0-25-wasm-inline.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@cipherstash/stack": minor ---- - -Bump `@cipherstash/protect-ffi` to `0.25.0` and align the WASM-inline path with its API. - -protect-ffi `0.25.0` is a breaking release for both entries: - -- **WASM (`@cipherstash/stack/wasm-inline`)**: `newClient` now takes a single options object with the auth strategy nested under `strategy` (was a separate first argument). The WASM `Encryption()` config now takes a **`workspaceCrn`** instead of a `region` — the CRN is the single source of truth for workspace identity, and the `AccessKeyStrategy` region is derived from it (`crn::`). `CS_REGION` is no longer consulted; set `CS_WORKSPACE_CRN`. This matches protect-ffi `0.25`, which dropped `CS_REGION` in favour of `CS_WORKSPACE_CRN`. -- **Node**: `serviceToken` was removed from the encrypt / decrypt / query option types (and the `CtsToken` export). The per-operation CTS token is no longer forwarded — auth flows through the client's strategy / credentials, while lock contexts continue to travel as `lockContext.identityClaim`. The public `LockContext` / `identify()` API is unchanged. - -Also adds an optional **`config.strategy`** to `Encryption()` (Node): pass an `AuthStrategy` — any `{ getToken(): Promise<{ token }> }`-shaped object, e.g. `AccessKeyStrategy` from `@cipherstash/auth` — and its `getToken()` is invoked on every ZeroKMS request, taking precedence over the credentials-derived default (the `clientKey` is still used for encryption). Omitting it preserves the existing credentials / env behaviour. `AuthStrategy` is re-exported from `@cipherstash/stack`. diff --git a/.changeset/stack-protect-ffi-0-26-oidc-strategy.md b/.changeset/stack-protect-ffi-0-26-oidc-strategy.md new file mode 100644 index 00000000..544daa5d --- /dev/null +++ b/.changeset/stack-protect-ffi-0-26-oidc-strategy.md @@ -0,0 +1,32 @@ +--- +"@cipherstash/stack": minor +--- + +Bump `@cipherstash/protect-ffi` to `0.26.0` and `@cipherstash/auth` to `0.39.0`, and replace the lock-context token ceremony with a strategy-based approach for identity-bound encryption. + +**protect-ffi `0.26.0`** supersedes `0.25.0`. The public API is unchanged from `0.25` (internal fixes only). As in `0.25`, `serviceToken` is gone from the encrypt / decrypt / query option types; auth flows through the client's strategy / credentials, and lock contexts travel as `lockContext.identityClaim`. The WASM-inline path takes a single options object with the auth strategy nested under `strategy`, and `Encryption()` config uses **`workspaceCrn`** (`CS_WORKSPACE_CRN`) as the single source of truth — `CS_REGION` is no longer consulted. + +**Strategy-based, identity-bound encryption.** `@cipherstash/auth` `0.39` adds `OidcFederationStrategy`, which federates an end user's third-party OIDC JWT (Clerk, Supabase, Auth0, …) into a CTS service token. Pass it as `config.strategy` so every ZeroKMS request authenticates *as that user*, then bind the data key to a claim with `.withLockContext({ identityClaim })`: + +```ts +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" + +const client = await Encryption({ + schemas: [users], + config: { + strategy: OidcFederationStrategy.create(region, workspaceId, () => getUserJwt()), + }, +}) + +await client + .encrypt("alice@example.com", { column: users.email, table: users }) + .withLockContext({ identityClaim: ["sub"] }) +``` + +This replaces the old ceremony (`new LockContext()` → `await lc.identify(jwt)` → `.withLockContext(lc)`), which relied on a per-operation CTS token that protect-ffi removed in `0.25`. + +- **`.withLockContext()`** now accepts a plain `{ identityClaim }` object (as well as a `LockContext`) and no longer requires a CTS token or an `identify()` call — it carries the identity claim only. +- **`LockContext.identify()` / `getLockContext()`** are **deprecated** (kept for backwards compatibility); the strategy handles token acquisition. +- **Strategies are re-exported** from `@cipherstash/stack` (`OidcFederationStrategy`, `AccessKeyStrategy`, `AutoStrategy`, `DeviceSessionStrategy`) and from `@cipherstash/stack/wasm-inline` (`OidcFederationStrategy`, `AccessKeyStrategy`) so integrators don't need a separate `@cipherstash/auth` install. `AuthStrategy` remains re-exported for the structural type. + +Existing credential / env behaviour is preserved when `config.strategy` is omitted. diff --git a/AGENTS.md b/AGENTS.md index 7177f92c..d242ff53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Three rules to remember when editing CI or pnpm config: - `bulkEncryptModels(models[], table)` / `bulkDecryptModels(models[])` - `encryptQuery(value, { table, column, queryType?, returnType? })` for searchable queries - `encryptQuery(terms[])` for batch query encryption -- **Identity-aware encryption**: Use `LockContext` from `@cipherstash/stack/identity` and chain `.withLockContext()` on operations. Same context must be used for both encrypt and decrypt. +- **Identity-aware encryption**: Authenticate the client as the end user with `OidcFederationStrategy` (`config.strategy`, re-exported from `@cipherstash/stack`), then chain `.withLockContext({ identityClaim })` on operations to bind the data key to a claim. The same claim must be used for encrypt and decrypt. (`LockContext.identify()` from `@cipherstash/stack/identity` is deprecated — the strategy now handles token acquisition; `.withLockContext()` also accepts a `LockContext`.) - **Integrations**: - **Drizzle ORM**: `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack/drizzle` - **Supabase**: `encryptedSupabase` from `@cipherstash/stack/supabase` diff --git a/README.md b/README.md index 4868de45..0753d24e 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ bun add @cipherstash/stack - **[Searchable encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption)**: query encrypted data with equality, free text search, range, and [JSONB queries](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption#jsonb-queries-with-searchablejson). - **[Type-safe schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema)**: define encrypted tables and columns with `encryptedTable` / `encryptedColumn` - **[Model & bulk operations](https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt#model-operations)**: encrypt and decrypt entire objects or batches with `encryptModel` / `bulkEncryptModels`. -- **[Identity-aware encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/identity)**: bind encryption to user identity with lock contexts for policy-based access control. +- **[Identity-aware encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/identity)**: authenticate as the end user with `OidcFederationStrategy` and bind the data key to their identity with `.withLockContext({ identityClaim })` for policy-based access control. ## Integrations diff --git a/e2e/wasm/deno.json b/e2e/wasm/deno.json index 6e3b00d7..12b62bb8 100644 --- a/e2e/wasm/deno.json +++ b/e2e/wasm/deno.json @@ -9,7 +9,7 @@ }, "imports": { "@cipherstash/stack/wasm-inline": "../../packages/stack/dist/wasm-inline.js", - "@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.25.0/wasm-inline", - "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.38.0/wasm-inline" + "@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.26.0/wasm-inline", + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.39.0/wasm-inline" } } diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index 5e397900..8057b931 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -1,11 +1,13 @@ /** * Tests for the optional `config.strategy` auth strategy. * - * protect-ffi 0.25 lets `newClient` take an `AuthStrategy` (any - * `{ getToken(): Promise<{ token }> }` object). `Encryption` exposes it - * via `config.strategy`; when provided it must reach `newClient` as - * `opts.strategy`, and when omitted the option must be absent so the - * default credentials-derived strategy is used. + * protect-ffi (0.25+) lets `newClient` take an `AuthStrategy` (any + * `{ getToken(): Promise<{ token }> }` object — the shape every + * `@cipherstash/auth` strategy satisfies, including + * `OidcFederationStrategy` for per-user identity-bound encryption). + * `Encryption` exposes it via `config.strategy`; when provided it must + * reach `newClient` as `opts.strategy`, and when omitted the option must + * be absent so the default credentials-derived strategy is used. */ import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -64,6 +66,28 @@ describe('Encryption config.strategy', () => { ) }) + it('forwards an OidcFederationStrategy-shaped strategy to newClient', async () => { + // Mirror `OidcFederationStrategy`'s public shape (getToken returning a + // TokenResult) without loading the native `@cipherstash/auth` binding — + // `Encryption` forwards the object opaquely, so its concrete type is + // irrelevant to this wiring. + const oidcStrategy: AuthStrategy = { + getToken: vi.fn(async () => ({ + token: 'cts-service-token', + subject: 'CS|auth0|user123', + workspaceId: 'test-workspace', + issuer: 'https://cts.example', + services: { zerokms: 'https://zerokms.example' }, + })), + } + + await Encryption({ schemas: [users], config: { strategy: oidcStrategy } }) + + // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args + const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any + expect(opts.strategy).toBe(oidcStrategy) + }) + it('leaves strategy undefined when none is supplied', async () => { await Encryption({ schemas: [users] }) diff --git a/packages/stack/__tests__/lock-context-wiring.test.ts b/packages/stack/__tests__/lock-context-wiring.test.ts index 201ce2e4..cd856b0a 100644 --- a/packages/stack/__tests__/lock-context-wiring.test.ts +++ b/packages/stack/__tests__/lock-context-wiring.test.ts @@ -2,12 +2,14 @@ * Offline wiring tests for the lock-context path. * * protect-ffi 0.25 removed the per-operation `serviceToken` option — the - * CTS token is no longer forwarded; lock contexts travel as - * `lockContext.identityClaim` only. The live `lock-context.test.ts` - * exercises a real CTS round-trip but skips without a `USER_JWT`, so it - * can't guard this wiring in CI. These tests mock `@cipherstash/protect-ffi` - * and assert, for every operation, that: - * 1. the lock context's `identityClaim` reaches protect-ffi, and + * CTS token is no longer forwarded. Auth now flows through the client + * strategy (e.g. `OidcFederationStrategy`); the lock context carries only + * `identityClaim`, supplied per-operation with no token and no `identify()` + * call. The live `lock-context.test.ts` exercises a real CTS round-trip but + * skips without a `USER_JWT`, so it can't guard this wiring in CI. These + * tests mock `@cipherstash/protect-ffi` and assert, for every operation, that: + * 1. the `identityClaim` reaches protect-ffi — whether supplied as a + * `LockContext` or as a plain `{ identityClaim }` object, and * 2. no `serviceToken` is ever passed (the removed field must not creep * back in). */ @@ -48,13 +50,10 @@ const users = encryptedTable('users', { const IDENTITY_CLAIM = { identityClaim: ['sub'] } -// Build a lock context without a network round-trip: a pre-supplied CTS -// token short-circuits `identify()`, and the default context is -// `{ identityClaim: ['sub'] }`. -const lockCtx = () => - new LockContext({ - ctsToken: { accessToken: 'test-cts-token', expiry: 9_999_999_999 }, - }) +// A lock context needs no network round-trip or CTS token: the default +// context is `{ identityClaim: ['sub'] }` and `.withLockContext()` reads it +// synchronously. Operations also accept a plain `{ identityClaim }` object. +const lockCtx = () => new LockContext() /** Deep scan for a `serviceToken` key anywhere in a value. */ function hasServiceToken(value: unknown): boolean { @@ -99,6 +98,26 @@ describe('lock-context wiring: identityClaim forwarded, serviceToken never sent' expect(hasServiceToken(opts)).toBe(false) }) + it('encrypt accepts a plain { identityClaim } object (no LockContext needed)', async () => { + unwrap( + await client + .encrypt('alice@example.com', { column: users.email, table: users }) + .withLockContext({ identityClaim: ['sub'] }), + ) + + const opts = lastOpts(ffi.encrypt) + expect(opts.lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) + + it('decrypt accepts a plain { identityClaim } object and omits serviceToken', async () => { + unwrap(await client.decrypt(enc()).withLockContext({ identityClaim: ['sub'] })) + + const opts = lastOpts(ffi.decrypt) + expect(opts.lockContext).toEqual(IDENTITY_CLAIM) + expect(hasServiceToken(opts)).toBe(false) + }) + it('encrypt without a lock context sends neither lockContext nor serviceToken', async () => { unwrap( await client.encrypt('alice@example.com', { diff --git a/packages/stack/__tests__/lock-context.test.ts b/packages/stack/__tests__/lock-context.test.ts index 3a37c483..162d0106 100644 --- a/packages/stack/__tests__/lock-context.test.ts +++ b/packages/stack/__tests__/lock-context.test.ts @@ -1,55 +1,79 @@ import 'dotenv/config' -import { LockContext } from '@/identity' +import { OidcFederationStrategy } from '@cipherstash/auth' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' -import { beforeAll, describe, expect, it } from 'vitest' - +import { describe, expect, it } from 'vitest' + +/** + * Live, identity-bound encryption round-trips (gated on `USER_JWT`). + * + * This exercises the strategy-based replacement for the old LockContext + * ceremony: the client authenticates as the end user via + * `OidcFederationStrategy` (federating their OIDC JWT into a CTS service + * token), and the data key is bound to the user's `sub` claim with a plain + * `.withLockContext({ identityClaim })` — no `identify()`, no CTS token + * passed by hand. Decrypting without the same claim must fail. + * + * Requires `USER_JWT` plus `CS_WORKSPACE_CRN` / `CS_CLIENT_ID` / + * `CS_CLIENT_KEY`; skips silently if `USER_JWT` is absent. + */ const users = encryptedTable('users', { email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(), address: encryptedColumn('address').freeTextSearch(), }) -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - number?: number +const IDENTITY_CLAIM = { identityClaim: ['sub'] } + +/** Split `crn::` into its region and workspace id. */ +function regionAndWorkspaceFromCrn(crn: string): { + region: string + workspaceId: string +} { + const match = crn.match(/^crn:([^:]+):(.+)$/) + if (!match) { + throw new Error( + `CS_WORKSPACE_CRN must look like "crn::", got "${crn}"`, + ) + } + return { region: match[1], workspaceId: match[2] } } -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await Encryption({ +/** + * Build an encryption client that authenticates every ZeroKMS request as + * the end user behind `userJwt`. `getJwt` returns the *current* JWT and is + * re-invoked on every (re-)federation — here the value is constant. + */ +async function userClient(userJwt: string) { + const crn = process.env.CS_WORKSPACE_CRN + if (!crn) { + throw new Error('CS_WORKSPACE_CRN must be set for the lock-context tests') + } + const { region, workspaceId } = regionAndWorkspaceFromCrn(crn) + + return Encryption({ schemas: [users], + config: { + strategy: OidcFederationStrategy.create(region, workspaceId, () => + Promise.resolve(userJwt), + ), + }, }) -}) +} -describe('encryption and decryption with lock context', () => { - it('should encrypt and decrypt a payload with lock context', async () => { +describe('identity-bound encryption via OidcFederationStrategy + lock context', () => { + it('should encrypt and decrypt a payload bound to the user identity', async () => { const userJwt = process.env.USER_JWT - if (!userJwt) { console.log('Skipping lock context test - no USER_JWT provided') return } - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - + const protectClient = await userClient(userJwt) const email = 'hello@example.com' const ciphertext = await protectClient - .encrypt(email, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data) + .encrypt(email, { column: users.email, table: users }) + .withLockContext(IDENTITY_CLAIM) if (ciphertext.failure) { throw new Error(`[protect]: ${ciphertext.failure.message}`) @@ -57,7 +81,7 @@ describe('encryption and decryption with lock context', () => { const plaintext = await protectClient .decrypt(ciphertext.data) - .withLockContext(lockContext.data) + .withLockContext(IDENTITY_CLAIM) if (plaintext.failure) { throw new Error(`[protect]: ${plaintext.failure.message}`) @@ -66,81 +90,54 @@ describe('encryption and decryption with lock context', () => { expect(plaintext.data).toEqual(email) }, 30000) - it('should encrypt and decrypt a model with lock context', async () => { + it('should encrypt and decrypt a model bound to the user identity', async () => { const userJwt = process.env.USER_JWT - if (!userJwt) { console.log('Skipping lock context test - no USER_JWT provided') return } - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } + const protectClient = await userClient(userJwt) + const decryptedModel = { id: '1', email: 'plaintext' } - // Encrypt the model with lock context const encryptedModel = await protectClient .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) + .withLockContext(IDENTITY_CLAIM) if (encryptedModel.failure) { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } - // Decrypt the model with lock context const decryptedResult = await protectClient .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) + .withLockContext(IDENTITY_CLAIM) if (decryptedResult.failure) { throw new Error(`[protect]: ${decryptedResult.failure.message}`) } - expect(decryptedResult.data).toEqual({ - id: '1', - email: 'plaintext', - }) + expect(decryptedResult.data).toEqual({ id: '1', email: 'plaintext' }) }, 30000) - it('should encrypt with context and be unable to decrypt without context', async () => { + it('should encrypt with context and be unable to decrypt without it', async () => { const userJwt = process.env.USER_JWT - if (!userJwt) { console.log('Skipping lock context test - no USER_JWT provided') return } - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) + const protectClient = await userClient(userJwt) + const decryptedModel = { id: '1', email: 'plaintext' } - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } - - // Encrypt the model with lock context const encryptedModel = await protectClient .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) + .withLockContext(IDENTITY_CLAIM) if (encryptedModel.failure) { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Decrypting without the identity claim cannot reproduce the key tag. try { await protectClient.decryptModel(encryptedModel.data) } catch (error) { @@ -149,60 +146,38 @@ describe('encryption and decryption with lock context', () => { } }, 30000) - it('should bulk encrypt and decrypt models with lock context', async () => { + it('should bulk encrypt and decrypt models bound to the user identity', async () => { const userJwt = process.env.USER_JWT - if (!userJwt) { console.log('Skipping lock context test - no USER_JWT provided') return } - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create models with decrypted values + const protectClient = await userClient(userJwt) const decryptedModels = [ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, + { id: '1', email: 'test' }, + { id: '2', email: 'test2' }, ] - // Encrypt the models with lock context const encryptedModels = await protectClient .bulkEncryptModels(decryptedModels, users) - .withLockContext(lockContext.data) + .withLockContext(IDENTITY_CLAIM) if (encryptedModels.failure) { throw new Error(`[protect]: ${encryptedModels.failure.message}`) } - // Decrypt the models with lock context const decryptedResult = await protectClient .bulkDecryptModels(encryptedModels.data) - .withLockContext(lockContext.data) + .withLockContext(IDENTITY_CLAIM) if (decryptedResult.failure) { throw new Error(`[protect]: ${decryptedResult.failure.message}`) } expect(decryptedResult.data).toEqual([ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, + { id: '1', email: 'test' }, + { id: '2', email: 'test2' }, ]) }, 30000) }) diff --git a/packages/stack/package.json b/packages/stack/package.json index bce96133..a97030e0 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -214,11 +214,20 @@ "dependencies": { "@byteslice/result": "0.2.0", "@cipherstash/auth": "catalog:repo", - "@cipherstash/protect-ffi": "0.25.0", + "@cipherstash/protect-ffi": "0.26.0", "evlog": "1.11.0", "uuid": "14.0.0", "zod": "3.25.76" }, + "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. Required because @cipherstash/stack re-exports the Node auth strategies (OidcFederationStrategy, AccessKeyStrategy, …). All seven names share a single catalog entry to keep them in lockstep.", + "optionalDependencies": { + "@cipherstash/auth-darwin-arm64": "catalog:repo", + "@cipherstash/auth-darwin-x64": "catalog:repo", + "@cipherstash/auth-linux-arm64-gnu": "catalog:repo", + "@cipherstash/auth-linux-x64-gnu": "catalog:repo", + "@cipherstash/auth-linux-x64-musl": "catalog:repo", + "@cipherstash/auth-win32-x64-msvc": "catalog:repo" + }, "peerDependencies": { "@supabase/supabase-js": ">=2", "drizzle-orm": ">=0.33" diff --git a/packages/stack/src/encryption/helpers/model-helpers.ts b/packages/stack/src/encryption/helpers/model-helpers.ts index 710b5bdc..16df94eb 100644 --- a/packages/stack/src/encryption/helpers/model-helpers.ts +++ b/packages/stack/src/encryption/helpers/model-helpers.ts @@ -1,6 +1,6 @@ import { isEncryptedPayload } from '@/encryption/helpers' import type { AuditData } from '@/encryption/operations/base-operation' -import type { GetLockContextResponse } from '@/identity' +import type { Context } from '@/identity' import type { EncryptedTable, EncryptedTableColumn } from '@/schema' import type { Client, Decrypted, Encrypted } from '@/types' import { @@ -382,7 +382,7 @@ export async function decryptModelFieldsWithLockContext< >( model: T, client: Client, - lockContext: GetLockContextResponse, + lockContext: Context, auditData?: AuditData, ): Promise> { if (!client) { @@ -400,7 +400,7 @@ export async function decryptModelFieldsWithLockContext< ([key, value]) => ({ id: key, ciphertext: value as CipherStashEncrypted, - lockContext: lockContext.context, + lockContext, }), ) @@ -439,7 +439,7 @@ export async function encryptModelFieldsWithLockContext( model: Record, table: EncryptedTable, client: Client, - lockContext: GetLockContextResponse, + lockContext: Context, auditData?: AuditData, ): Promise> { if (!client) { @@ -459,7 +459,7 @@ export async function encryptModelFieldsWithLockContext( plaintext: value as string, table: table.tableName, column: key, - lockContext: lockContext.context, + lockContext, }), ) @@ -762,7 +762,7 @@ export async function bulkDecryptModelsWithLockContext< >( models: T[], client: Client, - lockContext: GetLockContextResponse, + lockContext: Context, auditData?: AuditData, ): Promise[]> { if (!client) { @@ -780,7 +780,7 @@ export async function bulkDecryptModelsWithLockContext< Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, ciphertext: value as CipherStashEncrypted, - lockContext: lockContext.context, + lockContext, })), ) @@ -833,7 +833,7 @@ export async function bulkEncryptModelsWithLockContext( models: Record[], table: EncryptedTable, client: Client, - lockContext: GetLockContextResponse, + lockContext: Context, auditData?: AuditData, ): Promise[]> { if (!client) { @@ -853,7 +853,7 @@ export async function bulkEncryptModelsWithLockContext( plaintext: value as string, table: table.tableName, column: key, - lockContext: lockContext.context, + lockContext, })), ) diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 1d223984..bb64b9bd 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -611,9 +611,11 @@ export class EncryptionClient { * columns to use. Credentials are read from the optional `config` or from the environment * (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`). * - * For custom token acquisition (service-to-service, edge runtimes, …) pass a `config.strategy` - * (e.g. `AccessKeyStrategy` from `@cipherstash/auth`); its `getToken()` is then used for every - * ZeroKMS request in place of the credentials-derived default. See {@link ClientConfig.strategy}. + * Pass a `config.strategy` to control how ZeroKMS requests are authenticated; its `getToken()` + * is then used for every request in place of the credentials-derived default. Use + * `OidcFederationStrategy` for per-user, identity-bound encryption (federates an end user's OIDC + * JWT into a CTS service token) or `AccessKeyStrategy` for service-to-service / CI. Both are + * re-exported from `@cipherstash/stack`. See {@link ClientConfig.strategy}. * * @param config - Initialization options. Must include `schemas`; optionally include `config` for * workspace/keys. Logging is configured via the `STASH_STACK_LOG` environment variable @@ -635,6 +637,24 @@ export class EncryptionClient { * const result = await client.encrypt("alice@example.com", { column: users.email, table: users }) * ``` * + * @example Per-user, identity-bound encryption + * ```typescript + * import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" + * + * // Authenticate every ZeroKMS request as the signed-in user. + * const client = await Encryption({ + * schemas: [users], + * config: { + * strategy: OidcFederationStrategy.create(region, workspaceId, () => getUserJwt()), + * }, + * }) + * + * // Bind the data key to the user's `sub` claim. + * const result = await client + * .encrypt("alice@example.com", { column: users.email, table: users }) + * .withLockContext({ identityClaim: ["sub"] }) + * ``` + * * @see {@link EncryptionClientConfig} for full config options. * @see {@link EncryptionClient} for available methods after initialization. */ diff --git a/packages/stack/src/encryption/operations/batch-encrypt-query.ts b/packages/stack/src/encryption/operations/batch-encrypt-query.ts index 7eb770b0..f7afbacc 100644 --- a/packages/stack/src/encryption/operations/batch-encrypt-query.ts +++ b/packages/stack/src/encryption/operations/batch-encrypt-query.ts @@ -1,7 +1,11 @@ import { formatEncryptedResult } from '@/encryption/helpers' import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' -import type { Context, LockContext } from '@/identity' +import { + type Context, + type LockContextInput, + resolveLockContext, +} from '@/identity' import type { Client, EncryptedQueryResult, ScalarQueryTerm } from '@/types' import { createRequestLogger } from '@/utils/logger' import { type Result, withResult } from '@byteslice/result' @@ -105,7 +109,7 @@ export class BatchEncryptQueryOperation extends EncryptionOperation< } public withLockContext( - lockContext: LockContext, + lockContext: LockContextInput, ): BatchEncryptQueryOperationWithLockContext { return new BatchEncryptQueryOperationWithLockContext( this.client, @@ -177,7 +181,7 @@ export class BatchEncryptQueryOperationWithLockContext extends EncryptionOperati constructor( private client: Client, private terms: readonly ScalarQueryTerm[], - private lockContext: LockContext, + private lockContext: LockContextInput, auditMetadata?: Record, ) { super() @@ -199,8 +203,6 @@ export class BatchEncryptQueryOperationWithLockContext extends EncryptionOperati return { data: [] } } - // Check for all-null terms BEFORE fetching lockContext to avoid an - // unnecessary network call. const { nonNullTerms } = filterNullTerms(this.terms) if (nonNullTerms.length === 0) { @@ -208,13 +210,7 @@ export class BatchEncryptQueryOperationWithLockContext extends EncryptionOperati return { data: this.terms.map(() => null) } } - const lockContextResult = await this.lockContext.getLockContext() - if (lockContextResult.failure) { - log.emit() - return { failure: lockContextResult.failure } - } - - const { context } = lockContextResult.data + const context = resolveLockContext(this.lockContext) const result = await withResult( async () => { diff --git a/packages/stack/src/encryption/operations/bulk-decrypt-models.ts b/packages/stack/src/encryption/operations/bulk-decrypt-models.ts index 56777362..d038a021 100644 --- a/packages/stack/src/encryption/operations/bulk-decrypt-models.ts +++ b/packages/stack/src/encryption/operations/bulk-decrypt-models.ts @@ -1,6 +1,6 @@ import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' -import type { LockContext } from '@/identity' +import { type LockContextInput, resolveLockContext } from '@/identity' import type { Client, Decrypted } from '@/types' import { createRequestLogger } from '@/utils/logger' import { type Result, withResult } from '@byteslice/result' @@ -24,7 +24,7 @@ export class BulkDecryptModelsOperation< } public withLockContext( - lockContext: LockContext, + lockContext: LockContextInput, ): BulkDecryptModelsOperationWithLockContext { return new BulkDecryptModelsOperationWithLockContext(this, lockContext) } @@ -75,11 +75,11 @@ export class BulkDecryptModelsOperationWithLockContext< T extends Record, > extends EncryptionOperation[]> { private operation: BulkDecryptModelsOperation - private lockContext: LockContext + private lockContext: LockContextInput constructor( operation: BulkDecryptModelsOperation, - lockContext: LockContext, + lockContext: LockContextInput, ) { super() this.operation = operation @@ -106,18 +106,14 @@ export class BulkDecryptModelsOperationWithLockContext< throw noClientError() } - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[encryption]: ${context.failure.message}`) - } + const context = resolveLockContext(this.lockContext) const auditData = this.getAuditData() return await bulkDecryptModelsWithLockContext( models, client, - context.data, + context, auditData, ) }, diff --git a/packages/stack/src/encryption/operations/bulk-decrypt.ts b/packages/stack/src/encryption/operations/bulk-decrypt.ts index 0cfc5204..d3325706 100644 --- a/packages/stack/src/encryption/operations/bulk-decrypt.ts +++ b/packages/stack/src/encryption/operations/bulk-decrypt.ts @@ -1,6 +1,10 @@ import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' -import type { Context, LockContext } from '@/identity' +import { + type Context, + type LockContextInput, + resolveLockContext, +} from '@/identity' import type { BulkDecryptPayload, BulkDecryptedData, Client } from '@/types' import { createRequestLogger } from '@/utils/logger' import { type Result, withResult } from '@byteslice/result' @@ -65,7 +69,7 @@ export class BulkDecryptOperation extends EncryptionOperation } public withLockContext( - lockContext: LockContext, + lockContext: LockContextInput, ): BulkDecryptOperationWithLockContext { return new BulkDecryptOperationWithLockContext(this, lockContext) } @@ -125,9 +129,9 @@ export class BulkDecryptOperation extends EncryptionOperation export class BulkDecryptOperationWithLockContext extends EncryptionOperation { private operation: BulkDecryptOperation - private lockContext: LockContext + private lockContext: LockContextInput - constructor(operation: BulkDecryptOperation, lockContext: LockContext) { + constructor(operation: BulkDecryptOperation, lockContext: LockContextInput) { super() this.operation = operation this.lockContext = lockContext @@ -152,14 +156,11 @@ export class BulkDecryptOperationWithLockContext extends EncryptionOperation { return new BulkEncryptModelsOperationWithLockContext(this, lockContext) } @@ -90,11 +90,11 @@ export class BulkEncryptModelsOperationWithLockContext< T extends Record, > extends EncryptionOperation { private operation: BulkEncryptModelsOperation - private lockContext: LockContext + private lockContext: LockContextInput constructor( operation: BulkEncryptModelsOperation, - lockContext: LockContext, + lockContext: LockContextInput, ) { super() this.operation = operation @@ -122,11 +122,7 @@ export class BulkEncryptModelsOperationWithLockContext< throw noClientError() } - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[encryption]: ${context.failure.message}`) - } + const context = resolveLockContext(this.lockContext) const auditData = this.getAuditData() @@ -134,7 +130,7 @@ export class BulkEncryptModelsOperationWithLockContext< models, table, client, - context.data, + context, auditData, )) as T[] }, diff --git a/packages/stack/src/encryption/operations/bulk-encrypt.ts b/packages/stack/src/encryption/operations/bulk-encrypt.ts index 61d3dc24..7e142ff9 100644 --- a/packages/stack/src/encryption/operations/bulk-encrypt.ts +++ b/packages/stack/src/encryption/operations/bulk-encrypt.ts @@ -1,6 +1,10 @@ import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' -import type { Context, LockContext } from '@/identity' +import { + type Context, + type LockContextInput, + resolveLockContext, +} from '@/identity' import type { EncryptedColumn, EncryptedField, @@ -80,7 +84,7 @@ export class BulkEncryptOperation extends EncryptionOperation } public withLockContext( - lockContext: LockContext, + lockContext: LockContextInput, ): BulkEncryptOperationWithLockContext { return new BulkEncryptOperationWithLockContext(this, lockContext) } @@ -153,9 +157,9 @@ export class BulkEncryptOperation extends EncryptionOperation export class BulkEncryptOperationWithLockContext extends EncryptionOperation { private operation: BulkEncryptOperation - private lockContext: LockContext + private lockContext: LockContextInput - constructor(operation: BulkEncryptOperation, lockContext: LockContext) { + constructor(operation: BulkEncryptOperation, lockContext: LockContextInput) { super() this.operation = operation this.lockContext = lockContext @@ -186,16 +190,13 @@ export class BulkEncryptOperationWithLockContext extends EncryptionOperation { return new DecryptModelOperationWithLockContext(this, lockContext) } @@ -74,9 +74,12 @@ export class DecryptModelOperationWithLockContext< T extends Record, > extends EncryptionOperation> { private operation: DecryptModelOperation - private lockContext: LockContext + private lockContext: LockContextInput - constructor(operation: DecryptModelOperation, lockContext: LockContext) { + constructor( + operation: DecryptModelOperation, + lockContext: LockContextInput, + ) { super() this.operation = operation this.lockContext = lockContext @@ -101,18 +104,14 @@ export class DecryptModelOperationWithLockContext< throw noClientError() } - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[encryption]: ${context.failure.message}`) - } + const context = resolveLockContext(this.lockContext) const auditData = this.getAuditData() return await decryptModelFieldsWithLockContext( model, client, - context.data, + context, auditData, ) }, diff --git a/packages/stack/src/encryption/operations/decrypt.ts b/packages/stack/src/encryption/operations/decrypt.ts index 63d77cae..ca0eee39 100644 --- a/packages/stack/src/encryption/operations/decrypt.ts +++ b/packages/stack/src/encryption/operations/decrypt.ts @@ -1,6 +1,6 @@ import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' -import type { LockContext } from '@/identity' +import { type LockContextInput, resolveLockContext } from '@/identity' import type { Client, Encrypted } from '@/types' import { createRequestLogger } from '@/utils/logger' import { type Result, withResult } from '@byteslice/result' @@ -30,7 +30,7 @@ export class DecryptOperation extends EncryptionOperation { } public withLockContext( - lockContext: LockContext, + lockContext: LockContextInput, ): DecryptOperationWithLockContext { return new DecryptOperationWithLockContext(this, lockContext) } @@ -88,9 +88,9 @@ export class DecryptOperation extends EncryptionOperation { export class DecryptOperationWithLockContext extends EncryptionOperation { private operation: DecryptOperation - private lockContext: LockContext + private lockContext: LockContextInput - constructor(operation: DecryptOperation, lockContext: LockContext) { + constructor(operation: DecryptOperation, lockContext: LockContextInput) { super() this.operation = operation this.lockContext = lockContext @@ -121,16 +121,12 @@ export class DecryptOperationWithLockContext extends EncryptionOperation { diff --git a/packages/stack/src/encryption/operations/encrypt-model.ts b/packages/stack/src/encryption/operations/encrypt-model.ts index f08ad9f1..e3c309b6 100644 --- a/packages/stack/src/encryption/operations/encrypt-model.ts +++ b/packages/stack/src/encryption/operations/encrypt-model.ts @@ -1,6 +1,6 @@ import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' -import type { LockContext } from '@/identity' +import { type LockContextInput, resolveLockContext } from '@/identity' import type { EncryptedTable, EncryptedTableColumn } from '@/schema' import type { Client } from '@/types' import { createRequestLogger } from '@/utils/logger' @@ -31,7 +31,7 @@ export class EncryptModelOperation< } public withLockContext( - lockContext: LockContext, + lockContext: LockContextInput, ): EncryptModelOperationWithLockContext { return new EncryptModelOperationWithLockContext(this, lockContext) } @@ -89,9 +89,12 @@ export class EncryptModelOperationWithLockContext< T extends Record, > extends EncryptionOperation { private operation: EncryptModelOperation - private lockContext: LockContext + private lockContext: LockContextInput - constructor(operation: EncryptModelOperation, lockContext: LockContext) { + constructor( + operation: EncryptModelOperation, + lockContext: LockContextInput, + ) { super() this.operation = operation this.lockContext = lockContext @@ -117,11 +120,7 @@ export class EncryptModelOperationWithLockContext< throw noClientError() } - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[encryption]: ${context.failure.message}`) - } + const context = resolveLockContext(this.lockContext) const auditData = this.getAuditData() @@ -129,7 +128,7 @@ export class EncryptModelOperationWithLockContext< model, table, client, - context.data, + context, auditData, )) as T }, diff --git a/packages/stack/src/encryption/operations/encrypt-query.ts b/packages/stack/src/encryption/operations/encrypt-query.ts index 38eca7e1..64eb4b63 100644 --- a/packages/stack/src/encryption/operations/encrypt-query.ts +++ b/packages/stack/src/encryption/operations/encrypt-query.ts @@ -1,7 +1,7 @@ import { formatEncryptedResult } from '@/encryption/helpers' import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' -import type { LockContext } from '@/identity' +import { type LockContextInput, resolveLockContext } from '@/identity' import type { Client, EncryptQueryOptions, EncryptedQueryResult } from '@/types' import { createRequestLogger } from '@/utils/logger' import { type Result, withResult } from '@byteslice/result' @@ -30,7 +30,7 @@ export class EncryptQueryOperation extends EncryptionOperation, ) { super() @@ -154,13 +154,7 @@ export class EncryptQueryOperationWithLockContext extends EncryptionOperation { diff --git a/packages/stack/src/encryption/operations/encrypt.ts b/packages/stack/src/encryption/operations/encrypt.ts index 63560a62..329f6e2b 100644 --- a/packages/stack/src/encryption/operations/encrypt.ts +++ b/packages/stack/src/encryption/operations/encrypt.ts @@ -1,6 +1,6 @@ import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' -import type { LockContext } from '@/identity' +import { type LockContextInput, resolveLockContext } from '@/identity' import type { EncryptedColumn, EncryptedField, @@ -40,7 +40,7 @@ export class EncryptOperation extends EncryptionOperation { } public withLockContext( - lockContext: LockContext, + lockContext: LockContextInput, ): EncryptOperationWithLockContext { return new EncryptOperationWithLockContext(this, lockContext) } @@ -123,9 +123,9 @@ export class EncryptOperation extends EncryptionOperation { export class EncryptOperationWithLockContext extends EncryptionOperation { private operation: EncryptOperation - private lockContext: LockContext + private lockContext: LockContextInput - constructor(operation: EncryptOperation, lockContext: LockContext) { + constructor(operation: EncryptOperation, lockContext: LockContextInput) { super() this.operation = operation this.lockContext = lockContext @@ -157,17 +157,13 @@ export class EncryptOperationWithLockContext extends EncryptionOperation getJwt()), + * }, + * }) * + * // Bind the key to the user's `sub` claim — no token, no identify(). * const result = await client * .encrypt(value, { column: users.email, table: users }) - * .withLockContext(identified.data) + * .withLockContext({ identityClaim: ["sub"] }) * ``` */ export class LockContext { @@ -76,20 +99,27 @@ export class LockContext { logger.debug('Successfully initialized the EQL lock context.') } + /** + * The identity-claim context this lock context binds to, e.g. + * `{ identityClaim: ['sub'] }`. Resolved synchronously — `.withLockContext()` + * uses this directly; no CTS token is required. + */ + get identityContext(): Context { + return this.context + } + /** * Exchange a user's JWT for a CTS token and bind it to this lock context. * - * @param jwtToken - A valid OIDC / JWT token for the current user. - * @returns A `Result` containing this `LockContext` (now authenticated) or an error. + * @deprecated Per-operation CTS tokens were removed in protect-ffi 0.25. + * Authenticate the client as the user with an `OidcFederationStrategy` + * (`config.strategy`) instead, and pass the claim to `.withLockContext()`. + * The token fetched here is no longer used by encryption operations. This + * method is kept for backwards compatibility and will be removed in a + * future major release. * - * @example - * ```typescript - * const lc = new LockContext() - * const result = await lc.identify(userJwt) - * if (result.failure) { - * console.error("Auth failed:", result.failure.message) - * } - * ``` + * @param jwtToken - A valid OIDC / JWT token for the current user. + * @returns A `Result` containing this `LockContext` or an error. */ async identify( jwtToken: string, @@ -145,28 +175,21 @@ export class LockContext { } /** - * Retrieve the current CTS token and context for use with encryption operations. - * - * Must be called after {@link identify}. Returns the token/context pair that - * `.withLockContext()` expects. + * Retrieve the identity context (and CTS token, if one was set). * - * @returns A `Result` containing the CTS token and identity context, or an error - * if {@link identify} has not been called. + * @deprecated Encryption operations no longer consume a CTS token — they read + * the identity claim directly via {@link identityContext}. Pass the claim to + * `.withLockContext()` and authenticate the client with an + * `OidcFederationStrategy` instead. Kept for backwards compatibility; the + * returned `ctsToken` is `undefined` unless one was supplied to the + * constructor or {@link identify} was called. */ getLockContext(): Promise> { return withResult( - () => { - if (!this.ctsToken?.accessToken || !this.ctsToken?.expiry) { - throw new Error( - 'The CTS token is not set. Please call identify() with a users JWT token, or pass an existing CTS token to the LockContext constructor before calling getLockContext().', - ) - } - - return { - context: this.context, - ctsToken: this.ctsToken, - } - }, + () => ({ + context: this.context, + ctsToken: this.ctsToken, + }), (error) => ({ type: EncryptionErrorTypes.CtsTokenError, message: error.message, diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 75891cfa..5efbaa8b 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -8,5 +8,18 @@ export { encryptedToPgComposite, } from '@/encryption/helpers' +// Re-export auth strategies for convenience. Pass one as `config.strategy` to +// `Encryption()` to control how ZeroKMS requests are authenticated — notably +// `OidcFederationStrategy` for per-user, identity-bound encryption (pair with +// `.withLockContext({ identityClaim })`). Re-exported so integrators don't need +// a separate `@cipherstash/auth` install. +export { + AccessKeyStrategy, + AutoStrategy, + DeviceSessionStrategy, + OidcFederationStrategy, +} from '@cipherstash/auth' + // Re-export types for convenience export type { Encrypted } from '@/types' +export type { AuthError, AuthErrorCode, TokenResult } from '@cipherstash/auth' diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 2b1037ae..1df17881 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -16,9 +16,11 @@ import type { /** * A pluggable authentication strategy for ZeroKMS requests. Any object * with a `getToken(): Promise<{ token: string }>` method satisfies it — - * notably `AccessKeyStrategy` from `@cipherstash/auth`. When supplied to - * {@link ClientConfig.strategy}, `getToken()` is invoked on every ZeroKMS - * request, taking precedence over the credentials-derived default. + * notably the strategies from `@cipherstash/auth`: `OidcFederationStrategy` + * (per-user, identity-bound encryption) and `AccessKeyStrategy` + * (service-to-service / CI). When supplied to {@link ClientConfig.strategy}, + * `getToken()` is invoked on every ZeroKMS request, taking precedence over + * the credentials-derived default. * * @see ClientConfig.strategy */ @@ -101,12 +103,19 @@ export type ClientConfig = { keyset?: KeysetIdentifier /** - * An optional authentication strategy for ZeroKMS requests, e.g. - * `AccessKeyStrategy` from `@cipherstash/auth`. When provided, its - * `getToken()` is invoked on every ZeroKMS request and takes - * precedence over the credentials-derived default strategy (the - * `clientKey` is still required). Use this to plug in custom token - * acquisition / caching (service-to-service, edge runtimes, …). + * An optional authentication strategy for ZeroKMS requests, from + * `@cipherstash/auth` (re-exported by `@cipherstash/stack`). When provided, + * its `getToken()` is invoked on every ZeroKMS request and takes precedence + * over the credentials-derived default strategy (the `clientKey` is still + * required for encryption). Use: + * + * - `OidcFederationStrategy` for per-user, identity-bound encryption — + * federates an end user's OIDC JWT into a CTS service token, so requests + * authenticate as that user. Pair with `.withLockContext({ identityClaim })` + * to bind the data key to a claim. This replaces the older + * `LockContext.identify()` ceremony. + * - `AccessKeyStrategy` for service-to-service / CI, or any custom + * `{ getToken() }` object for bespoke token acquisition / caching. * * Leave unset to let the client build its default strategy from * `workspaceCrn` / `accessKey` / `clientId` / `clientKey` (or the diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 87556420..adfb1d61 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -35,15 +35,32 @@ * const dec = await client.decrypt(enc) * ``` * - * For runtimes that need a custom token store (e.g. cookies on a - * Supabase Edge Function), build the strategy yourself with - * `AccessKeyStrategy.create(region, accessKey, { store })` from - * `@cipherstash/auth/wasm-inline` — derive `region` from your CRN with - * the same `crn::` split this module uses — and - * pass the strategy via `config.strategy`. + * For per-user, identity-bound encryption on the edge, build an + * `OidcFederationStrategy` (federates an end user's OIDC JWT — Clerk, + * Supabase, … — into a CTS service token) and pass it via + * `config.strategy`: + * + * ```ts + * import { OidcFederationStrategy } from "@cipherstash/stack/wasm-inline" + * import { cookieStore } from "@cipherstash/auth/cookies" + * + * const strategy = OidcFederationStrategy.create( + * "ap-southeast-2.aws", workspaceId, () => getClerkSessionToken(req), + * { store: cookieStore({ request: req, responseHeaders }) }, + * ) + * const client = await Encryption({ schemas, config: { strategy, clientId, clientKey } }) + * ``` + * + * For service-to-service / CI use with a custom token store, build an + * `AccessKeyStrategy.create(region, accessKey, { store })` the same way — + * derive `region` from your CRN with the `crn::` + * split this module uses. Both strategies are re-exported from this entry. */ -import { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' +import { + AccessKeyStrategy, + OidcFederationStrategy, +} from '@cipherstash/auth/wasm-inline' import { decrypt as wasmDecrypt, encrypt as wasmEncrypt, @@ -84,18 +101,27 @@ export type { export type { Encrypted } from '@/types' +// Auth strategies for `config.strategy` — `OidcFederationStrategy` for +// per-user identity-bound encryption, `AccessKeyStrategy` for M2M / CI. +// Re-exported so edge consumers don't need a separate `@cipherstash/auth` +// import (pair `OidcFederationStrategy` with `cookieStore` from +// `@cipherstash/auth/cookies` for cross-invocation token caching). +export { + AccessKeyStrategy, + OidcFederationStrategy, +} from '@cipherstash/auth/wasm-inline' + /** Re-exported convenience predicate — same as the raw protect-ffi one. */ export function isEncrypted(value: unknown): boolean { return wasmIsEncrypted(value as never) } // Note: the raw `newClient` / `encrypt` / `decrypt` from -// `@cipherstash/protect-ffi/wasm-inline` and `AccessKeyStrategy` from -// `@cipherstash/auth/wasm-inline` are intentionally NOT re-exported. The -// raw `newClient` does not normalise SDK-facing `cast_as` values (see -// `normalizeCastAs` below) and a re-export would invite consumers to -// build configs that this normaliser rejects. Import those names -// directly from their source packages if you need raw access. +// `@cipherstash/protect-ffi/wasm-inline` are intentionally NOT +// re-exported. The raw `newClient` does not normalise SDK-facing +// `cast_as` values (see `normalizeCastAs` below) and a re-export would +// invite consumers to build configs that this normaliser rejects. Import +// those names directly from their source package if you need raw access. // ----------------------------------------------------------------------- // High-level `Encryption` factory + client. @@ -128,9 +154,9 @@ export type WasmPlaintext = * For service-to-service / CI use, pass `accessKey` plus the workspace * `clientId` / `clientKey` and we construct an `AccessKeyStrategy` for * you. To plug in a custom token store (cookies on Supabase Edge, KV on - * Cloudflare Workers, …) build the strategy with - * `AccessKeyStrategy.create(region, accessKey, { store })` and hand it - * to `config.strategy` instead. + * Cloudflare Workers, …) or to bind encryption to an end user, build the + * strategy yourself — `AccessKeyStrategy` or `OidcFederationStrategy` — + * and hand it to `config.strategy` instead. */ export type WasmClientConfig = { /** @@ -152,10 +178,21 @@ export type WasmClientConfig = { } | { accessKey?: never - strategy: AccessKeyStrategy + strategy: WasmAuthStrategy } ) +/** + * Any auth strategy accepted on the WASM path. Both expose + * `getToken(): Promise<{ token }>`, which is all protect-ffi's WASM + * `newClient` requires: + * + * - {@link AccessKeyStrategy} — static M2M / CI access key. + * - {@link OidcFederationStrategy} — federates an end-user OIDC JWT into a + * CTS service token, for per-user identity-bound encryption. + */ +export type WasmAuthStrategy = AccessKeyStrategy | OidcFederationStrategy + export type WasmEncryptionConfig = { schemas: [ EncryptedTable, @@ -316,7 +353,7 @@ function getColumnName( ) } -function resolveStrategy(cfg: WasmClientConfig): AccessKeyStrategy { +function resolveStrategy(cfg: WasmClientConfig): WasmAuthStrategy { if (cfg.strategy) return cfg.strategy // Discriminated union guarantees this branch implies `accessKey` is set. // `AccessKeyStrategy.create` still takes a bare region string, so derive diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec0c3e5f..df9ed0f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,26 +7,26 @@ settings: catalogs: repo: '@cipherstash/auth': - specifier: 0.38.0 - version: 0.38.0 + specifier: 0.39.0 + version: 0.39.0 '@cipherstash/auth-darwin-arm64': - specifier: 0.38.0 - version: 0.38.0 + specifier: 0.39.0 + version: 0.39.0 '@cipherstash/auth-darwin-x64': - specifier: 0.38.0 - version: 0.38.0 + specifier: 0.39.0 + version: 0.39.0 '@cipherstash/auth-linux-arm64-gnu': - specifier: 0.38.0 - version: 0.38.0 + specifier: 0.39.0 + version: 0.39.0 '@cipherstash/auth-linux-x64-gnu': - specifier: 0.38.0 - version: 0.38.0 + specifier: 0.39.0 + version: 0.39.0 '@cipherstash/auth-linux-x64-musl': - specifier: 0.38.0 - version: 0.38.0 + specifier: 0.39.0 + version: 0.39.0 '@cipherstash/auth-win32-x64-msvc': - specifier: 0.38.0 - version: 0.38.0 + specifier: 0.39.0 + version: 0.39.0 tsup: specifier: 8.5.1 version: 8.5.1 @@ -215,7 +215,7 @@ importers: dependencies: '@cipherstash/auth': specifier: catalog:repo - version: 0.38.0(@cipherstash/auth-darwin-arm64@0.38.0)(@cipherstash/auth-darwin-x64@0.38.0)(@cipherstash/auth-linux-arm64-gnu@0.38.0)(@cipherstash/auth-linux-x64-gnu@0.38.0)(@cipherstash/auth-linux-x64-musl@0.38.0)(@cipherstash/auth-win32-x64-msvc@0.38.0) + version: 0.39.0(@cipherstash/auth-darwin-arm64@0.39.0)(@cipherstash/auth-darwin-x64@0.39.0)(@cipherstash/auth-linux-arm64-gnu@0.39.0)(@cipherstash/auth-linux-x64-gnu@0.39.0)(@cipherstash/auth-linux-x64-musl@0.39.0)(@cipherstash/auth-win32-x64-msvc@0.39.0) '@cipherstash/migrate': specifier: workspace:* version: link:../migrate @@ -268,22 +268,22 @@ importers: optionalDependencies: '@cipherstash/auth-darwin-arm64': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-darwin-x64': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-linux-arm64-gnu': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-linux-x64-gnu': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-linux-x64-musl': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-win32-x64-msvc': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 packages/drizzle: dependencies: @@ -556,10 +556,10 @@ importers: version: 0.2.0 '@cipherstash/auth': specifier: catalog:repo - version: 0.38.0(@cipherstash/auth-darwin-arm64@0.38.0)(@cipherstash/auth-darwin-x64@0.38.0)(@cipherstash/auth-linux-arm64-gnu@0.38.0)(@cipherstash/auth-linux-x64-gnu@0.38.0)(@cipherstash/auth-linux-x64-musl@0.38.0)(@cipherstash/auth-win32-x64-msvc@0.38.0) + version: 0.39.0(@cipherstash/auth-darwin-arm64@0.39.0)(@cipherstash/auth-darwin-x64@0.39.0)(@cipherstash/auth-linux-arm64-gnu@0.39.0)(@cipherstash/auth-linux-x64-gnu@0.39.0)(@cipherstash/auth-linux-x64-musl@0.39.0)(@cipherstash/auth-win32-x64-msvc@0.39.0) '@cipherstash/protect-ffi': - specifier: 0.25.0 - version: 0.25.0 + specifier: 0.26.0 + version: 0.26.0 evlog: specifier: 1.11.0 version: 1.11.0(next@15.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) @@ -614,7 +614,7 @@ importers: version: 0.3.143(@anthropic-ai/sdk@0.81.0(zod@3.25.76))(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(zod@3.25.76) '@cipherstash/auth': specifier: catalog:repo - version: 0.38.0(@cipherstash/auth-darwin-arm64@0.38.0)(@cipherstash/auth-darwin-x64@0.38.0)(@cipherstash/auth-linux-arm64-gnu@0.38.0)(@cipherstash/auth-linux-x64-gnu@0.38.0)(@cipherstash/auth-linux-x64-musl@0.38.0)(@cipherstash/auth-win32-x64-msvc@0.38.0) + version: 0.39.0(@cipherstash/auth-darwin-arm64@0.39.0)(@cipherstash/auth-darwin-x64@0.39.0)(@cipherstash/auth-linux-arm64-gnu@0.39.0)(@cipherstash/auth-linux-x64-gnu@0.39.0)(@cipherstash/auth-linux-x64-musl@0.39.0)(@cipherstash/auth-win32-x64-msvc@0.39.0) '@clack/prompts': specifier: 1.4.0 version: 1.4.0 @@ -652,22 +652,22 @@ importers: optionalDependencies: '@cipherstash/auth-darwin-arm64': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-darwin-x64': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-linux-arm64-gnu': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-linux-x64-gnu': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-linux-x64-musl': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 '@cipherstash/auth-win32-x64-msvc': specifier: catalog:repo - version: 0.38.0 + version: 0.39.0 packages: @@ -861,48 +861,48 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/auth-darwin-arm64@0.38.0': - resolution: {integrity: sha512-LF4u99t+KMll3m6ALcKzq8UCTC9zxUQLb6eKHMG5PGE5kIOb4MbG8yeEc3nUM6Up4hAl5leoCMlZsFdcVdSEQA==} + '@cipherstash/auth-darwin-arm64@0.39.0': + resolution: {integrity: sha512-Ub5Dpk+v0qcVGGBTV+s5onFft5sF13VRxYZFwD2SPZZnfGnci6EDmHRXAFcdWs7rHiAr9+adujFLS+nttTQwLg==} cpu: [arm64] os: [darwin] - '@cipherstash/auth-darwin-x64@0.38.0': - resolution: {integrity: sha512-E+S8ehq92S7fSuUSDTN1JZOZiWPVpmL01KK/aF+R7DaaGGBzQ0ZleR6JbY2t6pmX4TUpGRgbN8ImewuZW6QqgA==} + '@cipherstash/auth-darwin-x64@0.39.0': + resolution: {integrity: sha512-KJAgqKrblQzixzv/FqaOsWQlTHWaxWuKYhksy1FHypk9NgAIG+5o0WE6C10qln3JmfS6a0qCyMngPyd7xgVYYQ==} cpu: [x64] os: [darwin] - '@cipherstash/auth-linux-arm64-gnu@0.38.0': - resolution: {integrity: sha512-ZF167YZRIl4+Geqi0+diShyV2VdWG14UfAsvP1ZPfrLOsNJn5wCK3tL9Mw90Q526zr6Yik/smbfrUrS69rHU6A==} + '@cipherstash/auth-linux-arm64-gnu@0.39.0': + resolution: {integrity: sha512-CISDNdVuYkVEIUufUmH1Fb+XhhewJG1IEi5bn1uIz7DfKxPfNcguqT/0J8KXwBefiRkl7/GV6e2zxkJvuzG3Sw==} cpu: [arm64] os: [linux] libc: [glibc] - '@cipherstash/auth-linux-x64-gnu@0.38.0': - resolution: {integrity: sha512-xl1zMuANCtHMhfC77QBKULlfsbGMsGOqWTl5zD6NPn8lrM4tqDpaOdLwEbIo4EjbLSoA38IY9jxYB0qvlV0QQA==} + '@cipherstash/auth-linux-x64-gnu@0.39.0': + resolution: {integrity: sha512-ARzdAwBzRG8+Gl/snG0E5VdZASLOY4DUnBjmES28dsYFkRH9HHWoM5uzoC8PJz/HTc/bhSecBT88Lyy0e1HKmw==} cpu: [x64] os: [linux] libc: [glibc] - '@cipherstash/auth-linux-x64-musl@0.38.0': - resolution: {integrity: sha512-rN4E+sOjZH7xLCV/NFOixceTMYqivnF+CyFqxJaUpmqW36vwwuTAuv8S93A+wOzn+A6W8HPwfkBWMmZenNUznQ==} + '@cipherstash/auth-linux-x64-musl@0.39.0': + resolution: {integrity: sha512-E9n2qc0cjPhlcesHXCkH/qvqqU2fqnnbhPw4l5gNnLS85yCh2kWJgSN3TayEor/IWLwOzCbFGeg7Vk8L/n0llw==} cpu: [x64] os: [linux] libc: [musl] - '@cipherstash/auth-win32-x64-msvc@0.38.0': - resolution: {integrity: sha512-cvnqgRL4sKeuJ7HvdLyLkwS59TW4FI9z/Fdreyv8Q78TEhjmG0HMXKdNeTW7AAATFYmzqJlmZX2RRa+QnUfhfQ==} + '@cipherstash/auth-win32-x64-msvc@0.39.0': + resolution: {integrity: sha512-gy6byPAidnVU+Lt8GyejrJ6dBe0I3y9BHwb5WLgCSbvlKhJgAPyTDS4PjvqROVzqkhPmjHlYeOVU2carYEPMQw==} cpu: [x64] os: [win32] - '@cipherstash/auth@0.38.0': - resolution: {integrity: sha512-jsrJ8fqenjcuMGWG1pBzVUflZUQZ7c1MosYiqyxh6fYDsMlTl70S8agDSsa2ciXmVQyti49n4on8c1CZxGhF5w==} + '@cipherstash/auth@0.39.0': + resolution: {integrity: sha512-pn2HhF8T1xZdEOpHlOb9hamFwKI/r0gYaKZEswnTyjOahIT5uqV86M7Iu4Py4n88pAMRZUsvW7h1r5I3zBsFdg==} peerDependencies: - '@cipherstash/auth-darwin-arm64': 0.38.0 - '@cipherstash/auth-darwin-x64': 0.38.0 - '@cipherstash/auth-linux-arm64-gnu': 0.38.0 - '@cipherstash/auth-linux-x64-gnu': 0.38.0 - '@cipherstash/auth-linux-x64-musl': 0.38.0 - '@cipherstash/auth-win32-x64-msvc': 0.38.0 + '@cipherstash/auth-darwin-arm64': 0.39.0 + '@cipherstash/auth-darwin-x64': 0.39.0 + '@cipherstash/auth-linux-arm64-gnu': 0.39.0 + '@cipherstash/auth-linux-x64-gnu': 0.39.0 + '@cipherstash/auth-linux-x64-musl': 0.39.0 + '@cipherstash/auth-win32-x64-msvc': 0.39.0 peerDependenciesMeta: '@cipherstash/auth-darwin-arm64': optional: true @@ -922,8 +922,8 @@ packages: cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-arm64@0.25.0': - resolution: {integrity: sha512-VP19LCpaNG2mGlvyAVOjS4x+ldUiCw2MbUw2AunWzduxth5dsRgWv4XVTikeRTtPpVlMf7aIMm0T/J+ioU+O5g==} + '@cipherstash/protect-ffi-darwin-arm64@0.26.0': + resolution: {integrity: sha512-v2ZFgDlqHVdWEtnqxGYHGQ7gbkLVWzbB9CSEo0V3TGFhKjfh3tRsQFfZle+Fns9/1hHN0c/Q8jEvqmeRBt+TAA==} cpu: [arm64] os: [darwin] @@ -932,8 +932,8 @@ packages: cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.25.0': - resolution: {integrity: sha512-1AjRv8+uCfWVweC0tyqSm4EhGiETPYICD1hoZi9NUouWeMOaelYPy3/oC3GAhadbzSrCj+cESu8EJdqEgS6zFg==} + '@cipherstash/protect-ffi-darwin-x64@0.26.0': + resolution: {integrity: sha512-z9pvB0v8k3S1RRzqkwMUCCNQjjJr2iY8KV3ItZOb7ykvNLd55x3gFlZKFe+zLhBesCIZASSHBFp9yIcO0GGyBA==} cpu: [x64] os: [darwin] @@ -942,8 +942,8 @@ packages: cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.25.0': - resolution: {integrity: sha512-H1tTwSS0ow5T05uWzidNVgPDB2luJO6r/LC2AxX6Vveu472P3fbelANpBOzIB3RjJ5Q9Tje/UMkF4umIWg2JRA==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.26.0': + resolution: {integrity: sha512-IA8r5IwzCpoFUE2cB5VCCCioA8RfOIMql87SUB2DLAFShOeIACvaSwNBktwTCzLgMe5283BXqqyPNn2HzEGYGA==} cpu: [arm64] os: [linux] @@ -952,8 +952,8 @@ packages: cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.25.0': - resolution: {integrity: sha512-sfc8hTh8QPLw4oBM+LmEnRukgtrGRjLYG+dg4SuOlDCKFh0cFXrR42N3zz5ZV1WdNyjXay3pLo+sCpIg02bo9Q==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.26.0': + resolution: {integrity: sha512-QZVoThJ5kjrf5icg3OJ9ctcnN0CqF6fatVTcuUOLg4nz8xb+m5dIv4WhgvCT7HnipGmALGwKWdpNlnaMrUu0cg==} cpu: [x64] os: [linux] @@ -962,8 +962,8 @@ packages: cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-musl@0.25.0': - resolution: {integrity: sha512-QXlt+q+KvxR1WsRyryJLqagfyc26Edw6IXZkJJdc5kd9ZEN7IvmN3ZyRKKQzxwCqEf24SMeQKh9Ug+UZg4i4zA==} + '@cipherstash/protect-ffi-linux-x64-musl@0.26.0': + resolution: {integrity: sha512-ji6uo8F8vKNI5lmkfEy7RX22jm79Yjg/csyVXAQcTRdwVNZdVGe01vm3f4BIXrFrGDUmHvVJWUHqChqU6z8QiA==} cpu: [x64] os: [linux] @@ -972,16 +972,16 @@ packages: cpu: [x64] os: [win32] - '@cipherstash/protect-ffi-win32-x64-msvc@0.25.0': - resolution: {integrity: sha512-9DD/Mik8nyVKBmIYKNafX+LZXnR2WAWB2518KE40CTeibY9NuO6sm6n9+eDbkot7E/VvSwMdDAAMj1q301mrrg==} + '@cipherstash/protect-ffi-win32-x64-msvc@0.26.0': + resolution: {integrity: sha512-1XsLN+pcGjs1O7YCGBawbaVYnJ6N2r8jIcyL0LxQOgE40P4H+CcAwwxcZT4H243g18FJGIYGIW7MVN42csPOWw==} cpu: [x64] os: [win32] '@cipherstash/protect-ffi@0.23.0': resolution: {integrity: sha512-Ca8MKLrrumC561VoPDOhuUZcF8C8YenqO1Ig9hSJSRUB+jFeIJXeyn7glExsvKYWtxOx/pRub9FV8A0RyuPHMg==} - '@cipherstash/protect-ffi@0.25.0': - resolution: {integrity: sha512-TR4kJcIStAjfukBrRkMDxyPXUNY6ZkYdJg5QBQFCw+Sph9C1qEisq27yGu98yIAMTVgfn6473g16gAx/dHIqXQ==} + '@cipherstash/protect-ffi@0.26.0': + resolution: {integrity: sha512-UqhzMh/x/qBKhYVRiBi6V5MpHH+WiUaWQ0Wm/Q80aCrMoBNolhLWxPhiXM6zBE3tMTmH7Ge0wYIWOxFMfygMEA==} '@clack/core@1.3.0': resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} @@ -4387,67 +4387,67 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@cipherstash/auth-darwin-arm64@0.38.0': + '@cipherstash/auth-darwin-arm64@0.39.0': optional: true - '@cipherstash/auth-darwin-x64@0.38.0': + '@cipherstash/auth-darwin-x64@0.39.0': optional: true - '@cipherstash/auth-linux-arm64-gnu@0.38.0': + '@cipherstash/auth-linux-arm64-gnu@0.39.0': optional: true - '@cipherstash/auth-linux-x64-gnu@0.38.0': + '@cipherstash/auth-linux-x64-gnu@0.39.0': optional: true - '@cipherstash/auth-linux-x64-musl@0.38.0': + '@cipherstash/auth-linux-x64-musl@0.39.0': optional: true - '@cipherstash/auth-win32-x64-msvc@0.38.0': + '@cipherstash/auth-win32-x64-msvc@0.39.0': optional: true - '@cipherstash/auth@0.38.0(@cipherstash/auth-darwin-arm64@0.38.0)(@cipherstash/auth-darwin-x64@0.38.0)(@cipherstash/auth-linux-arm64-gnu@0.38.0)(@cipherstash/auth-linux-x64-gnu@0.38.0)(@cipherstash/auth-linux-x64-musl@0.38.0)(@cipherstash/auth-win32-x64-msvc@0.38.0)': + '@cipherstash/auth@0.39.0(@cipherstash/auth-darwin-arm64@0.39.0)(@cipherstash/auth-darwin-x64@0.39.0)(@cipherstash/auth-linux-arm64-gnu@0.39.0)(@cipherstash/auth-linux-x64-gnu@0.39.0)(@cipherstash/auth-linux-x64-musl@0.39.0)(@cipherstash/auth-win32-x64-msvc@0.39.0)': optionalDependencies: - '@cipherstash/auth-darwin-arm64': 0.38.0 - '@cipherstash/auth-darwin-x64': 0.38.0 - '@cipherstash/auth-linux-arm64-gnu': 0.38.0 - '@cipherstash/auth-linux-x64-gnu': 0.38.0 - '@cipherstash/auth-linux-x64-musl': 0.38.0 - '@cipherstash/auth-win32-x64-msvc': 0.38.0 + '@cipherstash/auth-darwin-arm64': 0.39.0 + '@cipherstash/auth-darwin-x64': 0.39.0 + '@cipherstash/auth-linux-arm64-gnu': 0.39.0 + '@cipherstash/auth-linux-x64-gnu': 0.39.0 + '@cipherstash/auth-linux-x64-musl': 0.39.0 + '@cipherstash/auth-win32-x64-msvc': 0.39.0 '@cipherstash/protect-ffi-darwin-arm64@0.23.0': optional: true - '@cipherstash/protect-ffi-darwin-arm64@0.25.0': + '@cipherstash/protect-ffi-darwin-arm64@0.26.0': optional: true '@cipherstash/protect-ffi-darwin-x64@0.23.0': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.25.0': + '@cipherstash/protect-ffi-darwin-x64@0.26.0': optional: true '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.25.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.26.0': optional: true '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.25.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.26.0': optional: true '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.25.0': + '@cipherstash/protect-ffi-linux-x64-musl@0.26.0': optional: true '@cipherstash/protect-ffi-win32-x64-msvc@0.23.0': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.25.0': + '@cipherstash/protect-ffi-win32-x64-msvc@0.26.0': optional: true '@cipherstash/protect-ffi@0.23.0': @@ -4461,16 +4461,16 @@ snapshots: '@cipherstash/protect-ffi-linux-x64-musl': 0.23.0 '@cipherstash/protect-ffi-win32-x64-msvc': 0.23.0 - '@cipherstash/protect-ffi@0.25.0': + '@cipherstash/protect-ffi@0.26.0': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.25.0 - '@cipherstash/protect-ffi-darwin-x64': 0.25.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.25.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.25.0 - '@cipherstash/protect-ffi-linux-x64-musl': 0.25.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.25.0 + '@cipherstash/protect-ffi-darwin-arm64': 0.26.0 + '@cipherstash/protect-ffi-darwin-x64': 0.26.0 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.26.0 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.26.0 + '@cipherstash/protect-ffi-linux-x64-musl': 0.26.0 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.26.0 '@clack/core@1.3.0': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 270fc7e5..3f918353 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,13 +11,13 @@ catalogs: # declare them as `optionalDependencies` via `catalog:repo`. Keep # all seven entries on the same version so a single bump here moves # everything in lockstep. - '@cipherstash/auth': 0.38.0 - '@cipherstash/auth-darwin-arm64': 0.38.0 - '@cipherstash/auth-darwin-x64': 0.38.0 - '@cipherstash/auth-linux-arm64-gnu': 0.38.0 - '@cipherstash/auth-linux-x64-gnu': 0.38.0 - '@cipherstash/auth-linux-x64-musl': 0.38.0 - '@cipherstash/auth-win32-x64-msvc': 0.38.0 + '@cipherstash/auth': 0.39.0 + '@cipherstash/auth-darwin-arm64': 0.39.0 + '@cipherstash/auth-darwin-x64': 0.39.0 + '@cipherstash/auth-linux-arm64-gnu': 0.39.0 + '@cipherstash/auth-linux-x64-gnu': 0.39.0 + '@cipherstash/auth-linux-x64-musl': 0.39.0 + '@cipherstash/auth-win32-x64-msvc': 0.39.0 tsup: 8.5.1 tsx: 4.22.1 typescript: 5.9.3 From a6a9a672c7e29f03384c3a168dc316f932d31d38 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 8 Jun 2026 16:30:09 +0800 Subject: [PATCH 4/6] chore(stack): update lockfile for @cipherstash/auth platform optionalDependencies The optionalDependencies block added to packages/stack/package.json was not reflected in pnpm-lock.yaml, breaking `pnpm install --frozen-lockfile` in CI. --- pnpm-lock.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df9ed0f4..3cb4610d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -606,6 +606,25 @@ importers: vitest: specifier: catalog:repo version: 3.2.4(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) + optionalDependencies: + '@cipherstash/auth-darwin-arm64': + specifier: catalog:repo + version: 0.39.0 + '@cipherstash/auth-darwin-x64': + specifier: catalog:repo + version: 0.39.0 + '@cipherstash/auth-linux-arm64-gnu': + specifier: catalog:repo + version: 0.39.0 + '@cipherstash/auth-linux-x64-gnu': + specifier: catalog:repo + version: 0.39.0 + '@cipherstash/auth-linux-x64-musl': + specifier: catalog:repo + version: 0.39.0 + '@cipherstash/auth-win32-x64-msvc': + specifier: catalog:repo + version: 0.39.0 packages/wizard: dependencies: From aec288558cdc96527fee98ce665d5e216a7537e1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 8 Jun 2026 16:48:18 +0800 Subject: [PATCH 5/6] fix(stack): pass workspace CRN to AccessKeyStrategy.create for auth 0.39 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @cipherstash/auth 0.39 changed AccessKeyStrategy.create(region, accessKey) to AccessKeyStrategy.create(workspaceCrn, accessKey) — it derives the region from the CRN itself. The wasm-inline resolveStrategy still passed a derived region, so the Deno WASM e2e failed with 'Invalid CRN: '. Pass the CRN directly and drop the now-obsolete extractRegionFromCrn helper + tests. (OidcFederationStrategy.create still takes region + workspaceId.) --- .../__tests__/wasm-inline-normalize.test.ts | 28 +---------- packages/stack/src/wasm-inline.ts | 47 +++++-------------- 2 files changed, 13 insertions(+), 62 deletions(-) diff --git a/packages/stack/__tests__/wasm-inline-normalize.test.ts b/packages/stack/__tests__/wasm-inline-normalize.test.ts index fcc5dc7a..77afbb5e 100644 --- a/packages/stack/__tests__/wasm-inline-normalize.test.ts +++ b/packages/stack/__tests__/wasm-inline-normalize.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { castAsEnum, toEqlCastAs } from '../src/schema' -import { extractRegionFromCrn, normalizeCastAs } from '../src/wasm-inline' +import { normalizeCastAs } from '../src/wasm-inline' // Exhaustive mapping the wasm-inline normaliser is expected to produce. // If you add a variant to `castAsEnum`, add the corresponding EQL value @@ -94,29 +94,3 @@ describe('wasm-inline normalizeCastAs', () => { ) }) }) - -describe('wasm-inline extractRegionFromCrn', () => { - it('pulls the region out of a well-formed workspace CRN', () => { - expect( - extractRegionFromCrn('crn:ap-southeast-2.aws:my-workspace-id'), - ).toBe('ap-southeast-2.aws') - }) - - it('handles a plain region segment (no .aws suffix)', () => { - expect(extractRegionFromCrn('crn:us-east-1:envWorkspace')).toBe( - 'us-east-1', - ) - }) - - it('throws on a CRN missing the workspace-id segment', () => { - expect(() => extractRegionFromCrn('crn:ap-southeast-2.aws')).toThrowError( - /invalid workspace CRN/, - ) - }) - - it('throws on a value that is not a CRN at all', () => { - expect(() => extractRegionFromCrn('ap-southeast-2.aws')).toThrowError( - /invalid workspace CRN/, - ) - }) -}) diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index adfb1d61..412fbb30 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -52,9 +52,9 @@ * ``` * * For service-to-service / CI use with a custom token store, build an - * `AccessKeyStrategy.create(region, accessKey, { store })` the same way — - * derive `region` from your CRN with the `crn::` - * split this module uses. Both strategies are re-exported from this entry. + * `AccessKeyStrategy.create(workspaceCrn, accessKey, { store })` the same + * way (it derives the region from the CRN). Both strategies are + * re-exported from this entry. */ import { @@ -146,10 +146,9 @@ export type WasmPlaintext = * The workspace CRN is the single source of truth for workspace * identity and deployment region — matching the Node entry and * protect-ffi 0.25+, which read `CS_WORKSPACE_CRN` and no longer - * consult a separate `CS_REGION`. The region the underlying - * `AccessKeyStrategy` needs is derived from the CRN's - * `crn::` form, so there is no `region` field to - * keep in sync. + * consult a separate `CS_REGION`. The CRN is passed straight to the + * underlying `AccessKeyStrategy`, which derives the region from it, so + * there is no `region` field to keep in sync. * * For service-to-service / CI use, pass `accessKey` plus the workspace * `clientId` / `clientKey` and we construct an `AccessKeyStrategy` for @@ -162,8 +161,8 @@ export type WasmClientConfig = { /** * CipherStash workspace CRN, e.g. * `"crn:ap-southeast-2.aws:my-workspace-id"`. Required — it is the - * single source of truth for workspace identity, and the region the - * `AccessKeyStrategy` needs is derived from it. + * single source of truth for workspace identity; the `AccessKeyStrategy` + * derives the region from it. */ workspaceCrn: string /** Workspace client identifier — required by the WASM client. */ @@ -356,30 +355,8 @@ function getColumnName( function resolveStrategy(cfg: WasmClientConfig): WasmAuthStrategy { if (cfg.strategy) return cfg.strategy // Discriminated union guarantees this branch implies `accessKey` is set. - // `AccessKeyStrategy.create` still takes a bare region string, so derive - // it from the CRN — keeping the CRN as the single source of truth. - return AccessKeyStrategy.create( - extractRegionFromCrn(cfg.workspaceCrn), - cfg.accessKey as string, - ) -} - -/** - * Pull the region out of a workspace CRN (`crn::`, - * e.g. `crn:ap-southeast-2.aws:my-workspace` → `ap-southeast-2.aws`). - * - * Defined locally rather than imported from `@/utils/config` so the - * WASM entry stays free of that module's Node-only `fs` / `path` - * imports. - * - * @internal exported for unit-test coverage. - */ -export function extractRegionFromCrn(crn: string): string { - const match = crn.match(/^crn:([^:]+):[^:]+$/) - if (!match) { - throw new Error( - `[encryption]: invalid workspace CRN "${crn}" — expected the form "crn::".`, - ) - } - return match[1] + // `AccessKeyStrategy.create` takes the full workspace CRN (region is + // derived from it inside `@cipherstash/auth`), so the CRN stays the + // single source of truth — no manual region split. + return AccessKeyStrategy.create(cfg.workspaceCrn, cfg.accessKey as string) } From 1efdd03fec1dbd2cedd5aff09f2146d18b755e02 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 8 Jun 2026 17:03:55 +0800 Subject: [PATCH 6/6] fix(stack): ESM-safe auth strategy re-export + update query lock-context tests - index.ts: @cipherstash/auth's Node entry is CJS with `module.exports = { ...native }`; the spread defeats cjs-module-lexer so a static `export { AccessKeyStrategy } from` throws 'Named export not found' under real Node ESM (the E2E cli failure). Default-import the module (which is module.exports at runtime, all names present) and re-export each binding explicitly, with instance types for the strategy classes. - encrypt-query / encrypt-query-searchable-json tests + fixtures: the ops no longer call getLockContext(); .withLockContext() takes a plain { identityClaim }. createMockLockContext() now returns that shape; dropped the getLockContext spy assertions and the obsolete failure / null-context cases (resolveLockContext is synchronous and cannot fail). --- .../encrypt-query-searchable-json.test.ts | 77 ++----------------- .../stack/__tests__/encrypt-query.test.ts | 74 ++---------------- packages/stack/__tests__/fixtures/index.ts | 59 ++------------ packages/stack/src/index.ts | 24 ++++-- 4 files changed, 40 insertions(+), 194 deletions(-) diff --git a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts index ee657525..c72eece0 100644 --- a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts +++ b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts @@ -1,13 +1,10 @@ import 'dotenv/config' -import { EncryptionErrorTypes } from '@/errors' import { Encryption } from '@/index' import { beforeAll, describe, expect, it } from 'vitest' type EncryptionClient = Awaited> import { - createFailingMockLockContext, createMockLockContext, - createMockLockContextWithNullContext, expectFailure, jsonbSchema, metadata, @@ -450,20 +447,16 @@ describe('searchableJson with LockContext', () => { expect(typeof operation.withLockContext).toBe('function') }) - it('executes string plaintext with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - + it('executes string plaintext bound to an identity claim', async () => { const operation = protectClient.encryptQuery('$.user.email', { column: jsonbSchema.metadata, table: jsonbSchema, queryType: 'searchableJson', }) - const withContext = operation.withLockContext(mockLockContext as any) + const withContext = operation.withLockContext(createMockLockContext()) const result = await withContext.execute() - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - const data = unwrapResult(result) expect(data).toMatchObject({ i: { t: 'documents', c: 'metadata' }, @@ -472,9 +465,7 @@ describe('searchableJson with LockContext', () => { expectSelector(data) }, 30000) - it('executes object plaintext with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - + it('executes object plaintext bound to an identity claim', async () => { const operation = protectClient.encryptQuery( { role: 'admin' }, { @@ -484,17 +475,12 @@ describe('searchableJson with LockContext', () => { }, ) - const withContext = operation.withLockContext(mockLockContext as any) + const withContext = operation.withLockContext(createMockLockContext()) const result = await withContext.execute() - // LockContext should be called even if the actual encryption fails - // with a mock token (ste_vec_term operations may require real auth) - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - // Ensure the operation actually completed (has either data or failure) expect(result.data !== undefined || result.failure !== undefined).toBe(true) - // The result may fail due to mock token, but we verify LockContext integration worked if (result.data) { expect(result.data).toMatchObject({ i: { t: 'documents', c: 'metadata' }, @@ -504,9 +490,7 @@ describe('searchableJson with LockContext', () => { } }, 30000) - it('executes batch with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - + it('executes a batch bound to an identity claim', async () => { const operation = protectClient.encryptQuery([ { value: '$.user.email', @@ -522,62 +506,13 @@ describe('searchableJson with LockContext', () => { }, ]) - const withContext = operation.withLockContext(mockLockContext as any) + const withContext = operation.withLockContext(createMockLockContext()) const result = await withContext.execute() - // LockContext should be called even if the actual encryption fails - // with a mock token (ste_vec_term operations may require real auth) - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - // The result may fail due to mock token, but we verify LockContext integration worked if (result.data) { expect(result.data).toHaveLength(2) } }, 30000) - - it('handles LockContext failure gracefully', async () => { - const mockLockContext = createFailingMockLockContext( - EncryptionErrorTypes.CtsTokenError, - 'Mock LockContext failure', - ) - - const operation = protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expectFailure( - result, - 'Mock LockContext failure', - EncryptionErrorTypes.CtsTokenError, - ) - }, 30000) - - it('handles explicit null context from getLockContext gracefully', async () => { - const mockLockContext = createMockLockContextWithNullContext() - - const operation = protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Should succeed - null context should not be passed to FFI - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expectSelector(data[0]) - }, 30000) }) describe('searchableJson equivalence', () => { diff --git a/packages/stack/__tests__/encrypt-query.test.ts b/packages/stack/__tests__/encrypt-query.test.ts index eb02097a..b4e561c2 100644 --- a/packages/stack/__tests__/encrypt-query.test.ts +++ b/packages/stack/__tests__/encrypt-query.test.ts @@ -5,9 +5,7 @@ import { Encryption } from '@/index' import { beforeAll, describe, expect, it } from 'vitest' import { articles, - createFailingMockLockContext, createMockLockContext, - createMockLockContextWithNullContext, expectFailure, metadata, products, @@ -647,23 +645,19 @@ describe('encryptQuery', () => { }) describe('LockContext support', () => { - it('single query with LockContext calls getLockContext', async () => { - const mockLockContext = createMockLockContext() - + it('single query with a lock context builds an executable operation', async () => { const operation = protectClient.encryptQuery('test@example.com', { column: users.email, table: users, queryType: 'equality', }) - const withContext = operation.withLockContext(mockLockContext as any) + const withContext = operation.withLockContext(createMockLockContext()) expect(withContext).toHaveProperty('execute') expect(typeof withContext.execute).toBe('function') }, 30000) - it('bulk query with LockContext calls getLockContext', async () => { - const mockLockContext = createMockLockContext() - + it('bulk query with a lock context builds an executable operation', async () => { const operation = protectClient.encryptQuery([ { value: 'test@example.com', @@ -673,25 +667,21 @@ describe('encryptQuery', () => { }, ]) - const withContext = operation.withLockContext(mockLockContext as any) + const withContext = operation.withLockContext(createMockLockContext()) expect(withContext).toHaveProperty('execute') expect(typeof withContext.execute).toBe('function') }, 30000) - it('executes single query with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - + it('executes a single query bound to an identity claim', async () => { const operation = protectClient.encryptQuery('test@example.com', { column: users.email, table: users, queryType: 'equality', }) - const withContext = operation.withLockContext(mockLockContext as any) + const withContext = operation.withLockContext(createMockLockContext()) const result = await withContext.execute() - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - const data = unwrapResult(result) expect(data).toMatchObject({ i: { t: 'users', c: 'email' }, @@ -700,9 +690,7 @@ describe('encryptQuery', () => { expect(data).toHaveProperty('hm') }, 30000) - it('executes bulk query with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - + it('executes a bulk query bound to an identity claim', async () => { const operation = protectClient.encryptQuery([ { value: 'test@example.com', @@ -718,59 +706,13 @@ describe('encryptQuery', () => { }, ]) - const withContext = operation.withLockContext(mockLockContext as any) + const withContext = operation.withLockContext(createMockLockContext()) const result = await withContext.execute() - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - const data = unwrapResult(result) expect(data).toHaveLength(2) expect(data[0]).toHaveProperty('hm') expect(data[1]).toHaveProperty('ob') }, 30000) - - it('handles LockContext failure gracefully', async () => { - const mockLockContext = createFailingMockLockContext( - EncryptionErrorTypes.CtsTokenError, - 'Mock LockContext failure', - ) - - const operation = protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expectFailure( - result, - 'Mock LockContext failure', - EncryptionErrorTypes.CtsTokenError, - ) - }, 30000) - - it('handles explicit null context from getLockContext gracefully', async () => { - // Simulate a runtime scenario where context is null (bypasses TypeScript) - const mockLockContext = createMockLockContextWithNullContext() - - const operation = protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Should succeed - null context should not be passed to FFI - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(data[0]).toHaveProperty('hm') - }, 30000) }) }) diff --git a/packages/stack/__tests__/fixtures/index.ts b/packages/stack/__tests__/fixtures/index.ts index 38203397..16f0017b 100644 --- a/packages/stack/__tests__/fixtures/index.ts +++ b/packages/stack/__tests__/fixtures/index.ts @@ -1,5 +1,5 @@ import { encryptedColumn, encryptedTable } from '@/schema' -import { expect, vi } from 'vitest' +import { expect } from 'vitest' // ============ Schema Fixtures ============ @@ -54,57 +54,14 @@ export const mixedSchema = encryptedTable('records', { // ============ Mock Factories ============ /** - * Creates a mock LockContext with successful response + * Creates a lock-context input for `.withLockContext()`. + * + * Since protect-ffi 0.25 dropped the per-operation CTS token, a lock context + * is just an identity-claim spec — `.withLockContext()` accepts this plain + * object directly (no `LockContext` instance or `identify()` call needed). */ -export function createMockLockContext(overrides?: { - accessToken?: string - expiry?: number - identityClaim?: string[] -}) { - return { - getLockContext: vi.fn().mockResolvedValue({ - data: { - ctsToken: { - accessToken: overrides?.accessToken ?? 'mock-token', - expiry: overrides?.expiry ?? Date.now() + 3600000, - }, - context: { - identityClaim: overrides?.identityClaim ?? ['sub'], - }, - }, - }), - } -} - -/** - * Creates a mock LockContext with explicit null context (simulates runtime edge case) - */ -export function createMockLockContextWithNullContext() { - return { - getLockContext: vi.fn().mockResolvedValue({ - data: { - ctsToken: { - accessToken: 'mock-token', - expiry: Date.now() + 3600000, - }, - context: null, // Explicit null - simulating runtime edge case - }, - }), - } -} - -/** - * Creates a mock LockContext that returns a failure - */ -export function createFailingMockLockContext( - errorType: string, - message: string, -) { - return { - getLockContext: vi.fn().mockResolvedValue({ - failure: { type: errorType, message }, - }), - } +export function createMockLockContext(overrides?: { identityClaim?: string[] }) { + return { identityClaim: overrides?.identityClaim ?? ['sub'] } } // ============ Test Helpers ============ diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 5efbaa8b..14e52d96 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -13,12 +13,24 @@ export { // `OidcFederationStrategy` for per-user, identity-bound encryption (pair with // `.withLockContext({ identityClaim })`). Re-exported so integrators don't need // a separate `@cipherstash/auth` install. -export { - AccessKeyStrategy, - AutoStrategy, - DeviceSessionStrategy, - OidcFederationStrategy, -} from '@cipherstash/auth' +// +// `@cipherstash/auth`'s Node entry is a CommonJS NAPI module whose exports come +// via `module.exports = { ...native }`. The spread defeats cjs-module-lexer, so +// Node's ESM loader can't see those names through a static `export { … } from` +// re-export (it throws "Named export not found"). We default-import the module +// (which IS `module.exports` at runtime, with every name present) and re-export +// each binding explicitly — both the value and, for the strategy classes, the +// instance type — so this works under real Node ESM, not just the bundler. +import auth from '@cipherstash/auth' + +export const AccessKeyStrategy = auth.AccessKeyStrategy +export type AccessKeyStrategy = InstanceType +export const AutoStrategy = auth.AutoStrategy +export type AutoStrategy = InstanceType +export const DeviceSessionStrategy = auth.DeviceSessionStrategy +export type DeviceSessionStrategy = InstanceType +export const OidcFederationStrategy = auth.OidcFederationStrategy +export type OidcFederationStrategy = InstanceType // Re-export types for convenience export type { Encrypted } from '@/types'