From ac2a6861d1c2d522b6d250a812c302fff5e8ac8d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 15:17:48 +1000 Subject: [PATCH 1/4] feat(stack): rename config.strategy to authStrategy; document auth + keysets Rename the encryption client's auth strategy field from `strategy` to `authStrategy`. The old `strategy` field is retained as a deprecated alias: passing it still works and forwards to the client, but logs a one-time runtime deprecation warning. `authStrategy` wins when both are set. Also rewrites the `Encryption()` TypeDoc to walk through authentication: - the default `auto` strategy (env vars, then local dev profile which also supplies the client key) - `npx stash auth login` for local development - the four `CS_*` env vars for production/CI (table + dashboard link) - custom strategies via `authStrategy` (AccessKeyStrategy, OidcFederationStrategy) - lock context as an identity-bound capability on top of OidcFederationStrategy - keysets for multi-tenant isolation (one client per tenant) Tests cover the new `authStrategy` field, the deprecated `strategy` alias (forwarding + warning), and precedence when both are supplied. --- .../stack/__tests__/init-strategy.test.ts | 78 +++++++-- packages/stack/__tests__/lock-context.test.ts | 2 +- packages/stack/src/encryption/index.ts | 151 +++++++++++++++--- packages/stack/src/identity/index.ts | 4 +- packages/stack/src/index.ts | 4 +- packages/stack/src/types.ts | 31 ++-- 6 files changed, 216 insertions(+), 54 deletions(-) diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index 7d729e6f..8ca3ffac 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -1,13 +1,16 @@ /** - * Tests for the optional `config.strategy` auth strategy. + * Tests for the optional `config.authStrategy` auth strategy (and its + * deprecated `config.strategy` alias). * * 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. + * `Encryption` exposes it via `config.authStrategy`; when provided it must + * reach `newClient` as `opts.strategy` (the FFI's option name), and when + * omitted the option must be absent so the default `auto` strategy is used. + * The legacy `config.strategy` field is still honoured (with a runtime + * deprecation warning) until it is removed. */ import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -30,28 +33,28 @@ beforeEach(() => { vi.clearAllMocks() }) -describe('Encryption config.strategy', () => { - it('forwards a supplied strategy to newClient', async () => { - const strategy: AuthStrategy = { +describe('Encryption config.authStrategy', () => { + it('forwards a supplied authStrategy to newClient', async () => { + const authStrategy: AuthStrategy = { getToken: vi.fn(async () => ({ token: 'service-token' })), } - await Encryption({ schemas: [users], config: { strategy } }) + await Encryption({ schemas: [users], config: { authStrategy } }) // 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) + expect(opts.strategy).toBe(authStrategy) }) - it('passes the strategy alongside the credential clientOpts', async () => { - const strategy: AuthStrategy = { + it('passes the authStrategy alongside the credential clientOpts', async () => { + const authStrategy: AuthStrategy = { getToken: vi.fn(async () => ({ token: 'service-token' })), } await Encryption({ schemas: [users], config: { - strategy, + authStrategy, workspaceCrn: 'crn:ap-southeast-2.aws:test-workspace', clientId: 'client-id', clientKey: 'client-key', @@ -60,8 +63,8 @@ describe('Encryption 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) - // clientKey is still required even when a strategy is supplied. + expect(opts.strategy).toBe(authStrategy) + // clientKey is still required even when an authStrategy is supplied. expect(opts.clientOpts.clientKey).toBe('client-key') expect(opts.clientOpts.workspaceCrn).toBe( 'crn:ap-southeast-2.aws:test-workspace', @@ -83,7 +86,10 @@ describe('Encryption config.strategy', () => { })), } - await Encryption({ schemas: [users], config: { strategy: oidcStrategy } }) + await Encryption({ + schemas: [users], + config: { authStrategy: oidcStrategy }, + }) // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any @@ -99,6 +105,48 @@ describe('Encryption config.strategy', () => { }) }) +describe('Encryption config.strategy (deprecated alias)', () => { + it('still forwards a deprecated strategy to newClient and warns once', async () => { + // The rename warning is deduped per process, so this must be the first + // test in the file that passes `config.strategy`; the other suites use + // `authStrategy` and never trip the flag. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + 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) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('`config.strategy` is deprecated'), + ) + + warn.mockRestore() + }) + + it('prefers authStrategy over the deprecated strategy when both are set', async () => { + const strategy: AuthStrategy = { + getToken: vi.fn(async () => ({ token: 'legacy-token' })), + } + const authStrategy: AuthStrategy = { + getToken: vi.fn(async () => ({ token: 'new-token' })), + } + + await Encryption({ + schemas: [users], + config: { strategy, authStrategy }, + }) + + // 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(authStrategy) + }) +}) + // A minimal structural EQL v3 table: what marks a table as v3 for wire-format // detection is its `buildColumnKeyMap()` method (v2 tables have none). Built // by hand rather than via `@cipherstash/stack/v3` so this suite pins the diff --git a/packages/stack/__tests__/lock-context.test.ts b/packages/stack/__tests__/lock-context.test.ts index ff213629..fd45797a 100644 --- a/packages/stack/__tests__/lock-context.test.ts +++ b/packages/stack/__tests__/lock-context.test.ts @@ -38,7 +38,7 @@ async function userClient(userJwt: string) { return Encryption({ schemas: [users], config: { - strategy: OidcFederationStrategy.create(crn, () => + authStrategy: OidcFederationStrategy.create(crn, () => Promise.resolve(userJwt), ), }, diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 67a8c8c3..393bf1c1 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -127,7 +127,7 @@ export class EncryptionClient { clientId?: string clientKey?: string keyset?: KeysetIdentifier - strategy?: AuthStrategy + authStrategy?: AuthStrategy eqlVersion?: 2 | 3 }): Promise> { return await withResult( @@ -145,7 +145,7 @@ 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() + // When `authStrategy` 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 @@ -163,7 +163,7 @@ export class EncryptionClient { clientKey: config.clientKey, keyset: toFfiKeysetIdentifier(config.keyset), }, - strategy: config.strategy, + strategy: config.authStrategy, eqlVersion: config.eqlVersion, }) @@ -673,40 +673,87 @@ export class EncryptionClient { } } +// Emit the `config.strategy` → `config.authStrategy` rename warning at most +// once per process so repeated `Encryption()` calls don't spam the console. +let warnedStrategyDeprecated = false +function warnStrategyDeprecated(): void { + if (warnedStrategyDeprecated) return + warnedStrategyDeprecated = true + console.warn( + '[encryption]: `config.strategy` is deprecated and will be removed in a future release — use `config.authStrategy` instead.', + ) +} + /** * Creates and initializes an Encryption client for encrypting and decrypting data with CipherStash. * * Provide at least one schema (from {@link encryptedTable}) so the client knows which tables and - * 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`). + * columns to use: * - * 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}. + * ```typescript + * import { Encryption, encryptedTable, encryptedColumn } from "@cipherstash/stack" * - * @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`). - * @returns A Promise that resolves to an initialized {@link EncryptionClient} ready for - * {@link EncryptionClient.encrypt}, {@link EncryptionClient.decrypt}, and related operations. + * const users = encryptedTable("users", { email: encryptedColumn("email") }) + * const client = await Encryption({ schemas: [users] }) + * const result = await client.encrypt("alice@example.com", { column: users.email, table: users }) + * ``` * - * @throws Throws if `schemas` is empty, or if a keyset `id` is supplied but is not a valid UUID. - * Also throws if the client fails to initialize (e.g. invalid credentials or config). + * ## Authentication + * + * By default the client uses the `auto` auth strategy. `auto` first looks for the `CS_*` + * environment variables (see below) and, if they are not set, falls back to the local **dev + * profile** on your machine. The dev profile also supplies the client key, so during local + * development you generally don't need to set any environment variables at all. + * + * ### Local development — create a dev profile + * + * Log in once to create the dev profile that `auto` picks up automatically: + * + * ```bash + * npx stash auth login + * ``` + * + * ### Production / CI — environment variables + * + * In production and CI you typically authenticate with the four `CS_*` environment variables + * instead of a dev profile. Developers can obtain these values from the + * [CipherStash dashboard](https://dashboard.cipherstash.com): + * + * | Environment variable | Description | + * | ---------------------- | ------------------------------------------------------------------------------ | + * | `CS_WORKSPACE_CRN` | The workspace Cloud Resource Name (CRN) that identifies your workspace. | + * | `CS_CLIENT_ID` | The client identifier issued when you create an access key. | + * | `CS_CLIENT_KEY` | The client key material combined with ZeroKMS to perform encryption. | + * | `CS_CLIENT_ACCESS_KEY` | The API access key used to authenticate requests to CipherStash. | + * + * When these are set, `auto` uses them in preference to the local dev profile. + * + * ### Custom auth strategies — `config.authStrategy` + * + * For finer control, pass an explicit strategy via `config.authStrategy` (from `@cipherstash/auth`, + * re-exported by `@cipherstash/stack`). See the `@cipherstash/auth` package for the full list. Two + * common choices: + * + * `AccessKeyStrategy` — like `auto`, but only ever uses an access key; it never falls back to the + * local dev profile. Ideal for services and CI: * - * @example * ```typescript - * import { Encryption, encryptedTable, encryptedColumn } from "@cipherstash/stack" + * import { Encryption, AccessKeyStrategy } from "@cipherstash/stack" * - * const users = encryptedTable("users", { - * email: encryptedColumn("email"), + * const client = await Encryption({ + * schemas: [users], + * config: { + * authStrategy: AccessKeyStrategy.create(workspaceCrn, accessKey), + * }, * }) - * const client = await Encryption({ schemas: [users] }) - * const result = await client.encrypt("alice@example.com", { column: users.email, table: users }) * ``` * - * @example Per-user, identity-bound encryption + * `OidcFederationStrategy` — authenticate end users through your own identity provider (Supabase, + * Clerk, Auth0 or Okta) by federating their OIDC JWT into a CipherStash token. Add the provider to + * your workspace first at + * [dashboard.cipherstash.com/workspaces/_/oidc-providers](https://dashboard.cipherstash.com/workspaces/_/oidc-providers) + * (the `_` in the URL resolves to whichever workspace you select): + * * ```typescript * import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" * @@ -714,17 +761,63 @@ export class EncryptionClient { * const client = await Encryption({ * schemas: [users], * config: { - * strategy: OidcFederationStrategy.create(workspaceCrn, () => getUserJwt()), + * authStrategy: OidcFederationStrategy.create(workspaceCrn, () => getUserJwt()), * }, * }) + * ``` + * + * ### Lock context (identity-bound encryption) * + * Lock context is an **additional** capability layered on top of `OidcFederationStrategy`: it + * requires that strategy, but `OidcFederationStrategy` does not require lock context. It binds a + * value to a claim from the user's JWT (typically `sub`) so that only the user who encrypted a + * value can decrypt it: + * + * ```typescript * // 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"] }) * ``` * + * Because the lock is tied to a specific end user's identity, `AccessKeyStrategy` (which + * authenticates a service, not a user) is not valid for lock context — there is no user `sub` + * claim to bind to. + * + * ## Keysets (multi-tenant isolation) + * + * Pass `config.keyset` to encrypt under a specific **keyset** — a named or UUID-identified keyspace + * that gives each tenant its own cryptographic isolation, so data encrypted under one keyset cannot + * be decrypted under another. Create and manage keysets in the + * [dashboard](https://dashboard.cipherstash.com/workspaces/_/keysets) (the `_` in the URL resolves + * to whichever workspace you select); omit `config.keyset` to use the workspace's default keyset. + * + * ```typescript + * const client = await Encryption({ + * schemas: [users], + * config: { + * keyset: { name: "tenant-a" }, // or { id: "" } + * }, + * }) + * ``` + * + * A client is bound to a single keyset for its lifetime, so multi-tenant applications use **one + * `Encryption()` client per tenant**. Keysets are orthogonal to `authStrategy` and lock context — + * they isolate a whole tenant's *keyspace* (coarse, fixed per client), whereas lock context binds + * an individual value to a user's identity claim (fine-grained, per operation) — and can be + * combined with both. + * + * @param config - Initialization options. Must include `schemas`; optionally include `config` for + * credentials and authentication. Logging is configured via the `STASH_STACK_LOG` environment + * variable (`debug | info | error`, default: `error`). + * @returns A Promise that resolves to an initialized {@link EncryptionClient} ready for + * {@link EncryptionClient.encrypt}, {@link EncryptionClient.decrypt}, and related operations. + * + * @throws Throws if `schemas` is empty, or if a keyset `id` is supplied but is not a valid UUID. + * Also throws if the client fails to initialize (e.g. invalid credentials or config). + * * @see {@link EncryptionClientConfig} for full config options. + * @see {@link ClientConfig.authStrategy} for the auth strategy field. * @see {@link EncryptionClient} for available methods after initialization. */ export const Encryption = async ( @@ -748,6 +841,13 @@ export const Encryption = async ( ) } + // Resolve the auth strategy, honouring the deprecated `strategy` alias. + // `authStrategy` wins when both are set. + if (clientConfig?.strategy && !clientConfig.authStrategy) { + warnStrategyDeprecated() + } + const authStrategy = clientConfig?.authStrategy ?? clientConfig?.strategy + const client = new EncryptionClient() const encryptConfig = buildEncryptConfig(...schemas) @@ -760,6 +860,7 @@ export const Encryption = async ( const result = await client.init({ encryptConfig, ...clientConfig, + authStrategy, eqlVersion, }) diff --git a/packages/stack/src/identity/index.ts b/packages/stack/src/identity/index.ts index f9d0e4fb..97437658 100644 --- a/packages/stack/src/identity/index.ts +++ b/packages/stack/src/identity/index.ts @@ -68,7 +68,7 @@ export function resolveLockContext(input: LockContextInput): Context { * const client = await Encryption({ * schemas, * config: { - * strategy: OidcFederationStrategy.create(workspaceCrn, () => getJwt()), + * authStrategy: OidcFederationStrategy.create(workspaceCrn, () => getJwt()), * }, * }) * @@ -118,7 +118,7 @@ export class LockContext { * * @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()`. + * (`config.authStrategy`) 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. diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index b44f9b4d..32d4f48e 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -8,8 +8,8 @@ export { } from '@/encryption/helpers' export { encryptedColumn, encryptedField, encryptedTable } from '@/schema' -// Re-export auth strategies for convenience. Pass one as `config.strategy` to -// `Encryption()` to control how ZeroKMS requests are authenticated — notably +// Re-export auth strategies for convenience. Pass one as `config.authStrategy` +// 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. diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 5c5e3942..54ef574d 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -23,11 +23,11 @@ import type { * with a `getToken(): Promise<{ token: string }>` method satisfies it — * notably the strategies from `@cipherstash/auth`: `OidcFederationStrategy` * (per-user, identity-bound encryption) and `AccessKeyStrategy` - * (service-to-service / CI). When supplied to {@link ClientConfig.strategy}, + * (service-to-service / CI). When supplied to {@link ClientConfig.authStrategy}, * `getToken()` is invoked on every ZeroKMS request, taking precedence over - * the credentials-derived default. + * the default `auto` strategy. * - * @see ClientConfig.strategy + * @see ClientConfig.authStrategy */ export type { AuthStrategy } @@ -132,7 +132,12 @@ export type ClientConfig = { * An optional keyset identifier for multi-tenant encryption. * Each keyset provides cryptographic isolation, giving each tenant its own keyspace. * Specify by name (`{ name: "tenant-a" }`) or UUID (`{ id: "..." }`). - * Keysets are created and managed in the CipherStash dashboard. + * Keysets are created and managed in the + * [dashboard](https://dashboard.cipherstash.com/workspaces/_/keysets); omit to + * use the workspace's default keyset. A client is bound to one keyset for its + * lifetime, so use one client per tenant. + * + * @see {@link Encryption} for the full keysets walkthrough. */ keyset?: KeysetIdentifier @@ -140,8 +145,8 @@ export type ClientConfig = { * 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: + * over the default `auto` 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 @@ -151,11 +156,19 @@ export type ClientConfig = { * - `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 - * corresponding `CS_*` environment variables). + * Leave unset to use the default `auto` strategy, which reads credentials + * from the `CS_*` environment variables and falls back to the local dev + * profile created by `npx stash auth login`. * * @see {@link AuthStrategy} + * @see {@link Encryption} for a full walkthrough of the authentication options. + */ + authStrategy?: AuthStrategy + + /** + * @deprecated Renamed to {@link ClientConfig.authStrategy}. Still honoured for + * backwards compatibility — passing it logs a deprecation warning at runtime — + * but it will be removed in a future release. Set `authStrategy` instead. */ strategy?: AuthStrategy From 90d19fb8784cfc6eced1aa8f79cf7cad3dc45071 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:14:08 +1000 Subject: [PATCH 2/4] fix(stack): address review feedback on authStrategy rename - Warn whenever the deprecated `config.strategy` is present, even when `config.authStrategy` is also set (matches the ClientConfig TSDoc, which states passing `strategy` always warns). `authStrategy` still wins. - Harden the deprecation test: reset the once-per-process warning latch and spy console.warn in beforeEach, restore in afterEach (no leak on assertion throw), drop the order-dependent guard comment, and add a negative test that authStrategy alone does not warn. - Add an @internal reset hook (not re-exported from the package entry) so the test controls the warning latch deterministically. - TypeDoc: note that the auth/keyset snippets reuse the `users` schema and placeholder workspaceCrn/accessKey credentials. - Add changeset (minor). --- .../rename-strategy-to-auth-strategy.md | 24 +++++++++++ .../stack/__tests__/init-strategy.test.ts | 42 ++++++++++++++----- packages/stack/src/encryption/index.ts | 22 +++++++++- 3 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 .changeset/rename-strategy-to-auth-strategy.md diff --git a/.changeset/rename-strategy-to-auth-strategy.md b/.changeset/rename-strategy-to-auth-strategy.md new file mode 100644 index 00000000..d9dcb17a --- /dev/null +++ b/.changeset/rename-strategy-to-auth-strategy.md @@ -0,0 +1,24 @@ +--- +"@cipherstash/stack": minor +--- + +Rename the encryption client's auth strategy config field from `config.strategy` to **`config.authStrategy`** to make its purpose clear, and expand the `Encryption()` TypeDoc with a full authentication and keysets walkthrough. + +**`config.authStrategy`** is the new, documented field for supplying an auth strategy (`OidcFederationStrategy`, `AccessKeyStrategy`, or any `{ getToken() }` object). **`config.strategy` is retained as a deprecated alias** — passing it still works and forwards to the client, but logs a one-time runtime deprecation warning. When both are set, `authStrategy` wins (and the deprecation warning still fires so the leftover field gets cleaned up). + +```ts +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" + +const client = await Encryption({ + schemas: [users], + config: { + authStrategy: OidcFederationStrategy.create(workspaceCrn, () => getUserJwt()), + }, +}) +``` + +**Migration:** rename `config.strategy` → `config.authStrategy`. No behavioural change beyond the deprecation warning; the field is forwarded to protect-ffi's `strategy` option exactly as before. + +The `Encryption()` TypeDoc now documents the default `auto` strategy (env vars → local dev profile via `npx stash auth login`), the four `CS_*` production/CI variables, custom strategies (`AccessKeyStrategy`, `OidcFederationStrategy`), lock context, and keysets for multi-tenant isolation. + +Note: the `@cipherstash/stack/wasm-inline` entry still uses `config.strategy`; aligning that entry point to `authStrategy` is tracked as a follow-up. diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index 8ca3ffac..65766d4b 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -13,7 +13,8 @@ * deprecation warning) until it is removed. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { __resetStrategyDeprecationWarningForTests } from '@/encryption' import { EncryptionV3 } from '@/encryption/v3' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -29,8 +30,19 @@ const users = encryptedTable('users', { email: encryptedColumn('email'), }) +// Silence + capture the deprecation warning, and reset its once-per-process +// latch, so each test asserts warning behaviour deterministically regardless +// of order. `afterEach` restores the spy even if an assertion throws mid-test. +let warnSpy: ReturnType + beforeEach(() => { vi.clearAllMocks() + __resetStrategyDeprecationWarningForTests() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + warnSpy.mockRestore() }) describe('Encryption config.authStrategy', () => { @@ -106,12 +118,7 @@ describe('Encryption config.authStrategy', () => { }) describe('Encryption config.strategy (deprecated alias)', () => { - it('still forwards a deprecated strategy to newClient and warns once', async () => { - // The rename warning is deduped per process, so this must be the first - // test in the file that passes `config.strategy`; the other suites use - // `authStrategy` and never trip the flag. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - + it('still forwards a deprecated strategy to newClient and warns', async () => { const strategy: AuthStrategy = { getToken: vi.fn(async () => ({ token: 'service-token' })), } @@ -121,14 +128,12 @@ describe('Encryption config.strategy (deprecated alias)', () => { // 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) - expect(warn).toHaveBeenCalledWith( + expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('`config.strategy` is deprecated'), ) - - warn.mockRestore() }) - it('prefers authStrategy over the deprecated strategy when both are set', async () => { + it('prefers authStrategy over the deprecated strategy when both are set, and still warns', async () => { const strategy: AuthStrategy = { getToken: vi.fn(async () => ({ token: 'legacy-token' })), } @@ -144,6 +149,21 @@ describe('Encryption config.strategy (deprecated alias)', () => { // 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(authStrategy) + // The deprecated field is still present, so the nudge to remove it fires + // even though `authStrategy` takes precedence. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('`config.strategy` is deprecated'), + ) + }) + + it('does not warn when only authStrategy is supplied', async () => { + const authStrategy: AuthStrategy = { + getToken: vi.fn(async () => ({ token: 'new-token' })), + } + + await Encryption({ schemas: [users], config: { authStrategy } }) + + expect(warnSpy).not.toHaveBeenCalled() }) }) diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 393bf1c1..d07bf8d9 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -684,6 +684,17 @@ function warnStrategyDeprecated(): void { ) } +/** + * Reset the once-per-process deprecation-warning latch. Test-only hook so + * suites can assert the warning fires deterministically, independent of test + * ordering. Not re-exported from the package entry, so it stays off the public + * API surface. + * @internal + */ +export function __resetStrategyDeprecationWarningForTests(): void { + warnedStrategyDeprecated = false +} + /** * Creates and initializes an Encryption client for encrypting and decrypting data with CipherStash. * @@ -700,6 +711,10 @@ function warnStrategyDeprecated(): void { * * ## Authentication * + * The snippets in this section reuse the `users` schema from the example above, and + * `workspaceCrn` / `accessKey` stand in for your own workspace credentials (from the + * [dashboard](https://dashboard.cipherstash.com) or the `CS_*` variables below). + * * By default the client uses the `auto` auth strategy. `auto` first looks for the `CS_*` * environment variables (see below) and, if they are not set, falls back to the local **dev * profile** on your machine. The dev profile also supplies the client key, so during local @@ -793,6 +808,7 @@ function warnStrategyDeprecated(): void { * to whichever workspace you select); omit `config.keyset` to use the workspace's default keyset. * * ```typescript + * // `users` is the schema from the first example above. * const client = await Encryption({ * schemas: [users], * config: { @@ -842,8 +858,10 @@ export const Encryption = async ( } // Resolve the auth strategy, honouring the deprecated `strategy` alias. - // `authStrategy` wins when both are set. - if (clientConfig?.strategy && !clientConfig.authStrategy) { + // Warn whenever the deprecated field is present at all — even alongside + // `authStrategy` — so the leftover field gets cleaned up. `authStrategy` + // still wins when both are set. + if (clientConfig?.strategy) { warnStrategyDeprecated() } const authStrategy = clientConfig?.authStrategy ?? clientConfig?.strategy From db9a756336995c12edc8cbd0cdb39d4e708985d6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:40:09 +1000 Subject: [PATCH 3/4] feat(stack): align wasm-inline entry to config.authStrategy Keep the Node and WASM interfaces in sync: the `@cipherstash/stack/wasm-inline` entry now uses `config.authStrategy` as the documented field, with `config.strategy` retained as a deprecated alias. - `WasmClientConfig` gains an `authStrategy` arm; the deprecated `strategy` is kept as an alias (mutually exclusive with `accessKey`, as before). - `resolveStrategy` resolves `authStrategy ?? strategy`, guards the auth-strategy vs `accessKey` mutual exclusion against both fields, and emits a one-time runtime deprecation warning when `strategy` is used (mirroring the Node entry, with its own `@internal` reset hook for deterministic tests). - Update wasm-inline docs/examples and the offline resolveStrategy / new-client tests to cover authStrategy, the deprecated alias, and the warning. Closes #564. --- .../rename-strategy-to-auth-strategy.md | 2 +- .../__tests__/wasm-inline-new-client.test.ts | 10 ++- .../__tests__/wasm-inline-strategy.test.ts | 72 +++++++++++++-- packages/stack/src/wasm-inline.ts | 87 +++++++++++++++---- 4 files changed, 142 insertions(+), 29 deletions(-) diff --git a/.changeset/rename-strategy-to-auth-strategy.md b/.changeset/rename-strategy-to-auth-strategy.md index d9dcb17a..a05a629a 100644 --- a/.changeset/rename-strategy-to-auth-strategy.md +++ b/.changeset/rename-strategy-to-auth-strategy.md @@ -21,4 +21,4 @@ const client = await Encryption({ The `Encryption()` TypeDoc now documents the default `auto` strategy (env vars → local dev profile via `npx stash auth login`), the four `CS_*` production/CI variables, custom strategies (`AccessKeyStrategy`, `OidcFederationStrategy`), lock context, and keysets for multi-tenant isolation. -Note: the `@cipherstash/stack/wasm-inline` entry still uses `config.strategy`; aligning that entry point to `authStrategy` is tracked as a follow-up. +The `@cipherstash/stack/wasm-inline` entry (Deno / Edge / Workers / Bun) gets the same rename so the Node and WASM interfaces stay in sync: `WasmClientConfig.authStrategy` is the documented field, `strategy` is a deprecated alias that still works and warns at runtime. diff --git a/packages/stack/__tests__/wasm-inline-new-client.test.ts b/packages/stack/__tests__/wasm-inline-new-client.test.ts index b33c4d72..2fcd2b57 100644 --- a/packages/stack/__tests__/wasm-inline-new-client.test.ts +++ b/packages/stack/__tests__/wasm-inline-new-client.test.ts @@ -100,12 +100,16 @@ describe('wasm-inline Encryption → newClient (protect-ffi 0.25 single-object f expect(arg.encryptConfig.tables.users.email.cast_as).toBe('text') }) - it('uses an explicit config.strategy verbatim on the strategy path', async () => { + it('uses an explicit config.authStrategy verbatim on the strategy path', async () => { const explicit = { getToken: vi.fn() } await Encryption({ schemas: [users], - // biome-ignore lint/suspicious/noExplicitAny: exercise the strategy arm of the config union - config: { strategy: explicit, clientId: 'cid', clientKey: 'ckey' } as any, + config: { + authStrategy: explicit, + clientId: 'cid', + clientKey: 'ckey', + // biome-ignore lint/suspicious/noExplicitAny: exercise the authStrategy arm of the config union + } as any, }) // biome-ignore lint/suspicious/noExplicitAny: reading the recorded single options object diff --git a/packages/stack/__tests__/wasm-inline-strategy.test.ts b/packages/stack/__tests__/wasm-inline-strategy.test.ts index 4880bc43..febaa2ec 100644 --- a/packages/stack/__tests__/wasm-inline-strategy.test.ts +++ b/packages/stack/__tests__/wasm-inline-strategy.test.ts @@ -7,11 +7,13 @@ * Deno e2e (`e2e/wasm/roundtrip.test.ts`), which skips without real `CS_*` * secrets, so the wiring goes unchecked in the normal suite. These tests mock * `@cipherstash/auth/wasm-inline` and assert that the CRN reaches - * `AccessKeyStrategy.create`, that an explicit `config.strategy` is used - * verbatim, and that the `strategy` + `accessKey` mutual-exclusion guard fires. + * `AccessKeyStrategy.create`, that an explicit `config.authStrategy` is used + * verbatim, that the deprecated `config.strategy` alias still works (and warns), + * and that the auth-strategy + `accessKey` mutual-exclusion guard fires. This + * mirrors the Node entry's `config.authStrategy` contract. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@cipherstash/auth/wasm-inline', () => ({ AccessKeyStrategy: { @@ -28,12 +30,25 @@ vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ })) import { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' -import { resolveStrategy } from '../src/wasm-inline' +import { + __resetStrategyDeprecationWarningForTests, + resolveStrategy, +} from '../src/wasm-inline' const CRN = 'crn:ap-southeast-2.aws:test-workspace' +// Silence + capture the deprecation warning and reset its once-per-process +// latch so each test asserts warning behaviour deterministically. +let warnSpy: ReturnType + beforeEach(() => { vi.clearAllMocks() + __resetStrategyDeprecationWarningForTests() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + warnSpy.mockRestore() }) describe('wasm-inline resolveStrategy', () => { @@ -47,15 +62,43 @@ describe('wasm-inline resolveStrategy', () => { 'CSAK.test', ) expect(strategy).toEqual({ __mock: 'access-key-strategy' }) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('uses an explicit config.authStrategy verbatim and never builds an access key', () => { + const explicit = { getToken: vi.fn() } + // biome-ignore lint/suspicious/noExplicitAny: exercise the authStrategy arm of the discriminated union directly + const strategy = resolveStrategy({ authStrategy: explicit } as any) + + expect(strategy).toBe(explicit) + expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled() + expect(warnSpy).not.toHaveBeenCalled() }) - it('uses an explicit config.strategy verbatim and never builds an access key', () => { + it('honours the deprecated config.strategy alias and warns', () => { const explicit = { getToken: vi.fn() } - // biome-ignore lint/suspicious/noExplicitAny: exercise the strategy arm of the discriminated union directly + // biome-ignore lint/suspicious/noExplicitAny: exercise the deprecated strategy arm directly const strategy = resolveStrategy({ strategy: explicit } as any) expect(strategy).toBe(explicit) expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('`config.strategy` is deprecated'), + ) + }) + + it('prefers authStrategy over the deprecated strategy when both are set, and still warns', () => { + const authStrategy = { getToken: vi.fn() } + const strategy = { getToken: vi.fn() } + const resolved = resolveStrategy( + // biome-ignore lint/suspicious/noExplicitAny: both fields set — JS callers bypass the compile-time union + { authStrategy, strategy } as any, + ) + + expect(resolved).toBe(authStrategy) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('`config.strategy` is deprecated'), + ) }) it('throws when the access-key arm is missing workspaceCrn or accessKey', () => { @@ -78,11 +121,11 @@ describe('wasm-inline resolveStrategy', () => { expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled() }) - it('throws when both strategy and accessKey are supplied', () => { + it('throws when both an auth strategy and accessKey are supplied', () => { const both = { workspaceCrn: CRN, accessKey: 'CSAK.test', - strategy: { getToken: vi.fn() }, + authStrategy: { getToken: vi.fn() }, } expect(() => // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — JS callers bypass the compile-time union @@ -91,4 +134,17 @@ describe('wasm-inline resolveStrategy', () => { // The guard must short-circuit *before* building a strategy. expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled() }) + + it('throws when the deprecated strategy and accessKey are both supplied', () => { + const both = { + workspaceCrn: CRN, + accessKey: 'CSAK.test', + strategy: { getToken: vi.fn() }, + } + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — JS callers bypass the compile-time union + resolveStrategy(both as any), + ).toThrowError(/mutually exclusive/) + expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled() + }) }) diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 9fc36a4b..f97ed176 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -38,17 +38,17 @@ * 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`: + * `config.authStrategy`: * * ```ts * import { OidcFederationStrategy } from "@cipherstash/stack/wasm-inline" * import { cookieStore } from "@cipherstash/auth/cookies" * - * const strategy = OidcFederationStrategy.create( + * const authStrategy = OidcFederationStrategy.create( * "crn:ap-southeast-2.aws:my-workspace-id", () => getClerkSessionToken(req), * { store: cookieStore({ request: req, responseHeaders }) }, * ) - * const client = await Encryption({ schemas, config: { strategy, clientId, clientKey } }) + * const client = await Encryption({ schemas, config: { authStrategy, clientId, clientKey } }) * ``` * * For service-to-service / CI use with a custom token store, build an @@ -82,7 +82,7 @@ import type { Encrypted, EncryptOptions } from '@/types' // Schema + type re-exports // ----------------------------------------------------------------------- -// Auth strategies for `config.strategy` — `OidcFederationStrategy` for +// Auth strategies for `config.authStrategy` — `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 @@ -151,8 +151,11 @@ export type WasmPlaintext = * you. To plug in a custom token store (cookies on Supabase Edge, KV on * Cloudflare Workers, …) or to bind encryption to an end user, build the * strategy yourself — `AccessKeyStrategy` or `OidcFederationStrategy` — - * and hand it to `config.strategy` instead. A pre-built strategy already - * carries the CRN, so `workspaceCrn` is optional on that path. + * and hand it to `config.authStrategy` instead. A pre-built strategy + * already carries the CRN, so `workspaceCrn` is optional on that path. + * + * Mirrors the Node `ClientConfig`: `authStrategy` is the documented field, + * `strategy` is retained as a deprecated alias (see below). */ export type WasmClientConfig = { /** Workspace client identifier — required by the WASM client. */ @@ -160,7 +163,7 @@ export type WasmClientConfig = { /** Workspace client key — required by the WASM client. */ clientKey: string // Provide exactly one of `accessKey` (we build the strategy) or a - // pre-built `strategy` — never both, never neither. + // pre-built auth strategy — never both, never neither. } & ( | { /** @@ -171,17 +174,36 @@ export type WasmClientConfig = { */ workspaceCrn: string accessKey: string + authStrategy?: never strategy?: never } | { /** - * Optional on the strategy path. A pre-built `strategy` (e.g. + * Optional on the strategy path. A pre-built `authStrategy` (e.g. * `OidcFederationStrategy.create(workspaceCrn, …)`) already * encapsulates the workspace CRN and region, so the SDK never reads * this — supply it if convenient, omit it otherwise. */ workspaceCrn?: string accessKey?: never + /** A pre-built auth strategy for per-user or M2M authentication. */ + authStrategy: WasmAuthStrategy + /** + * @deprecated Renamed to `authStrategy`. Still honoured for backwards + * compatibility (it logs a deprecation warning at runtime) but will be + * removed in a future release. + */ + strategy?: WasmAuthStrategy + } + | { + workspaceCrn?: string + accessKey?: never + authStrategy?: never + /** + * @deprecated Renamed to `authStrategy`. Still honoured for backwards + * compatibility (it logs a deprecation warning at runtime) but will be + * removed in a future release. + */ strategy: WasmAuthStrategy } ) @@ -376,35 +398,66 @@ export function getColumnName(col: EncryptOptions['column']): string { ) } +// Emit the `config.strategy` → `config.authStrategy` rename warning at most +// once per process so repeated `Encryption()` calls don't spam the console. +// Mirrors the identical latch on the Node entry (`@/encryption`); the two are +// kept separate so the wasm bundle never imports the Node-only module. +let warnedStrategyDeprecated = false +function warnStrategyDeprecated(): void { + if (warnedStrategyDeprecated) return + warnedStrategyDeprecated = true + console.warn( + '[encryption]: `config.strategy` is deprecated and will be removed in a future release — use `config.authStrategy` instead.', + ) +} + +/** + * Reset the once-per-process deprecation-warning latch. Test-only hook so + * suites can assert the warning fires deterministically, independent of test + * ordering. + * @internal + */ +export function __resetStrategyDeprecationWarningForTests(): void { + warnedStrategyDeprecated = false +} + /** * Resolve the auth strategy for the WASM client from its config: an explicit - * `config.strategy`, or — for the access-key path — an `AccessKeyStrategy` - * built from the workspace CRN (region derived from it inside - * `@cipherstash/auth`). `strategy` and `accessKey` are mutually exclusive. + * `config.authStrategy` (or the deprecated `config.strategy` alias), or — for + * the access-key path — an `AccessKeyStrategy` built from the workspace CRN + * (region derived from it inside `@cipherstash/auth`). An auth strategy and + * `accessKey` are mutually exclusive. * * @internal exported for offline unit coverage of the strategy wiring; the * gated Deno e2e (`e2e/wasm/roundtrip.test.ts`) is the only other exercise of * this path and it skips without real `CS_*` secrets. */ export function resolveStrategy(cfg: WasmClientConfig): WasmAuthStrategy { - // The discriminated union rejects `accessKey` + `strategy` together at + // Honour the deprecated `strategy` alias; `authStrategy` wins when both are + // set. Warn whenever the deprecated field is present at all so the leftover + // field gets cleaned up (mirrors the Node entry). + if (cfg.strategy) { + warnStrategyDeprecated() + } + const authStrategy = cfg.authStrategy ?? cfg.strategy + // The discriminated union rejects an auth strategy + `accessKey` together at // compile time, but JS callers (Deno / plain JS) bypass that — guard at // runtime so a conflicting config fails loudly instead of silently // preferring one. - if (cfg.strategy && cfg.accessKey) { + if (authStrategy && cfg.accessKey) { throw new Error( - '[encryption]: `config.strategy` and `config.accessKey` are mutually exclusive — pass exactly one.', + '[encryption]: `config.authStrategy` and `config.accessKey` are mutually exclusive — pass exactly one.', ) } - if (cfg.strategy) return cfg.strategy - // No strategy → the access-key arm, where `workspaceCrn` and `accessKey` + if (authStrategy) return authStrategy + // No auth strategy → the access-key arm, where `workspaceCrn` and `accessKey` // are both required (and so present at runtime); the union widens their // static types to `string | undefined`, hence the casts. Guard at runtime // so plain JS / Deno callers that bypass the compile-time union fail loudly // instead of forwarding `undefined` into `AccessKeyStrategy.create`. if (!cfg.workspaceCrn || !cfg.accessKey) { throw new Error( - '[encryption]: `config.workspaceCrn` and `config.accessKey` are required when `config.strategy` is not provided.', + '[encryption]: `config.workspaceCrn` and `config.accessKey` are required when no auth strategy is provided.', ) } // `AccessKeyStrategy.create` takes the full workspace CRN — the region is From 9e082520fbaa3a9c8878ddcff89b4207ec625e5e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:48:59 +1000 Subject: [PATCH 4/4] fix(stack): name the actual field in wasm auth-strategy exclusion error When the deprecated `config.strategy` is set alongside `accessKey`, the mutual-exclusion error now says `config.strategy` (not the resolved `config.authStrategy`), so it names the field the caller actually set. Tests assert the precise message for both the `authStrategy` and deprecated `strategy` cases. --- packages/stack/__tests__/wasm-inline-strategy.test.ts | 11 ++++++++--- packages/stack/src/wasm-inline.ts | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/stack/__tests__/wasm-inline-strategy.test.ts b/packages/stack/__tests__/wasm-inline-strategy.test.ts index febaa2ec..281fedf2 100644 --- a/packages/stack/__tests__/wasm-inline-strategy.test.ts +++ b/packages/stack/__tests__/wasm-inline-strategy.test.ts @@ -130,12 +130,14 @@ describe('wasm-inline resolveStrategy', () => { expect(() => // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — JS callers bypass the compile-time union resolveStrategy(both as any), - ).toThrowError(/mutually exclusive/) + ).toThrowError( + /`config\.authStrategy` and `config\.accessKey` are mutually exclusive/, + ) // The guard must short-circuit *before* building a strategy. expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled() }) - it('throws when the deprecated strategy and accessKey are both supplied', () => { + it('throws when the deprecated strategy and accessKey are both supplied, naming `strategy`', () => { const both = { workspaceCrn: CRN, accessKey: 'CSAK.test', @@ -144,7 +146,10 @@ describe('wasm-inline resolveStrategy', () => { expect(() => // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — JS callers bypass the compile-time union resolveStrategy(both as any), - ).toThrowError(/mutually exclusive/) + ).toThrowError( + // Names the field the caller actually set, not the resolved `authStrategy`. + /`config\.strategy` and `config\.accessKey` are mutually exclusive/, + ) expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled() }) }) diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index f97ed176..378a7eaf 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -445,8 +445,11 @@ export function resolveStrategy(cfg: WasmClientConfig): WasmAuthStrategy { // runtime so a conflicting config fails loudly instead of silently // preferring one. if (authStrategy && cfg.accessKey) { + // Name the field the caller actually set — `strategy` when only the + // deprecated alias was used — so the message isn't misleading. + const field = cfg.authStrategy ? 'authStrategy' : 'strategy' throw new Error( - '[encryption]: `config.authStrategy` and `config.accessKey` are mutually exclusive — pass exactly one.', + `[encryption]: \`config.${field}\` and \`config.accessKey\` are mutually exclusive — pass exactly one.`, ) } if (authStrategy) return authStrategy