From 6f83fa359b42c10677a8f6a049bc51d4c99996a1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 29 May 2026 18:44:23 +1000 Subject: [PATCH 1/2] 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/2] 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[]]