Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/stack-auth-0-41-result-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@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<T, AuthFailure>` (`{ 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.
- 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.
6 changes: 3 additions & 3 deletions e2e/wasm/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"//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.",
"nodeModulesDir": "auto",
"tasks": {
Expand All @@ -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": "../../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"
}
}
37 changes: 25 additions & 12 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,36 +34,49 @@ 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<T, AuthFailure>` 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()}`,
)
}

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<void, AuthFailure>` 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!')
}
15 changes: 11 additions & 4 deletions packages/cli/src/commands/init/steps/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,20 @@ interface ExistingAuth {
*/
async function checkExistingAuth(): Promise<ExistingAuth | undefined> {
try {
const strategy = AutoStrategy.detect()
const result = await strategy.getToken()
// As of `@cipherstash/auth` `0.41`, `detect()` and `getToken()` return a
// `Result<T, AuthFailure>` 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
}
Expand Down
4 changes: 4 additions & 0 deletions packages/stack/__tests__/helpers/stub-auth-wasm-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Strategy, AuthFailure>`
// 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(
Expand Down
14 changes: 14 additions & 0 deletions packages/stack/__tests__/init-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/stack/__tests__/wasm-inline-new-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Strategy, AuthFailure>`
// (`{ data }` on success) — `resolveStrategy` unwraps `.data`.
create: vi.fn(() => ({ data: { __mock: 'access-key-strategy' } })),
},
OidcFederationStrategy: class {},
}))
Expand Down
46 changes: 45 additions & 1 deletion packages/stack/__tests__/wasm-inline-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Strategy, AuthFailure>`
// (`{ data }` on success) — `resolveStrategy` unwraps `.data`.
create: vi.fn(() => ({ data: { __mock: 'access-key-strategy' } })),
},
OidcFederationStrategy: class {},
}))
Expand Down Expand Up @@ -65,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
Expand All @@ -87,6 +118,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() }
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion packages/stack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
19 changes: 15 additions & 4 deletions packages/stack/src/wasm-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<TokenResult, AuthFailure>>` — 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
Expand Down Expand Up @@ -465,6 +468,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<AccessKeyStrategy, AuthFailure>` rather
// than throwing — unwrap it and surface a construction failure loudly.
const result = AccessKeyStrategy.create(cfg.workspaceCrn, cfg.accessKey)
if (result.failure) {
Comment thread
coderdan marked this conversation as resolved.
throw new Error(
`[encryption]: failed to construct \`AccessKeyStrategy\` from \`config.workspaceCrn\` / \`config.accessKey\` (${result.failure.type}): ${result.failure.error.message}`,
)
}
return result.data
}
14 changes: 10 additions & 4 deletions packages/wizard/src/__tests__/prerequisites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AutoStrategy, AuthFailure>`
// and `getToken()` returns `Result<TokenResult, AuthFailure>` (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'),
},
}),
},
}),
},
Expand Down
24 changes: 22 additions & 2 deletions packages/wizard/src/agent/fetch-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, AuthFailure>` 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 {
Expand Down
11 changes: 8 additions & 3 deletions packages/wizard/src/agent/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,14 @@ export function wizardCanUseTool(
*/
async function getAccessToken(): Promise<string | undefined> {
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<T, AuthFailure>` 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
}
Expand Down
30 changes: 21 additions & 9 deletions packages/wizard/src/lib/prerequisites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<T, AuthFailure>` 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. */
Expand Down
Loading
Loading