From bad2a34ee0d5146a0393385d8cb80a7f774a2da4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 17:01:15 +1000 Subject: [PATCH 1/8] test(stack): assert the deprecation warning fires at most once per process Cover the once-per-process latch on both the Node and WASM entries: two successive Encryption()/resolveStrategy() calls in one test (no beforeEach reset between them) must warn exactly once. A regression dropping the `if (warnedStrategyDeprecated) return` guard would warn twice and fail these. --- packages/stack/__tests__/init-strategy.test.ts | 14 ++++++++++++++ .../stack/__tests__/wasm-inline-strategy.test.ts | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index 65766d4b9..857885572 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -165,6 +165,20 @@ describe('Encryption config.strategy (deprecated alias)', () => { expect(warnSpy).not.toHaveBeenCalled() }) + + it('warns at most once per process across repeated Encryption calls', async () => { + // No reset between the two calls (the latch is only reset in beforeEach), + // so they share one process-level latch — a regression that dropped the + // `if (warnedStrategyDeprecated) return` guard would warn twice here. + const strategy: AuthStrategy = { + getToken: vi.fn(async () => ({ token: 'service-token' })), + } + + await Encryption({ schemas: [users], config: { strategy } }) + await Encryption({ schemas: [users], config: { strategy } }) + + expect(warnSpy).toHaveBeenCalledTimes(1) + }) }) // A minimal structural EQL v3 table: what marks a table as v3 for wire-format diff --git a/packages/stack/__tests__/wasm-inline-strategy.test.ts b/packages/stack/__tests__/wasm-inline-strategy.test.ts index 281fedf27..b335cb9ee 100644 --- a/packages/stack/__tests__/wasm-inline-strategy.test.ts +++ b/packages/stack/__tests__/wasm-inline-strategy.test.ts @@ -87,6 +87,19 @@ describe('wasm-inline resolveStrategy', () => { ) }) + it('warns at most once per process across repeated resolveStrategy calls', () => { + // No reset between the two calls (the latch is only reset in beforeEach), + // so they share one process-level latch — a regression that dropped the + // `if (warnedStrategyDeprecated) return` guard would warn twice here. + const explicit = { getToken: vi.fn() } + // biome-ignore lint/suspicious/noExplicitAny: exercise the deprecated strategy arm directly + resolveStrategy({ strategy: explicit } as any) + // biome-ignore lint/suspicious/noExplicitAny: exercise the deprecated strategy arm directly + resolveStrategy({ strategy: explicit } as any) + + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + it('prefers authStrategy over the deprecated strategy when both are set, and still warns', () => { const authStrategy = { getToken: vi.fn() } const strategy = { getToken: vi.fn() } From a5f5422ee4a74eeb65b5c642180dbbc92e3dc3de Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 12:51:47 +1000 Subject: [PATCH 2/8] feat: bump @cipherstash/auth to 0.41 and migrate to Result API `@cipherstash/auth` 0.41 switches every fallible auth operation to return a `@byteslice/result` `Result` (`{ data }` / `{ failure }`) instead of throwing, and renames the `AuthError` type to `AuthFailure` (a `.type`-keyed discriminated union replacing `error.code`). - catalog: bump `@cipherstash/auth` + 6 platform bindings 0.40.0 -> 0.41.0 in pnpm-workspace.yaml and root package.json; update lockfile - stack: re-export `AuthFailure` (was `AuthError`); unwrap the Result from `AccessKeyStrategy.create` in the wasm-inline `resolveStrategy` - cli: unwrap Result in `stash auth login` device-code flow, `bindClientDevice`, and the `init` existing-auth check - wizard: unwrap Result in gateway token fetch, agent token helper, and the credential prerequisite check (`error.code` -> `failure.type`) - tests: update auth mocks/stubs to the Result shape - changeset: stack minor (breaking type surface), stash + wizard patch Refs #567 --- .changeset/stack-auth-0-41-result-api.md | 16 ++ packages/cli/src/commands/auth/login.ts | 37 +++-- .../src/commands/init/steps/authenticate.ts | 15 +- .../helpers/stub-auth-wasm-inline.ts | 4 + .../__tests__/wasm-inline-new-client.test.ts | 4 +- .../__tests__/wasm-inline-strategy.test.ts | 4 +- packages/stack/src/index.ts | 6 +- packages/stack/src/wasm-inline.ts | 12 +- .../src/__tests__/prerequisites.test.ts | 14 +- packages/wizard/src/agent/fetch-prompt.ts | 24 ++- packages/wizard/src/agent/interface.ts | 11 +- packages/wizard/src/lib/prerequisites.ts | 30 ++-- pnpm-lock.yaml | 143 +++++++++--------- pnpm-workspace.yaml | 14 +- 14 files changed, 220 insertions(+), 114 deletions(-) create mode 100644 .changeset/stack-auth-0-41-result-api.md diff --git a/.changeset/stack-auth-0-41-result-api.md b/.changeset/stack-auth-0-41-result-api.md new file mode 100644 index 000000000..37c924305 --- /dev/null +++ b/.changeset/stack-auth-0-41-result-api.md @@ -0,0 +1,16 @@ +--- +"@cipherstash/stack": minor +"@cipherstash/wizard": patch +"stash": patch +--- + +Bump `@cipherstash/auth` (and its per-platform native bindings) from `0.40.0` to `0.41.0`, and migrate to its new `Result`-returning API. + +**What changed in `@cipherstash/auth` `0.41`.** Every fallible auth operation now returns a `@byteslice/result` `Result` (`{ data }` on success, `{ failure }` on error) instead of throwing. This covers strategy construction (`AccessKeyStrategy.create`, `OidcFederationStrategy.create`, `AutoStrategy.detect`, `DeviceSessionStrategy.fromProfile`), `getToken()`, and the device-code flow (`beginDeviceCodeFlow`, `pollForToken`, `openInBrowser`, `bindClientDevice`). Consumers now write `if (result.failure) …` and read `result.data` rather than `try/catch`. The `AuthError` type was renamed to **`AuthFailure`** — a discriminated union keyed by `type` (`"NOT_AUTHENTICATED"`, `"WORKSPACE_MISMATCH"`, …), replacing the old `error.code` string. + +**`@cipherstash/stack` (breaking type surface).** + +- **`AuthError` is renamed to `AuthFailure`** in the public re-exports from `@cipherstash/stack`. `AuthErrorCode` and `TokenResult` are unchanged. Anyone importing `AuthError` from `@cipherstash/stack` must switch to `AuthFailure`. +- The WASM-inline access-key path (`resolveStrategy`, used by `@cipherstash/stack/wasm-inline`'s `Encryption()`) now unwraps the `Result` from `AccessKeyStrategy.create`. A construction failure (e.g. an invalid CRN or access key) throws a descriptive `[encryption]` error naming the `AuthFailure.type` instead of surfacing the raw auth error. + +**`stash` (CLI) and `@cipherstash/wizard`.** Internal auth call sites (`stash auth login`, device binding, `init` auth check, and the wizard's token acquisition / prerequisite check) were updated to unwrap `Result` and branch on `failure.type`. Behaviour is preserved — auth failures still surface the same way to end users; no CLI/wizard API changed. diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 3ce022323..1a7e375ce 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -34,23 +34,35 @@ export async function login(region: string, _referrer: string | undefined) { // Must be 'cli' — it's the only OAuth client_id registered with CTS. // Passing anything else (e.g. `cli-supabase`) causes INVALID_CLIENT. + // As of `@cipherstash/auth` `0.41`, the device-code flow returns + // `Result` instead of throwing — unwrap each step. const pending = await beginDeviceCodeFlow(region, 'cli') + if (pending.failure) { + p.log.error(pending.failure.error.message) + process.exit(1) + } + const flow = pending.data - p.log.info(`Your code is: ${pending.userCode}`) - p.log.info(`Visit: ${pending.verificationUriComplete}`) - p.log.info(`Code expires in: ${pending.expiresIn}s`) + p.log.info(`Your code is: ${flow.userCode}`) + p.log.info(`Visit: ${flow.verificationUriComplete}`) + p.log.info(`Code expires in: ${flow.expiresIn}s`) - const opened = pending.openInBrowser() - if (!opened) { + const opened = flow.openInBrowser() + if (opened.failure || !opened.data) { p.log.warn('Could not open browser — please visit the URL above manually.') } s.start('Waiting for authorization...') - const auth = await pending.pollForToken() + const auth = await flow.pollForToken() + if (auth.failure) { + s.stop('Authentication failed!') + p.log.error(auth.failure.error.message) + process.exit(1) + } s.stop('Authenticated!') p.log.info( - `Token expires at: ${new Date(auth.expiresAt * 1000).toISOString()}`, + `Token expires at: ${new Date(auth.data.expiresAt * 1000).toISOString()}`, ) } @@ -58,12 +70,13 @@ export async function bindDevice() { const s = p.spinner() s.start('Binding device to the default Keyset...') - try { - await bindClientDevice() - s.stop('Your device has been bound to the default Keyset!') - } catch (error) { + // `bindClientDevice()` returns `Result` as of + // `@cipherstash/auth` `0.41` — a failure no longer throws. + const result = await bindClientDevice() + if (result.failure) { s.stop('Failed to bind your device to the default Keyset!') - p.log.error(error instanceof Error ? error.message : 'Unknown error') + p.log.error(result.failure.error.message) process.exit(1) } + s.stop('Your device has been bound to the default Keyset!') } diff --git a/packages/cli/src/commands/init/steps/authenticate.ts b/packages/cli/src/commands/init/steps/authenticate.ts index 163581f61..9e2a10049 100644 --- a/packages/cli/src/commands/init/steps/authenticate.ts +++ b/packages/cli/src/commands/init/steps/authenticate.ts @@ -16,13 +16,20 @@ interface ExistingAuth { */ async function checkExistingAuth(): Promise { try { - const strategy = AutoStrategy.detect() - const result = await strategy.getToken() + // As of `@cipherstash/auth` `0.41`, `detect()` and `getToken()` return a + // `Result` instead of throwing — a failure at either step + // just means "not authenticated yet", so fall through to `undefined`. + const detected = AutoStrategy.detect() + if (detected.failure) return undefined - const regionEntry = regions.find((r) => result.issuer.includes(r.value)) + const result = await detected.data.getToken() + if (result.failure) return undefined + + const { issuer, workspaceId } = result.data + const regionEntry = regions.find((r) => issuer.includes(r.value)) const regionLabel = regionEntry?.label ?? 'unknown' - return { workspace: result.workspaceId, regionLabel } + return { workspace: workspaceId, regionLabel } } catch { return undefined } diff --git a/packages/stack/__tests__/helpers/stub-auth-wasm-inline.ts b/packages/stack/__tests__/helpers/stub-auth-wasm-inline.ts index 237111050..386a7eb4b 100644 --- a/packages/stack/__tests__/helpers/stub-auth-wasm-inline.ts +++ b/packages/stack/__tests__/helpers/stub-auth-wasm-inline.ts @@ -6,6 +6,10 @@ * lets Vitest load `src/wasm-inline` for pure-helper unit tests. Aliased in via * `vitest.config.ts`. */ +// `@cipherstash/auth` `0.41` `create` returns a `Result` +// rather than throwing. These stubs are only reached by tests that don't +// override the module with `vi.mock`; they still throw loudly so an +// unexpectedly-exercised path fails visibly rather than silently. export const AccessKeyStrategy = { create: (): never => { throw new Error( diff --git a/packages/stack/__tests__/wasm-inline-new-client.test.ts b/packages/stack/__tests__/wasm-inline-new-client.test.ts index 2fcd2b57f..c7a1e5564 100644 --- a/packages/stack/__tests__/wasm-inline-new-client.test.ts +++ b/packages/stack/__tests__/wasm-inline-new-client.test.ts @@ -18,7 +18,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@cipherstash/auth/wasm-inline', () => ({ AccessKeyStrategy: { - create: vi.fn(() => ({ __mock: 'access-key-strategy' })), + // `@cipherstash/auth` `0.41` `create` returns a `Result` + // (`{ data }` on success) — `resolveStrategy` unwraps `.data`. + create: vi.fn(() => ({ data: { __mock: 'access-key-strategy' } })), }, OidcFederationStrategy: class {}, })) diff --git a/packages/stack/__tests__/wasm-inline-strategy.test.ts b/packages/stack/__tests__/wasm-inline-strategy.test.ts index b335cb9ee..a0a7e3a39 100644 --- a/packages/stack/__tests__/wasm-inline-strategy.test.ts +++ b/packages/stack/__tests__/wasm-inline-strategy.test.ts @@ -17,7 +17,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@cipherstash/auth/wasm-inline', () => ({ AccessKeyStrategy: { - create: vi.fn(() => ({ __mock: 'access-key-strategy' })), + // `@cipherstash/auth` `0.41` `create` returns a `Result` + // (`{ data }` on success) — `resolveStrategy` unwraps `.data`. + create: vi.fn(() => ({ data: { __mock: 'access-key-strategy' } })), }, OidcFederationStrategy: class {}, })) diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 32d4f48ed..2274c86ed 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -36,6 +36,10 @@ export type OidcFederationStrategy = InstanceType< typeof auth.OidcFederationStrategy > -export type { AuthError, AuthErrorCode, TokenResult } from '@cipherstash/auth' +export type { + AuthErrorCode, + AuthFailure, + TokenResult, +} from '@cipherstash/auth' // Re-export types for convenience export type { Encrypted } from '@/types' diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 378a7eafa..0998c53d7 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -465,6 +465,14 @@ export function resolveStrategy(cfg: WasmClientConfig): WasmAuthStrategy { } // `AccessKeyStrategy.create` takes the full workspace CRN — the region is // derived from it inside `@cipherstash/auth`, so the CRN stays the single - // source of truth with no manual region split. - return AccessKeyStrategy.create(cfg.workspaceCrn, cfg.accessKey) + // source of truth with no manual region split. As of `@cipherstash/auth` + // `0.41` `create` returns a `Result` rather + // than throwing — unwrap it and surface a construction failure loudly. + const result = AccessKeyStrategy.create(cfg.workspaceCrn, cfg.accessKey) + if (result.failure) { + throw new Error( + `[encryption]: failed to construct \`AccessKeyStrategy\` from \`config.workspaceCrn\` / \`config.accessKey\` (${result.failure.type}): ${result.failure.error.message}`, + ) + } + return result.data } diff --git a/packages/wizard/src/__tests__/prerequisites.test.ts b/packages/wizard/src/__tests__/prerequisites.test.ts index f57fddb35..3436ae245 100644 --- a/packages/wizard/src/__tests__/prerequisites.test.ts +++ b/packages/wizard/src/__tests__/prerequisites.test.ts @@ -5,14 +5,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { checkPrerequisites } from '../lib/prerequisites.js' // Force the auth check to fail so we exercise the missing-list copy. +// `@cipherstash/auth` `0.41`: `detect()` returns `Result` +// and `getToken()` returns `Result` (no throwing). +// A `NOT_AUTHENTICATED` failure is what `hasCredentials` reads as "missing". vi.mock('@cipherstash/auth', () => ({ default: { AutoStrategy: { detect: () => ({ - getToken: async () => { - const err = new Error('not authed') as Error & { code: string } - err.code = 'NOT_AUTHENTICATED' - throw err + data: { + getToken: async () => ({ + failure: { + type: 'NOT_AUTHENTICATED', + error: new Error('not authed'), + }, + }), }, }), }, diff --git a/packages/wizard/src/agent/fetch-prompt.ts b/packages/wizard/src/agent/fetch-prompt.ts index 4d38ee56f..ae9b09890 100644 --- a/packages/wizard/src/agent/fetch-prompt.ts +++ b/packages/wizard/src/agent/fetch-prompt.ts @@ -29,8 +29,28 @@ export async function fetchIntegrationPrompt( const { ctx, cliVersion, runner } = options const mode: WizardMode = options.mode ?? 'implement' - const strategy = AutoStrategy.detect() - const { token } = await strategy.getToken() + // As of `@cipherstash/auth` `0.41`, `detect()` and `getToken()` return a + // `Result` instead of throwing — unwrap both, surfacing an + // auth failure as a formatted wizard error. + const detected = AutoStrategy.detect() + if (detected.failure) { + throw new Error( + formatWizardError( + 'Could not authenticate with CipherStash.', + detected.failure.error.message, + ), + ) + } + const tokenResult = await detected.data.getToken() + if (tokenResult.failure) { + throw new Error( + formatWizardError( + 'Could not authenticate with CipherStash.', + tokenResult.failure.error.message, + ), + ) + } + const { token } = tokenResult.data let res: Response try { diff --git a/packages/wizard/src/agent/interface.ts b/packages/wizard/src/agent/interface.ts index 2c1eceacf..b4d7b2abf 100644 --- a/packages/wizard/src/agent/interface.ts +++ b/packages/wizard/src/agent/interface.ts @@ -204,9 +204,14 @@ export function wizardCanUseTool( */ async function getAccessToken(): Promise { try { - const strategy = AutoStrategy.detect() - const result = await strategy.getToken() - return result.token + // As of `@cipherstash/auth` `0.41`, `detect()` and `getToken()` return a + // `Result` instead of throwing — a failure at either step + // means no usable token, so fall through to `undefined`. + const detected = AutoStrategy.detect() + if (detected.failure) return undefined + const result = await detected.data.getToken() + if (result.failure) return undefined + return result.data.token } catch { return undefined } diff --git a/packages/wizard/src/lib/prerequisites.ts b/packages/wizard/src/lib/prerequisites.ts index fcf234ac8..faa909e83 100644 --- a/packages/wizard/src/lib/prerequisites.ts +++ b/packages/wizard/src/lib/prerequisites.ts @@ -37,16 +37,28 @@ export async function checkPrerequisites( // between auth versions and duplicating it in the CLI is what caused // CIP-2996 in the first place. async function hasCredentials(): Promise { - try { - await auth.AutoStrategy.detect().getToken() - return true - } catch (error) { - const code = (error as { code?: string } | null)?.code - if (code === 'NOT_AUTHENTICATED' || code === 'MISSING_WORKSPACE_CRN') { - return false - } - throw error + // As of `@cipherstash/auth` `0.41`, `detect()` and `getToken()` return a + // `Result` instead of throwing. The `failure.type` + // discriminant carries what used to be `error.code`: a "not authenticated" + // failure means no credentials (return false); any other failure is + // unexpected and rethrown. + const notAuthenticated = (failure: { type: string }): boolean => + failure.type === 'NOT_AUTHENTICATED' || + failure.type === 'MISSING_WORKSPACE_CRN' + + const detected = auth.AutoStrategy.detect() + if (detected.failure) { + if (notAuthenticated(detected.failure)) return false + throw detected.failure.error + } + + const result = await detected.data.getToken() + if (result.failure) { + if (notAuthenticated(result.failure)) return false + throw result.failure.error } + + return true } /** Walk up from cwd to find stash.config.ts. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b2f10707..536334a69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,26 +7,26 @@ settings: catalogs: repo: '@cipherstash/auth': - specifier: 0.40.0 - version: 0.40.0 + specifier: 0.41.0 + version: 0.41.0 '@cipherstash/auth-darwin-arm64': - specifier: 0.40.0 - version: 0.40.0 + specifier: 0.41.0 + version: 0.41.0 '@cipherstash/auth-darwin-x64': - specifier: 0.40.0 - version: 0.40.0 + specifier: 0.41.0 + version: 0.41.0 '@cipherstash/auth-linux-arm64-gnu': - specifier: 0.40.0 - version: 0.40.0 + specifier: 0.41.0 + version: 0.41.0 '@cipherstash/auth-linux-x64-gnu': - specifier: 0.40.0 - version: 0.40.0 + specifier: 0.41.0 + version: 0.41.0 '@cipherstash/auth-linux-x64-musl': - specifier: 0.40.0 - version: 0.40.0 + specifier: 0.41.0 + version: 0.41.0 '@cipherstash/auth-win32-x64-msvc': - specifier: 0.40.0 - version: 0.40.0 + specifier: 0.41.0 + version: 0.41.0 tsup: specifier: 8.5.1 version: 8.5.1 @@ -235,7 +235,7 @@ importers: dependencies: '@cipherstash/auth': specifier: catalog:repo - version: 0.40.0(@cipherstash/auth-darwin-arm64@0.40.0)(@cipherstash/auth-darwin-x64@0.40.0)(@cipherstash/auth-linux-arm64-gnu@0.40.0)(@cipherstash/auth-linux-x64-gnu@0.40.0)(@cipherstash/auth-linux-x64-musl@0.40.0)(@cipherstash/auth-win32-x64-msvc@0.40.0) + version: 0.41.0(@cipherstash/auth-darwin-arm64@0.41.0)(@cipherstash/auth-darwin-x64@0.41.0)(@cipherstash/auth-linux-arm64-gnu@0.41.0)(@cipherstash/auth-linux-x64-gnu@0.41.0)(@cipherstash/auth-linux-x64-musl@0.41.0)(@cipherstash/auth-win32-x64-msvc@0.41.0) '@cipherstash/migrate': specifier: workspace:* version: link:../migrate @@ -288,22 +288,22 @@ importers: optionalDependencies: '@cipherstash/auth-darwin-arm64': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-darwin-x64': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-linux-arm64-gnu': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-linux-x64-gnu': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-linux-x64-musl': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-win32-x64-msvc': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 packages/drizzle: dependencies: @@ -576,7 +576,7 @@ importers: version: 0.2.0 '@cipherstash/auth': specifier: catalog:repo - version: 0.40.0(@cipherstash/auth-darwin-arm64@0.40.0)(@cipherstash/auth-darwin-x64@0.40.0)(@cipherstash/auth-linux-arm64-gnu@0.40.0)(@cipherstash/auth-linux-x64-gnu@0.40.0)(@cipherstash/auth-linux-x64-musl@0.40.0)(@cipherstash/auth-win32-x64-msvc@0.40.0) + version: 0.41.0(@cipherstash/auth-darwin-arm64@0.41.0)(@cipherstash/auth-darwin-x64@0.41.0)(@cipherstash/auth-linux-arm64-gnu@0.41.0)(@cipherstash/auth-linux-x64-gnu@0.41.0)(@cipherstash/auth-linux-x64-musl@0.41.0)(@cipherstash/auth-win32-x64-msvc@0.41.0) '@cipherstash/protect-ffi': specifier: 0.27.0 version: 0.27.0 @@ -632,22 +632,22 @@ importers: optionalDependencies: '@cipherstash/auth-darwin-arm64': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-darwin-x64': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-linux-arm64-gnu': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-linux-x64-gnu': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-linux-x64-musl': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-win32-x64-msvc': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 packages/wizard: dependencies: @@ -659,7 +659,7 @@ importers: version: 0.106.0(zod@3.25.76) '@cipherstash/auth': specifier: catalog:repo - version: 0.40.0(@cipherstash/auth-darwin-arm64@0.40.0)(@cipherstash/auth-darwin-x64@0.40.0)(@cipherstash/auth-linux-arm64-gnu@0.40.0)(@cipherstash/auth-linux-x64-gnu@0.40.0)(@cipherstash/auth-linux-x64-musl@0.40.0)(@cipherstash/auth-win32-x64-msvc@0.40.0) + version: 0.41.0(@cipherstash/auth-darwin-arm64@0.41.0)(@cipherstash/auth-darwin-x64@0.41.0)(@cipherstash/auth-linux-arm64-gnu@0.41.0)(@cipherstash/auth-linux-x64-gnu@0.41.0)(@cipherstash/auth-linux-x64-musl@0.41.0)(@cipherstash/auth-win32-x64-msvc@0.41.0) '@clack/prompts': specifier: 1.4.0 version: 1.4.0 @@ -697,22 +697,22 @@ importers: optionalDependencies: '@cipherstash/auth-darwin-arm64': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-darwin-x64': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-linux-arm64-gnu': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-linux-x64-gnu': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-linux-x64-musl': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 '@cipherstash/auth-win32-x64-msvc': specifier: catalog:repo - version: 0.40.0 + version: 0.41.0 packages: @@ -855,6 +855,9 @@ packages: '@byteslice/result@0.2.0': resolution: {integrity: sha512-4zENy+fMQuZ5vvoK+W685qdkPu7FBJ8lo5t5XSUAbqvixsiTzvAGDkxsrAR9WwqlvY3OgitWo8ciCXLSURcNLw==} + '@byteslice/result@0.3.0': + resolution: {integrity: sha512-nIVjzVJ5LEUsj5jZ196OQ+XFYDPw74JVmHrsKs0g/iX1c8llydLWKbseMKmAtcEnpRj8hdsEue5H/naz7wdC4A==} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -910,48 +913,48 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/auth-darwin-arm64@0.40.0': - resolution: {integrity: sha512-TMVgX+dTiP4zvDEgKDDe92SQSeTZarRMq4+StUFRCJrefRVfjtnNT0zqhWmpZelCxOtnL7Ch+mq+TKRmYCY1cQ==} + '@cipherstash/auth-darwin-arm64@0.41.0': + resolution: {integrity: sha512-2B+J/bPblfiWQcxm3Z9voLhV66TZcmDpzVz5aBNS4y/B7Ke55jGH6m3H9g+c1xHZbe62q7zwDUEatveOy69f2A==} cpu: [arm64] os: [darwin] - '@cipherstash/auth-darwin-x64@0.40.0': - resolution: {integrity: sha512-G8fmOqWrOTE5cO3CwVSSUAqlJXKvvQjHlKzT1aKR8KC9GDExSAmaQMiV7wNDI1NonHq9ERRi2MQ2mYNmfRTqxA==} + '@cipherstash/auth-darwin-x64@0.41.0': + resolution: {integrity: sha512-XrmFRSdQaoed7tqJ+RJLiBvuQr/v7qiSu7LDA3PCVbegHVcsjYQ9D0sd49zDZEEFN4zZlcwmeh80eGAEJiyhKg==} cpu: [x64] os: [darwin] - '@cipherstash/auth-linux-arm64-gnu@0.40.0': - resolution: {integrity: sha512-3c4blsy35OUGK+SaOI9SIQCZW7zByuBYK6NR+HNde6RTXtxPOY2h9bpg72MMKOAvU/5KWkCvDR+jrMammAWRIg==} + '@cipherstash/auth-linux-arm64-gnu@0.41.0': + resolution: {integrity: sha512-NQJK++HzV7XCQPuNbVmIJcC5T4QuflOySYmQtP2PoTg0p+V7WK/3yruZZKgjbbCU6v8tek/D3T8hk02snq/8sQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@cipherstash/auth-linux-x64-gnu@0.40.0': - resolution: {integrity: sha512-Tul+CoQGvhdZCMrPGjKa7/YiHtpr/h4vzLRxX9C8vUHfM0r26d3JvHLJsJPz4wsiBTXfcbfQ9gcV/UEkPhn4vg==} + '@cipherstash/auth-linux-x64-gnu@0.41.0': + resolution: {integrity: sha512-9NMiMs2p8p5NBcir8Cx90zBzOlkM0EWbIZOeCIjokT9X4d6UJy3+JhXi5VRn0gWpqAA7i7+BZD+puZ+fmBzJLQ==} cpu: [x64] os: [linux] libc: [glibc] - '@cipherstash/auth-linux-x64-musl@0.40.0': - resolution: {integrity: sha512-qW5IhDWgIEVZ94oWrI/P0LUqMYd6BdLMRDirevWiTqc0Bfw8PaYe7bfds1MOfzx59kWJqedugVbpf/VApO448A==} + '@cipherstash/auth-linux-x64-musl@0.41.0': + resolution: {integrity: sha512-V8QwrxXLoqO0ED02hnvQJNBid/iDZUawt3zobUL+DqieN3QZxDxI5/viFIkA4bTg7CV6oSmy2b0rINGqiHvkig==} cpu: [x64] os: [linux] libc: [musl] - '@cipherstash/auth-win32-x64-msvc@0.40.0': - resolution: {integrity: sha512-V66Y6p3RPFb4o1M6jB4LRLGBciISqxVOkDYU000ehkk12Ztu2hd1IHihHtq9zxQtlXFbMVJUeWNtxbL4Etmifw==} + '@cipherstash/auth-win32-x64-msvc@0.41.0': + resolution: {integrity: sha512-MkC04kTuyUd7J2/dRWIzdER1if6V+g6tVDRoZI/dql/Uq/+FXtV9lBXDcS1iATDyE/1APLvMMvuAqJeELGrGag==} cpu: [x64] os: [win32] - '@cipherstash/auth@0.40.0': - resolution: {integrity: sha512-UARvsiy29HhxsBZM7cqntIqwqNOVEbuHz1JAkZO5gfFaB927BnDfC5lP5dgU7xjm5NN1MoGfBVSVK9jVMhquog==} + '@cipherstash/auth@0.41.0': + resolution: {integrity: sha512-jhhCthk+vilCQAvOfKYPSnIOZR2J3/+0JEYLNY3+M19rEzZ2ZmTLZDqzu4c+y1P73LgzTX+oWL6ESUoKsMd0TQ==} peerDependencies: - '@cipherstash/auth-darwin-arm64': 0.40.0 - '@cipherstash/auth-darwin-x64': 0.40.0 - '@cipherstash/auth-linux-arm64-gnu': 0.40.0 - '@cipherstash/auth-linux-x64-gnu': 0.40.0 - '@cipherstash/auth-linux-x64-musl': 0.40.0 - '@cipherstash/auth-win32-x64-msvc': 0.40.0 + '@cipherstash/auth-darwin-arm64': 0.41.0 + '@cipherstash/auth-darwin-x64': 0.41.0 + '@cipherstash/auth-linux-arm64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-musl': 0.41.0 + '@cipherstash/auth-win32-x64-msvc': 0.41.0 peerDependenciesMeta: '@cipherstash/auth-darwin-arm64': optional: true @@ -4137,6 +4140,8 @@ snapshots: '@byteslice/result@0.2.0': {} + '@byteslice/result@0.3.0': {} + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -4280,32 +4285,34 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@cipherstash/auth-darwin-arm64@0.40.0': + '@cipherstash/auth-darwin-arm64@0.41.0': optional: true - '@cipherstash/auth-darwin-x64@0.40.0': + '@cipherstash/auth-darwin-x64@0.41.0': optional: true - '@cipherstash/auth-linux-arm64-gnu@0.40.0': + '@cipherstash/auth-linux-arm64-gnu@0.41.0': optional: true - '@cipherstash/auth-linux-x64-gnu@0.40.0': + '@cipherstash/auth-linux-x64-gnu@0.41.0': optional: true - '@cipherstash/auth-linux-x64-musl@0.40.0': + '@cipherstash/auth-linux-x64-musl@0.41.0': optional: true - '@cipherstash/auth-win32-x64-msvc@0.40.0': + '@cipherstash/auth-win32-x64-msvc@0.41.0': optional: true - '@cipherstash/auth@0.40.0(@cipherstash/auth-darwin-arm64@0.40.0)(@cipherstash/auth-darwin-x64@0.40.0)(@cipherstash/auth-linux-arm64-gnu@0.40.0)(@cipherstash/auth-linux-x64-gnu@0.40.0)(@cipherstash/auth-linux-x64-musl@0.40.0)(@cipherstash/auth-win32-x64-msvc@0.40.0)': + '@cipherstash/auth@0.41.0(@cipherstash/auth-darwin-arm64@0.41.0)(@cipherstash/auth-darwin-x64@0.41.0)(@cipherstash/auth-linux-arm64-gnu@0.41.0)(@cipherstash/auth-linux-x64-gnu@0.41.0)(@cipherstash/auth-linux-x64-musl@0.41.0)(@cipherstash/auth-win32-x64-msvc@0.41.0)': + dependencies: + '@byteslice/result': 0.3.0 optionalDependencies: - '@cipherstash/auth-darwin-arm64': 0.40.0 - '@cipherstash/auth-darwin-x64': 0.40.0 - '@cipherstash/auth-linux-arm64-gnu': 0.40.0 - '@cipherstash/auth-linux-x64-gnu': 0.40.0 - '@cipherstash/auth-linux-x64-musl': 0.40.0 - '@cipherstash/auth-win32-x64-msvc': 0.40.0 + '@cipherstash/auth-darwin-arm64': 0.41.0 + '@cipherstash/auth-darwin-x64': 0.41.0 + '@cipherstash/auth-linux-arm64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-musl': 0.41.0 + '@cipherstash/auth-win32-x64-msvc': 0.41.0 '@cipherstash/protect-ffi-darwin-arm64@0.23.0': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f4ab662cb..b52bf6747 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,13 +11,13 @@ catalogs: # declare them as `optionalDependencies` via `catalog:repo`. Keep # all seven entries on the same version so a single bump here moves # everything in lockstep. - '@cipherstash/auth': 0.40.0 - '@cipherstash/auth-darwin-arm64': 0.40.0 - '@cipherstash/auth-darwin-x64': 0.40.0 - '@cipherstash/auth-linux-arm64-gnu': 0.40.0 - '@cipherstash/auth-linux-x64-gnu': 0.40.0 - '@cipherstash/auth-linux-x64-musl': 0.40.0 - '@cipherstash/auth-win32-x64-msvc': 0.40.0 + '@cipherstash/auth': 0.41.0 + '@cipherstash/auth-darwin-arm64': 0.41.0 + '@cipherstash/auth-darwin-x64': 0.41.0 + '@cipherstash/auth-linux-arm64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-gnu': 0.41.0 + '@cipherstash/auth-linux-x64-musl': 0.41.0 + '@cipherstash/auth-win32-x64-msvc': 0.41.0 tsup: 8.5.1 tsx: 4.22.1 typescript: 5.9.3 From 54a71ae819d720a07288a85edccd5868188fe626 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 13:08:01 +1000 Subject: [PATCH 3/8] test(e2e): point wasm smoke test at auth 0.41 / protect-ffi 0.27 The Deno WASM e2e import map pinned `@cipherstash/auth@0.40.0`, but stack's built `dist/wasm-inline.js` now unwraps the `Result` returned by auth 0.41's `AccessKeyStrategy.create`. Under the old pin, `create` returned the strategy directly, so `result.data` was `undefined` and protect-ffi's `newClient` rejected with "opts.strategy is required". Point the e2e at the versions stack actually ships (auth 0.41.0, protect-ffi 0.27.0). Refs #567 --- e2e/wasm/deno.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/wasm/deno.json b/e2e/wasm/deno.json index 47901fe8a..23b2c1d70 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.26.0/wasm-inline", - "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.40.0/wasm-inline" + "@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.27.0/wasm-inline", + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.41.0/wasm-inline" } } From 90b43ba3cfba5fbe56da0c4233587117c008a839 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 13:37:19 +1000 Subject: [PATCH 4/8] test(e2e): exempt first-party pkgs from Deno's npm freshness cooldown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deno 2.9 applies a 24h `install.minimumDependencyAge` cooldown to npm resolution by default. The WASM smoke test resolves @cipherstash/auth and @cipherstash/protect-ffi through Deno's own npm resolver, so a freshly published first-party version (e.g. auth 0.41.0, <24h old) is rejected with "newer than the specified minimum dependency date" — turning CI red for ~24h after every bump. In the 1.0 lead-up these are bumped frequently. Mirror pnpm-workspace.yaml's minimumReleaseAge (7d) + minimumReleaseAgeExclude: set minimumDependencyAge to P7D and exclude the first-party @cipherstash packages, so third-party deps keep the cooldown while our own lockstep bumps are testable immediately. Refs #567 --- e2e/wasm/deno.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/e2e/wasm/deno.json b/e2e/wasm/deno.json index 23b2c1d70..5b0ce933e 100644 --- a/e2e/wasm/deno.json +++ b/e2e/wasm/deno.json @@ -2,6 +2,16 @@ "//1": "Deno smoke test for @cipherstash/stack/wasm-inline. Run `pnpm exec turbo run build --filter @cipherstash/stack` first so dist/ is fresh.", "//2": "stack is imported via a file URL because the wasm-inline subpath isn't on a published version yet — once stack ships with /wasm-inline this can switch to a plain npm: specifier. protect-ffi and auth resolve via npm: against the workspace's installed versions (Deno's nodeModulesDir: auto lets it use what pnpm fetched).", "//3": "No --allow-ffi grant. If protect-ffi ever silently fell back to a native binding under Deno, the test would fail on missing FFI permission — this is the WASM guarantee.", + "//4": "Deno 2.9+ applies a 24h npm freshness cooldown (`install.minimumDependencyAge`) by default. That blocks freshly-published first-party versions — @cipherstash/auth, @cipherstash/protect-ffi — which we bump in lockstep with stack and want tested immediately, not 24h later. Mirror `pnpm-workspace.yaml`'s `minimumReleaseAge` (7d) + `minimumReleaseAgeExclude` here: keep the cooldown for third-party deps, exempt first-party. See skills/stash-supply-chain-security/.", + "install": { + "minimumDependencyAge": { + "age": "P7D", + "exclude": [ + "npm:@cipherstash/auth", + "npm:@cipherstash/protect-ffi" + ] + } + }, "nodeModulesDir": "auto", "tasks": { "//task": "--no-check because TypeScript can't infer the column-on-table intersection types when stack is loaded via file URL (the dist .d.ts strips brand info). This test exists to verify RUNTIME WASM round-trips — type narrowing is covered by the package's own vitest suite.", From cb4d6a444ae7d1469ef11f40e4aea09044b8f3e3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 13:41:50 +1000 Subject: [PATCH 5/8] test(e2e): disable Deno npm cooldown for the wasm smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declarative `install.minimumDependencyAge` exclude added in the previous commit does not apply to `deno test`'s implicit npm resolution — Deno 2.9.1 still enforced the default 24h cooldown and rejected the freshly-published auth 0.41.0. Use the `--minimum-dependency-age=0` flag on the test command instead (confirmed supported by `deno test`). Safe: the smoke test resolves only first-party @cipherstash packages (auth, protect-ffi) plus JSR std — no third-party npm — and pnpm's 7d cooldown still governs every real install. This unblocks lockstep first-party bumps in the 1.0 lead-up without waiting 24h per version. Refs #567 --- e2e/wasm/deno.json | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/e2e/wasm/deno.json b/e2e/wasm/deno.json index 5b0ce933e..0ef163ea6 100644 --- a/e2e/wasm/deno.json +++ b/e2e/wasm/deno.json @@ -2,20 +2,11 @@ "//1": "Deno smoke test for @cipherstash/stack/wasm-inline. Run `pnpm exec turbo run build --filter @cipherstash/stack` first so dist/ is fresh.", "//2": "stack is imported via a file URL because the wasm-inline subpath isn't on a published version yet — once stack ships with /wasm-inline this can switch to a plain npm: specifier. protect-ffi and auth resolve via npm: against the workspace's installed versions (Deno's nodeModulesDir: auto lets it use what pnpm fetched).", "//3": "No --allow-ffi grant. If protect-ffi ever silently fell back to a native binding under Deno, the test would fail on missing FFI permission — this is the WASM guarantee.", - "//4": "Deno 2.9+ applies a 24h npm freshness cooldown (`install.minimumDependencyAge`) by default. That blocks freshly-published first-party versions — @cipherstash/auth, @cipherstash/protect-ffi — which we bump in lockstep with stack and want tested immediately, not 24h later. Mirror `pnpm-workspace.yaml`'s `minimumReleaseAge` (7d) + `minimumReleaseAgeExclude` here: keep the cooldown for third-party deps, exempt first-party. See skills/stash-supply-chain-security/.", - "install": { - "minimumDependencyAge": { - "age": "P7D", - "exclude": [ - "npm:@cipherstash/auth", - "npm:@cipherstash/protect-ffi" - ] - } - }, + "//4": "Deno 2.9+ applies a 24h npm freshness cooldown to `deno test`'s npm resolution by default (error: \"newer than the specified minimum dependency date\"). The only npm deps here are the first-party @cipherstash packages below, which we bump in lockstep with stack and need tested immediately — not 24h later, which in the 1.0 lead-up (multiple bumps/day) would keep this job red almost continuously. The declarative `install.minimumDependencyAge` `exclude` form does NOT apply to `deno test`, so disable the cooldown for this run with `--minimum-dependency-age=0`. This is safe here: no third-party npm is resolved, and pnpm's own 7d cooldown (pnpm-workspace.yaml `minimumReleaseAge` + `minimumReleaseAgeExclude`) still governs every real install. See skills/stash-supply-chain-security/.", "nodeModulesDir": "auto", "tasks": { "//task": "--no-check because TypeScript can't infer the column-on-table intersection types when stack is loaded via file URL (the dist .d.ts strips brand info). This test exists to verify RUNTIME WASM round-trips — type narrowing is covered by the package's own vitest suite.", - "test": "deno test --no-check --allow-env --allow-net --allow-read --allow-sys --no-prompt" + "test": "deno test --minimum-dependency-age=0 --no-check --allow-env --allow-net --allow-read --allow-sys --no-prompt" }, "imports": { "@cipherstash/stack/wasm-inline": "../../packages/stack/dist/wasm-inline.js", From 1e18cb7c0c0a3c05a678358aa43f787f32b5d77c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 13:48:44 +1000 Subject: [PATCH 6/8] test(e2e): resolve wasm deps from pnpm install, not npm registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deno 2.9's default 24h npm freshness cooldown kept rejecting freshly published first-party versions (auth 0.41.0), and neither the `install.minimumDependencyAge` exclude nor the `--minimum-dependency-age=0` flag suppressed it for `deno test` in 2.9.1. Rather than fight Deno's cooldown knobs, stop resolving these against the registry at all. Point the import map at the WASM-inline entries pnpm already installed (reached via stack's own node_modules symlink), instead of `npm:@x@version` specifiers. Both entries import only their own relative wasm bindings, so there are no transitive npm deps. This: - removes all `npm:` resolution from the smoke test, so Deno's cooldown never applies (the real gate stays pnpm's minimumReleaseAge, which already exempts first-party @cipherstash packages); - keeps auth/protect-ffi in lockstep with the catalog automatically — a version bump no longer needs a matching edit here, which matters in the 1.0 lead-up where these are bumped often. Refs #567 --- e2e/wasm/deno.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/e2e/wasm/deno.json b/e2e/wasm/deno.json index 0ef163ea6..5b0e3bc38 100644 --- a/e2e/wasm/deno.json +++ b/e2e/wasm/deno.json @@ -1,16 +1,15 @@ { "//1": "Deno smoke test for @cipherstash/stack/wasm-inline. Run `pnpm exec turbo run build --filter @cipherstash/stack` first so dist/ is fresh.", - "//2": "stack is imported via a file URL because the wasm-inline subpath isn't on a published version yet — once stack ships with /wasm-inline this can switch to a plain npm: specifier. protect-ffi and auth resolve via npm: against the workspace's installed versions (Deno's nodeModulesDir: auto lets it use what pnpm fetched).", + "//2": "Everything resolves to files pnpm already installed in the workspace — no `npm:` specifiers, so Deno never re-resolves against the registry. stack is its locally-built dist; auth and protect-ffi are the WASM-inline entries of the exact versions the catalog pinned, reached via stack's own node_modules symlink (`packages/stack/node_modules/@cipherstash/*`). Both entries import only their own relative wasm bindings, so there are no transitive npm deps to resolve. This keeps the versions in lockstep with the catalog automatically (a bump to pnpm-workspace.yaml needs no edit here) and sidesteps Deno 2.9's default 24h npm freshness cooldown, which would otherwise reject a just-published first-party version and block lockstep bumps for a day. The real supply-chain gate is pnpm's `minimumReleaseAge` (see skills/stash-supply-chain-security/), which already exempts first-party @cipherstash packages.", "//3": "No --allow-ffi grant. If protect-ffi ever silently fell back to a native binding under Deno, the test would fail on missing FFI permission — this is the WASM guarantee.", - "//4": "Deno 2.9+ applies a 24h npm freshness cooldown to `deno test`'s npm resolution by default (error: \"newer than the specified minimum dependency date\"). The only npm deps here are the first-party @cipherstash packages below, which we bump in lockstep with stack and need tested immediately — not 24h later, which in the 1.0 lead-up (multiple bumps/day) would keep this job red almost continuously. The declarative `install.minimumDependencyAge` `exclude` form does NOT apply to `deno test`, so disable the cooldown for this run with `--minimum-dependency-age=0`. This is safe here: no third-party npm is resolved, and pnpm's own 7d cooldown (pnpm-workspace.yaml `minimumReleaseAge` + `minimumReleaseAgeExclude`) still governs every real install. See skills/stash-supply-chain-security/.", "nodeModulesDir": "auto", "tasks": { "//task": "--no-check because TypeScript can't infer the column-on-table intersection types when stack is loaded via file URL (the dist .d.ts strips brand info). This test exists to verify RUNTIME WASM round-trips — type narrowing is covered by the package's own vitest suite.", - "test": "deno test --minimum-dependency-age=0 --no-check --allow-env --allow-net --allow-read --allow-sys --no-prompt" + "test": "deno test --no-check --allow-env --allow-net --allow-read --allow-sys --no-prompt" }, "imports": { "@cipherstash/stack/wasm-inline": "../../packages/stack/dist/wasm-inline.js", - "@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.27.0/wasm-inline", - "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.41.0/wasm-inline" + "@cipherstash/protect-ffi/wasm-inline": "../../packages/stack/node_modules/@cipherstash/protect-ffi/dist/wasm/protect_ffi_inline.js", + "@cipherstash/auth/wasm-inline": "../../packages/stack/node_modules/@cipherstash/auth/wasm-inline.mjs" } } From f9fd3f84f80761747d41131d630114f038f27bf8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 13:54:20 +1000 Subject: [PATCH 7/8] fix(stack): bump @cipherstash/protect-ffi to 0.28 for the auth-0.41 Result envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth 0.41's WASM strategy getToken() returns the token inside a @byteslice/result envelope (Promise>). protect-ffi 0.27 read `.token` directly off the resolved value and saw `undefined`, failing the WASM encrypt/decrypt round-trip with "token field is not a string" (the Run WASM E2E Tests (Deno) CI check). protect-ffi 0.28 unwraps the envelope (.data.token / .failure) inside its WASM newClient — this was the parked blocker. - Bump @cipherstash/protect-ffi 0.27.0 → 0.28.0 in packages/stack (platform bindings follow in lockstep via the lockfile). The WASM Deno smoke test picks it up automatically (resolves from packages/stack/node_modules). - Refresh the now-stale WasmAuthStrategy doc comment: getToken returns a Result envelope, not a raw { token }; 0.28 is the ffi floor for the WASM path. - Note the ffi bump in the changeset. --- .changeset/stack-auth-0-41-result-api.md | 1 + packages/stack/package.json | 2 +- packages/stack/src/wasm-inline.ts | 7 ++- pnpm-lock.yaml | 58 ++++++++++++------------ 4 files changed, 36 insertions(+), 32 deletions(-) diff --git a/.changeset/stack-auth-0-41-result-api.md b/.changeset/stack-auth-0-41-result-api.md index 37c924305..0ab8677d5 100644 --- a/.changeset/stack-auth-0-41-result-api.md +++ b/.changeset/stack-auth-0-41-result-api.md @@ -12,5 +12,6 @@ Bump `@cipherstash/auth` (and its per-platform native bindings) from `0.40.0` to - **`AuthError` is renamed to `AuthFailure`** in the public re-exports from `@cipherstash/stack`. `AuthErrorCode` and `TokenResult` are unchanged. Anyone importing `AuthError` from `@cipherstash/stack` must switch to `AuthFailure`. - The WASM-inline access-key path (`resolveStrategy`, used by `@cipherstash/stack/wasm-inline`'s `Encryption()`) now unwraps the `Result` from `AccessKeyStrategy.create`. A construction failure (e.g. an invalid CRN or access key) throws a descriptive `[encryption]` error naming the `AuthFailure.type` instead of surfacing the raw auth error. +- Bump `@cipherstash/protect-ffi` from `0.27.0` to `0.28.0`. auth `0.41`'s `getToken()` returns the token inside a `Result` envelope; protect-ffi `0.28` unwraps it (`.data.token`) inside its WASM `newClient`, whereas `0.27` read `.token` off the envelope and got `undefined` — which failed the WASM encrypt/decrypt round-trip with `token field is not a string`. `0.28` is the floor for the WASM path under auth `0.41`. **`stash` (CLI) and `@cipherstash/wizard`.** Internal auth call sites (`stash auth login`, device binding, `init` auth check, and the wizard's token acquisition / prerequisite check) were updated to unwrap `Result` and branch on `failure.type`. Behaviour is preserved — auth failures still surface the same way to end users; no CLI/wizard API changed. diff --git a/packages/stack/package.json b/packages/stack/package.json index c4bcacc6a..5efb71b2e 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -244,7 +244,7 @@ "dependencies": { "@byteslice/result": "0.2.0", "@cipherstash/auth": "catalog:repo", - "@cipherstash/protect-ffi": "0.27.0", + "@cipherstash/protect-ffi": "0.28.0", "evlog": "1.11.0", "uuid": "14.0.0", "zod": "3.25.76" diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 0998c53d7..54ff117bc 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -210,8 +210,11 @@ export type WasmClientConfig = { /** * Any auth strategy accepted on the WASM path. Both expose - * `getToken(): Promise<{ token }>`, which is all protect-ffi's WASM - * `newClient` requires: + * `getToken(): Promise>` — as of + * `@cipherstash/auth` 0.41 the token is wrapped in a `@byteslice/result` + * envelope. `@cipherstash/protect-ffi` 0.28+ unwraps that envelope (reading + * `.data.token`, surfacing `.failure`) inside its WASM `newClient`; 0.27 read + * `.token` off the envelope and saw `undefined`, so keep the ffi floor at 0.28. * * - {@link AccessKeyStrategy} — static M2M / CI access key. * - {@link OidcFederationStrategy} — federates an end-user OIDC JWT into a diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 536334a69..3023ed143 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -578,8 +578,8 @@ importers: specifier: catalog:repo version: 0.41.0(@cipherstash/auth-darwin-arm64@0.41.0)(@cipherstash/auth-darwin-x64@0.41.0)(@cipherstash/auth-linux-arm64-gnu@0.41.0)(@cipherstash/auth-linux-x64-gnu@0.41.0)(@cipherstash/auth-linux-x64-musl@0.41.0)(@cipherstash/auth-win32-x64-msvc@0.41.0) '@cipherstash/protect-ffi': - specifier: 0.27.0 - version: 0.27.0 + specifier: 0.28.0 + version: 0.28.0 evlog: specifier: 1.11.0 version: 1.11.0(next@15.5.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) @@ -974,8 +974,8 @@ packages: cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-arm64@0.27.0': - resolution: {integrity: sha512-Ikh0DaqbRSsviwFAJZXHOClBMXWneCCqEbUR4YtzzGedCBnGeB3QRxoYBra/uKAkkbdgSKVncaXRJOze33bvag==} + '@cipherstash/protect-ffi-darwin-arm64@0.28.0': + resolution: {integrity: sha512-QRwiovEdgjstbaCONZGYXA82NG4Gwz9mJOZeAzthUmNIQfnVErO9UQAfJ+I1T/YG1Ww2FCWcHltXMl/qjyeK4A==} cpu: [arm64] os: [darwin] @@ -984,8 +984,8 @@ packages: cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.27.0': - resolution: {integrity: sha512-c9DOzv5h58csIJ12wh1DXuN0RNc4PCfep0BY/9GrlJ8+ZyCmtiHXJmputX7nxIGLLlZ8aIfZFRIKjD4dJU3FoA==} + '@cipherstash/protect-ffi-darwin-x64@0.28.0': + resolution: {integrity: sha512-H8qB2z3drJSc2bR5ITsJSFrOoNKvORbauTdHmaS1e5OleXsLl/F+56Zvghz8NWs+bXs/2aVzHxpYte8FgV8AAQ==} cpu: [x64] os: [darwin] @@ -994,8 +994,8 @@ packages: cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.27.0': - resolution: {integrity: sha512-exZw40bjYVjv9gm2dye/agd2hVcneyOVAsNdOYDnLiw6iLiWpAWKi0ndIvOKskLLlzOFfcmGqZxGEY3rWpnnKw==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.28.0': + resolution: {integrity: sha512-h7vZxB/wako6j1jspkGl41gptTN1dBTWYqAR6jhVnvKJK0TBlyUKuSJwO1NeqyNfFQ/b1KmqJ11JGvY95uRZOA==} cpu: [arm64] os: [linux] @@ -1004,8 +1004,8 @@ packages: cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.27.0': - resolution: {integrity: sha512-xA8V+2VHoK0tqsQcV5KCupEQw2E5jVazsBu56dfTBUypu6y6H7gF5/zyhxFIfQNDMErpFZgfcMDuQGZ7w8B7tQ==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.28.0': + resolution: {integrity: sha512-PgHn0Og0PiCwFdV4yKhipGKfqkCXP6Xchyy6ykTaISabmSlkkbRNkyNF1/tZEQYF65sm5gmqSpwSYzKODaAsbg==} cpu: [x64] os: [linux] @@ -1014,8 +1014,8 @@ packages: cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-musl@0.27.0': - resolution: {integrity: sha512-EcoEXvCq4CcVMKoRIeMxEFgB8UvGfjHHl7Mohy9vkrfazOKgXcmLIuy0oKP3IhNSJuyQU1iP26KEH7XDYbn/Sg==} + '@cipherstash/protect-ffi-linux-x64-musl@0.28.0': + resolution: {integrity: sha512-fMu3k1Y3wYMcGbGpVtrlCMNRLd1//5c9ZHcyWFjKFqimOCbBO5akgCQij4zcvj4kYDHtveu9dOdOKVhaG8k6hQ==} cpu: [x64] os: [linux] @@ -1024,16 +1024,16 @@ packages: cpu: [x64] os: [win32] - '@cipherstash/protect-ffi-win32-x64-msvc@0.27.0': - resolution: {integrity: sha512-G50vcDCp9c/b8WrP0Y6zw+Q3vb4sWlCFww2SDp3KY01loyLQGDadSn8Rbf24W7u/VOef1b2KqZlaCc0qeHJbGQ==} + '@cipherstash/protect-ffi-win32-x64-msvc@0.28.0': + resolution: {integrity: sha512-tF0y7TcyBtgN/jHodYMnrJ4etLkEI/klzDivW8Q8eIuIdoJ3q5muZKFIhNX0MbDDdh2O4GsRdNhXRubs7BiFpA==} cpu: [x64] os: [win32] '@cipherstash/protect-ffi@0.23.0': resolution: {integrity: sha512-Ca8MKLrrumC561VoPDOhuUZcF8C8YenqO1Ig9hSJSRUB+jFeIJXeyn7glExsvKYWtxOx/pRub9FV8A0RyuPHMg==} - '@cipherstash/protect-ffi@0.27.0': - resolution: {integrity: sha512-uGUb5DychOUZqe1OFI1H0zYiQkPbWAnbwW65YV6PgfJUu/Pu//EDMRJmty4SWzeNq8E6BA4t+YUPP2DEXKDnkw==} + '@cipherstash/protect-ffi@0.28.0': + resolution: {integrity: sha512-R2L/8HwMREkVKlR5KCcuELIWz4QNButSBQzY+nRDHl1PUXjRqWG1h265FkKVtTXKNKUup7rB4mswu+M+t9KF3A==} '@clack/core@1.3.0': resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} @@ -4317,37 +4317,37 @@ snapshots: '@cipherstash/protect-ffi-darwin-arm64@0.23.0': optional: true - '@cipherstash/protect-ffi-darwin-arm64@0.27.0': + '@cipherstash/protect-ffi-darwin-arm64@0.28.0': optional: true '@cipherstash/protect-ffi-darwin-x64@0.23.0': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.27.0': + '@cipherstash/protect-ffi-darwin-x64@0.28.0': optional: true '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.27.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.28.0': optional: true '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.27.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.28.0': optional: true '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.27.0': + '@cipherstash/protect-ffi-linux-x64-musl@0.28.0': optional: true '@cipherstash/protect-ffi-win32-x64-msvc@0.23.0': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.27.0': + '@cipherstash/protect-ffi-win32-x64-msvc@0.28.0': optional: true '@cipherstash/protect-ffi@0.23.0': @@ -4361,16 +4361,16 @@ snapshots: '@cipherstash/protect-ffi-linux-x64-musl': 0.23.0 '@cipherstash/protect-ffi-win32-x64-msvc': 0.23.0 - '@cipherstash/protect-ffi@0.27.0': + '@cipherstash/protect-ffi@0.28.0': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.27.0 - '@cipherstash/protect-ffi-darwin-x64': 0.27.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.27.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.27.0 - '@cipherstash/protect-ffi-linux-x64-musl': 0.27.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.27.0 + '@cipherstash/protect-ffi-darwin-arm64': 0.28.0 + '@cipherstash/protect-ffi-darwin-x64': 0.28.0 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.28.0 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.28.0 + '@cipherstash/protect-ffi-linux-x64-musl': 0.28.0 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.28.0 '@clack/core@1.3.0': dependencies: From 722408fe7129fdc3755a97967de34119e360b356 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 14:59:35 +1000 Subject: [PATCH 8/8] test(stack): cover the AccessKeyStrategy.create failure branch in wasm-inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveStrategy unwraps the Result from `@cipherstash/auth` 0.41's `AccessKeyStrategy.create` and throws a loud construction error on the failure arm. That branch was untested — add a unit test that overrides the mocked `create` to return `{ failure: { type, error } }` and asserts the thrown message names the failure type and underlying message, and that the builder was reached (guards passed) without warning. --- .../__tests__/wasm-inline-strategy.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/stack/__tests__/wasm-inline-strategy.test.ts b/packages/stack/__tests__/wasm-inline-strategy.test.ts index a0a7e3a39..9c3931299 100644 --- a/packages/stack/__tests__/wasm-inline-strategy.test.ts +++ b/packages/stack/__tests__/wasm-inline-strategy.test.ts @@ -67,6 +67,35 @@ describe('wasm-inline resolveStrategy', () => { expect(warnSpy).not.toHaveBeenCalled() }) + it('throws when AccessKeyStrategy.create returns a failure Result', () => { + // `@cipherstash/auth` `0.41` `create` returns `{ failure }` instead of + // throwing — `resolveStrategy` must surface that as a loud construction + // error naming the failure type and the underlying message, not forward + // an unusable strategy. + vi.mocked(AccessKeyStrategy.create).mockReturnValueOnce( + // biome-ignore lint/suspicious/noExplicitAny: mock the 0.41 Result failure arm + { + failure: { + type: 'InvalidWorkspaceCrn', + error: new Error('unparseable CRN'), + }, + } as any, + ) + + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: exercise the access-key arm directly + resolveStrategy({ workspaceCrn: CRN, accessKey: 'CSAK.test' } as any), + ).toThrowError( + /failed to construct.*\(InvalidWorkspaceCrn\): unparseable CRN/, + ) + // The guards passed and it reached the builder before failing. + expect(vi.mocked(AccessKeyStrategy.create)).toHaveBeenCalledWith( + CRN, + 'CSAK.test', + ) + 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