Skip to content

Commit af103cd

Browse files
authored
Merge pull request #568 from cipherstash/chore/bump-auth-0.41
Bump @cipherstash/auth to 0.41 and migrate to Result API
2 parents 8145936 + 722408f commit af103cd

17 files changed

Lines changed: 315 additions & 149 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@cipherstash/stack": minor
3+
"@cipherstash/wizard": patch
4+
"stash": patch
5+
---
6+
7+
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.
8+
9+
**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.
10+
11+
**`@cipherstash/stack` (breaking type surface).**
12+
13+
- **`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`.
14+
- 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.
15+
- 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`.
16+
17+
**`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.

e2e/wasm/deno.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"//1": "Deno smoke test for @cipherstash/stack/wasm-inline. Run `pnpm exec turbo run build --filter @cipherstash/stack` first so dist/ is fresh.",
3-
"//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+
"//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.",
44
"//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.",
55
"nodeModulesDir": "auto",
66
"tasks": {
@@ -9,7 +9,7 @@
99
},
1010
"imports": {
1111
"@cipherstash/stack/wasm-inline": "../../packages/stack/dist/wasm-inline.js",
12-
"@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.26.0/wasm-inline",
13-
"@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.40.0/wasm-inline"
12+
"@cipherstash/protect-ffi/wasm-inline": "../../packages/stack/node_modules/@cipherstash/protect-ffi/dist/wasm/protect_ffi_inline.js",
13+
"@cipherstash/auth/wasm-inline": "../../packages/stack/node_modules/@cipherstash/auth/wasm-inline.mjs"
1414
}
1515
}

packages/cli/src/commands/auth/login.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,36 +34,49 @@ export async function login(region: string, _referrer: string | undefined) {
3434

3535
// Must be 'cli' — it's the only OAuth client_id registered with CTS.
3636
// Passing anything else (e.g. `cli-supabase`) causes INVALID_CLIENT.
37+
// As of `@cipherstash/auth` `0.41`, the device-code flow returns
38+
// `Result<T, AuthFailure>` instead of throwing — unwrap each step.
3739
const pending = await beginDeviceCodeFlow(region, 'cli')
40+
if (pending.failure) {
41+
p.log.error(pending.failure.error.message)
42+
process.exit(1)
43+
}
44+
const flow = pending.data
3845

39-
p.log.info(`Your code is: ${pending.userCode}`)
40-
p.log.info(`Visit: ${pending.verificationUriComplete}`)
41-
p.log.info(`Code expires in: ${pending.expiresIn}s`)
46+
p.log.info(`Your code is: ${flow.userCode}`)
47+
p.log.info(`Visit: ${flow.verificationUriComplete}`)
48+
p.log.info(`Code expires in: ${flow.expiresIn}s`)
4249

43-
const opened = pending.openInBrowser()
44-
if (!opened) {
50+
const opened = flow.openInBrowser()
51+
if (opened.failure || !opened.data) {
4552
p.log.warn('Could not open browser — please visit the URL above manually.')
4653
}
4754

4855
s.start('Waiting for authorization...')
49-
const auth = await pending.pollForToken()
56+
const auth = await flow.pollForToken()
57+
if (auth.failure) {
58+
s.stop('Authentication failed!')
59+
p.log.error(auth.failure.error.message)
60+
process.exit(1)
61+
}
5062
s.stop('Authenticated!')
5163

5264
p.log.info(
53-
`Token expires at: ${new Date(auth.expiresAt * 1000).toISOString()}`,
65+
`Token expires at: ${new Date(auth.data.expiresAt * 1000).toISOString()}`,
5466
)
5567
}
5668

5769
export async function bindDevice() {
5870
const s = p.spinner()
5971
s.start('Binding device to the default Keyset...')
6072

61-
try {
62-
await bindClientDevice()
63-
s.stop('Your device has been bound to the default Keyset!')
64-
} catch (error) {
73+
// `bindClientDevice()` returns `Result<void, AuthFailure>` as of
74+
// `@cipherstash/auth` `0.41` — a failure no longer throws.
75+
const result = await bindClientDevice()
76+
if (result.failure) {
6577
s.stop('Failed to bind your device to the default Keyset!')
66-
p.log.error(error instanceof Error ? error.message : 'Unknown error')
78+
p.log.error(result.failure.error.message)
6779
process.exit(1)
6880
}
81+
s.stop('Your device has been bound to the default Keyset!')
6982
}

packages/cli/src/commands/init/steps/authenticate.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,20 @@ interface ExistingAuth {
1616
*/
1717
async function checkExistingAuth(): Promise<ExistingAuth | undefined> {
1818
try {
19-
const strategy = AutoStrategy.detect()
20-
const result = await strategy.getToken()
19+
// As of `@cipherstash/auth` `0.41`, `detect()` and `getToken()` return a
20+
// `Result<T, AuthFailure>` instead of throwing — a failure at either step
21+
// just means "not authenticated yet", so fall through to `undefined`.
22+
const detected = AutoStrategy.detect()
23+
if (detected.failure) return undefined
2124

22-
const regionEntry = regions.find((r) => result.issuer.includes(r.value))
25+
const result = await detected.data.getToken()
26+
if (result.failure) return undefined
27+
28+
const { issuer, workspaceId } = result.data
29+
const regionEntry = regions.find((r) => issuer.includes(r.value))
2330
const regionLabel = regionEntry?.label ?? 'unknown'
2431

25-
return { workspace: result.workspaceId, regionLabel }
32+
return { workspace: workspaceId, regionLabel }
2633
} catch {
2734
return undefined
2835
}

packages/stack/__tests__/helpers/stub-auth-wasm-inline.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
* lets Vitest load `src/wasm-inline` for pure-helper unit tests. Aliased in via
77
* `vitest.config.ts`.
88
*/
9+
// `@cipherstash/auth` `0.41` `create` returns a `Result<Strategy, AuthFailure>`
10+
// rather than throwing. These stubs are only reached by tests that don't
11+
// override the module with `vi.mock`; they still throw loudly so an
12+
// unexpectedly-exercised path fails visibly rather than silently.
913
export const AccessKeyStrategy = {
1014
create: (): never => {
1115
throw new Error(

packages/stack/__tests__/init-strategy.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,20 @@ describe('Encryption config.strategy (deprecated alias)', () => {
165165

166166
expect(warnSpy).not.toHaveBeenCalled()
167167
})
168+
169+
it('warns at most once per process across repeated Encryption calls', async () => {
170+
// No reset between the two calls (the latch is only reset in beforeEach),
171+
// so they share one process-level latch — a regression that dropped the
172+
// `if (warnedStrategyDeprecated) return` guard would warn twice here.
173+
const strategy: AuthStrategy = {
174+
getToken: vi.fn(async () => ({ token: 'service-token' })),
175+
}
176+
177+
await Encryption({ schemas: [users], config: { strategy } })
178+
await Encryption({ schemas: [users], config: { strategy } })
179+
180+
expect(warnSpy).toHaveBeenCalledTimes(1)
181+
})
168182
})
169183

170184
// A minimal structural EQL v3 table: what marks a table as v3 for wire-format

packages/stack/__tests__/wasm-inline-new-client.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1818

1919
vi.mock('@cipherstash/auth/wasm-inline', () => ({
2020
AccessKeyStrategy: {
21-
create: vi.fn(() => ({ __mock: 'access-key-strategy' })),
21+
// `@cipherstash/auth` `0.41` `create` returns a `Result<Strategy, AuthFailure>`
22+
// (`{ data }` on success) — `resolveStrategy` unwraps `.data`.
23+
create: vi.fn(() => ({ data: { __mock: 'access-key-strategy' } })),
2224
},
2325
OidcFederationStrategy: class {},
2426
}))

packages/stack/__tests__/wasm-inline-strategy.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1717

1818
vi.mock('@cipherstash/auth/wasm-inline', () => ({
1919
AccessKeyStrategy: {
20-
create: vi.fn(() => ({ __mock: 'access-key-strategy' })),
20+
// `@cipherstash/auth` `0.41` `create` returns a `Result<Strategy, AuthFailure>`
21+
// (`{ data }` on success) — `resolveStrategy` unwraps `.data`.
22+
create: vi.fn(() => ({ data: { __mock: 'access-key-strategy' } })),
2123
},
2224
OidcFederationStrategy: class {},
2325
}))
@@ -65,6 +67,35 @@ describe('wasm-inline resolveStrategy', () => {
6567
expect(warnSpy).not.toHaveBeenCalled()
6668
})
6769

70+
it('throws when AccessKeyStrategy.create returns a failure Result', () => {
71+
// `@cipherstash/auth` `0.41` `create` returns `{ failure }` instead of
72+
// throwing — `resolveStrategy` must surface that as a loud construction
73+
// error naming the failure type and the underlying message, not forward
74+
// an unusable strategy.
75+
vi.mocked(AccessKeyStrategy.create).mockReturnValueOnce(
76+
// biome-ignore lint/suspicious/noExplicitAny: mock the 0.41 Result failure arm
77+
{
78+
failure: {
79+
type: 'InvalidWorkspaceCrn',
80+
error: new Error('unparseable CRN'),
81+
},
82+
} as any,
83+
)
84+
85+
expect(() =>
86+
// biome-ignore lint/suspicious/noExplicitAny: exercise the access-key arm directly
87+
resolveStrategy({ workspaceCrn: CRN, accessKey: 'CSAK.test' } as any),
88+
).toThrowError(
89+
/failed to construct.*\(InvalidWorkspaceCrn\): unparseable CRN/,
90+
)
91+
// The guards passed and it reached the builder before failing.
92+
expect(vi.mocked(AccessKeyStrategy.create)).toHaveBeenCalledWith(
93+
CRN,
94+
'CSAK.test',
95+
)
96+
expect(warnSpy).not.toHaveBeenCalled()
97+
})
98+
6899
it('uses an explicit config.authStrategy verbatim and never builds an access key', () => {
69100
const explicit = { getToken: vi.fn() }
70101
// biome-ignore lint/suspicious/noExplicitAny: exercise the authStrategy arm of the discriminated union directly
@@ -87,6 +118,19 @@ describe('wasm-inline resolveStrategy', () => {
87118
)
88119
})
89120

121+
it('warns at most once per process across repeated resolveStrategy calls', () => {
122+
// No reset between the two calls (the latch is only reset in beforeEach),
123+
// so they share one process-level latch — a regression that dropped the
124+
// `if (warnedStrategyDeprecated) return` guard would warn twice here.
125+
const explicit = { getToken: vi.fn() }
126+
// biome-ignore lint/suspicious/noExplicitAny: exercise the deprecated strategy arm directly
127+
resolveStrategy({ strategy: explicit } as any)
128+
// biome-ignore lint/suspicious/noExplicitAny: exercise the deprecated strategy arm directly
129+
resolveStrategy({ strategy: explicit } as any)
130+
131+
expect(warnSpy).toHaveBeenCalledTimes(1)
132+
})
133+
90134
it('prefers authStrategy over the deprecated strategy when both are set, and still warns', () => {
91135
const authStrategy = { getToken: vi.fn() }
92136
const strategy = { getToken: vi.fn() }

packages/stack/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@
244244
"dependencies": {
245245
"@byteslice/result": "0.2.0",
246246
"@cipherstash/auth": "catalog:repo",
247-
"@cipherstash/protect-ffi": "0.27.0",
247+
"@cipherstash/protect-ffi": "0.28.0",
248248
"evlog": "1.11.0",
249249
"uuid": "14.0.0",
250250
"zod": "3.25.76"

packages/stack/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export type OidcFederationStrategy = InstanceType<
3636
typeof auth.OidcFederationStrategy
3737
>
3838

39-
export type { AuthError, AuthErrorCode, TokenResult } from '@cipherstash/auth'
39+
export type {
40+
AuthErrorCode,
41+
AuthFailure,
42+
TokenResult,
43+
} from '@cipherstash/auth'
4044
// Re-export types for convenience
4145
export type { Encrypted } from '@/types'

0 commit comments

Comments
 (0)