Skip to content

fix(stack): consume protect-ffi 0.31.0 - #860

Merged
tobyhede merged 3 commits into
mainfrom
feat/protect-ffi-1-consume-0.31
Aug 6, 2026
Merged

fix(stack): consume protect-ffi 0.31.0#860
tobyhede merged 3 commits into
mainfrom
feat/protect-ffi-1-consume-0.31

Conversation

@tobyhede

@tobyhede tobyhede commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Stack 1 of 4 — splitting #858 (the protect-ffi monorepo absorption) into reviewable pieces.

PR What it does
1 this one Consume the published protect-ffi 0.31.0
2 Close three fail-open holes in the no-caching gate
3 Vendor packages/protect-ffi (subtree, upstream history preserved)
4 Link consumers to the workspace copy

What

Bumps the exact pin 0.30.00.31.0 in @cipherstash/stack and the two adapters that carry it as a devDependency, and adapts the source to four breaking changes:

  1. ProtectError is gone, replaced by an isProtectErrorCode guard. The check is on the code's value, which fixes a pre-existing bug in dynamodb/helpers.ts — its fallback accepted any string-valued code, so ECONNRESET from the DynamoDB client surfaced as an encryption error code.
  2. The wasm newClient moved credentials into clientOpts and renamed strategyauthStrategy. Credentials left at the top level are now rejected outright; a keyset left there would be silently ignored and bind the client to the default keyset.
  3. encryptConfig no longer needs normalising — 0.31 normalizes at the Rust deserialization boundary on both bindings.
  4. Unknown payload keys now reach Rust and are rejected. Stack attached a correlation id to every bulk payload that protect-ffi never declared; 0.30 dropped it silently, 0.31 fails the call. It is now stripped at the FFI boundary.

Carries the major changeset for the adoption, and a require-cs-secrets pre-flight for the new hex-only clientKey — without it a base64 secret presents as all six credentialed workflows failing at once with a message that never mentions encoding.

Why this is independent of the vendoring

It consumes the published 0.31.0 from npm. Verified: packages/stack uses nothing added to protect-ffi after the 0.31.0 tag, and eql-v3.ts's export surface is identical between the published release and the later in-tree copy.

Verification

pnpm --filter @cipherstash/stack build clean; scripts suite 178 passing; biome 0 errors; lockfile in sync under --frozen-lockfile. The error-codes suite needs live CS_* credentials and was not run locally.

Summary by CodeRabbit

  • Breaking Changes
    • Explicit CS_CLIENT_KEY and clientKey values must now be even-length hexadecimal strings; base64 values are rejected.
    • WASM client authentication options now use the updated credential and strategy structure.
  • Bug Fixes
    • Improved encryption error handling with safer recognition and consistent fallback errors.
    • Bulk operations no longer transmit internal correlation identifiers.
  • Documentation
    • Added guidance for re-encoding keys and recovering from invalid credentials, including native and WASM differences.

Bumps the exact pin from 0.30.0 to 0.31.0 across `@cipherstash/stack` and the
two adapters that carry it as a devDependency. 0.31.0 is a release with a
`Breaking` heading, and four incompatibilities land with it.

**1. The `ProtectError` class is gone**, replaced by an `isProtectErrorCode`
guard. Both bindings now throw an ordinary `Error` with `code` set by Rust, so
there is no class to match — and `instanceof` was unreliable regardless, being
false across duplicate copies of a package. Every type-only import is
unaffected; the two value sites move to a value check.

That check is on the code's VALUE, not the presence of a `code` property,
which fixes a pre-existing bug in `dynamodb/helpers.ts`. Its fallback branch
accepted any string-valued `code` and asserted it into `ProtectErrorCode`, so
a Node error — `ECONNRESET` from the DynamoDB client, say — was reported as an
encryption error code. The two branches collapse into one correct one.

**2. The wasm `newClient` moved credentials into `clientOpts`** and renamed
`strategy` to `authStrategy`. Credentials left at the top level are now
rejected outright, so that half fails loudly; a `keyset` left there would be
silently ignored and bind the client to the DEFAULT keyset, encrypting under
the wrong keys. This config forwards no keyset, and the test now asserts
`clientOpts` as a whole so one landing elsewhere is caught.

The `as never` is deleted. 0.30 typed the wasm options as `any`, so the cast
was load-bearing; 0.31 types them properly. Removing it immediately surfaced
`encryptConfig`, below — which is the argument for removing it.

**3. `encryptConfig` no longer needs normalising.** 0.30's wasm binding
accepted EQL-native `cast_as` only, so the factory ran `normalizeCastAs`
first. 0.31 normalizes at the Rust deserialization boundary on both bindings
and types the result as `CanonicalEncryptConfig`, documented as a shape
nothing asks you to build — so it is not assignable to the `EncryptConfig`
`newClient` declares, and keeping the call would need an assertion that
misdescribes the value. `normalizeCastAs` is deprecated rather than deleted —
it is the only exhaustive consumer of `toEqlCastAs`, and removing both is a
deliberate cleanup, not a side effect of a dependency bump.

**4. Unknown payload keys now reach Rust and are rejected.** Stack attaches a
correlation `id` to every bulk encrypt/decrypt payload, and protect-ffi's
`EncryptPayload` / `BulkDecryptPayload` have never declared one — 0.30 dropped
it silently, 0.31 fails the whole call with ``unknown field `id` ``. Nothing
was using it: results are correlated positionally, by `keyMap` index in the
model helpers and against the original array in `mapEncryptedDataToResult` /
`mapDecryptedDataToResult`. The id is stripped at the FFI boundary and stays
on stack's own side of it.

This is the first of four stacked PRs splitting the protect-ffi monorepo
absorption. It consumes the PUBLISHED 0.31.0 from npm and is independent of
the vendoring that follows: `packages/stack` uses nothing added to protect-ffi
after the 0.31.0 tag, and `eql-v3.ts`'s export surface is identical between
the published release and the later in-tree copy.
protect-ffi 0.31.0 narrowed an explicit `clientKey` to hex only. It used to be
decoded by `SecretKey::from_hex`, which falls back to standard padded base64 —
the encoding `~/.cipherstash/secretkey.json` stores on disk — so a base64 value
pasted into the secret worked. The Neon entry forwards `CS_CLIENT_KEY` straight
through as `clientKey`, so this is the exact value that now has to be hex.

Without a check, a base64 secret presents as all six credentialed workflows
failing simultaneously at client construction, with `invalid clientKey:
expected a hex-encoded key` and nothing else — protect-ffi discards the decode
error on purpose, because hex's own message names the offending character and
its offset, which would put part of a live key into logs and error trackers.
Six unrelated-looking red jobs and a message that does not mention encoding is
a bad afternoon.

It goes in `require-cs-secrets` because every workflow that sets
CS_CLIENT_KEY already calls that action — verified across all six — so this is
one edit rather than six, and it sits next to the existing presence check it
naturally follows.

The key is never echoed: the check is a charset and even-length test, and both
the success and failure messages report only the length.
@tobyhede
tobyhede requested a review from a team as a code owner August 6, 2026 00:57
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4932503

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
stash Major
@cipherstash/stack Major
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/bench Patch
@cipherstash/stack-drizzle Major
@cipherstash/stack-prisma Major
@cipherstash/stack-supabase Major
@cipherstash/test-kit Patch
@cipherstash/prisma-example Patch
@cipherstash/wizard Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ac7a83a-bd93-4733-a2e7-23091d3d0d75

📥 Commits

Reviewing files that changed from the base of the PR and between 970e759 and 4932503.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (20)
  • .changeset/lucky-cows-repeat.md
  • .changeset/olive-pugs-invite.md
  • .github/actions/require-cs-secrets/action.yml
  • packages/stack-drizzle/package.json
  • packages/stack-supabase/package.json
  • packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts
  • packages/stack/__tests__/encrypt-lock-context-guards.test.ts
  • packages/stack/__tests__/encrypt-query-match-preflight.test.ts
  • packages/stack/__tests__/error-codes.test.ts
  • packages/stack/__tests__/error-helpers.test.ts
  • packages/stack/__tests__/wasm-inline-new-client.test.ts
  • packages/stack/__tests__/wasm-inline-v3.test.ts
  • packages/stack/package.json
  • packages/stack/src/dynamodb/helpers.ts
  • packages/stack/src/encryption/helpers/error-code.ts
  • packages/stack/src/encryption/helpers/model-helpers.ts
  • packages/stack/src/encryption/operations/bulk-decrypt.ts
  • packages/stack/src/encryption/operations/bulk-encrypt.ts
  • packages/stack/src/wasm-inline.ts
  • skills/stash-auth/SKILL.md

📝 Walkthrough

Walkthrough

The PR upgrades @cipherstash/protect-ffi to 0.31.0. It enforces hexadecimal explicit client keys, validates Protect error codes, changes WASM client options, preserves cast_as, and removes bulk-operation IDs from FFI payloads.

Changes

protect-ffi 0.31 compatibility

Layer / File(s) Summary
Dependency and client-key contract
.changeset/*, .github/actions/require-cs-secrets/action.yml, packages/*/package.json, skills/stash-auth/SKILL.md
The packages use protect-ffi 0.31.0. Explicit CS_CLIENT_KEY values must be even-length hexadecimal strings. Invalid values fail without exposing the key. Documentation describes native profile fallback and WASM requirements.
Protect error-code validation
packages/stack/src/dynamodb/helpers.ts, packages/stack/src/encryption/helpers/error-code.ts, packages/stack/__tests__/*error*, packages/stack/__tests__/encrypt-*-test.ts
Error handling uses isProtectErrorCode to preserve recognized FFI codes. Unknown, unrelated, and non-string codes use DYNAMODB_ENCRYPTION_ERROR. Tests cover extraction, fallback, logging, and error identity.
Bulk FFI payload sanitization
packages/stack/src/encryption/helpers/model-helpers.ts, packages/stack/src/encryption/operations/bulk-*.ts
Bulk encryption and decryption omit internal id values from FFI payloads. Positional result mapping remains in place.
WASM client option migration
packages/stack/src/wasm-inline.ts, packages/stack/__tests__/wasm-inline-*.test.ts
WASM construction uses authStrategy and nested clientOpts credentials. encryptConfig is forwarded without SDK-side cast_as normalization. Tests reject legacy option fields.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StackCaller
  participant wasmNewClient
  participant protect_ffi
  StackCaller->>wasmNewClient: provide credentials and authentication strategy
  wasmNewClient->>protect_ffi: construct client with authStrategy and clientOpts
  wasmNewClient->>protect_ffi: forward encryptConfig unchanged
  protect_ffi-->>wasmNewClient: return initialized client
Loading

Possibly related PRs

Suggested reviewers: auxesis, coderdan

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: updating Stack to consume protect-ffi 0.31.0.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/protect-ffi-1-consume-0.31

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: consume protect-ffi 0.31.0

Reviewed at the actual PR head (66ba41a7) against main. Built, checked, and ran the tests locally; verified every changed call against the installed @cipherstash/protect-ffi@0.31.0 type surface.

Verdict: Approve. Clean adoption, no blocking issues. Two optional test-coverage nits below.

Verification

  • pnpm --filter @cipherstash/stack build — clean (DTS type-check passes, which is what validates dropping the as never in wasm-inline.ts).
  • pnpm --filter @cipherstash/stack test1026 passed, 44 skipped (skips are live lock-context tests without USER_JWT), 0 failed.
  • pnpm run code:check — 0 errors (the only diagnostics on a raw run come from the gitignored .turbo/cache, which CI never sees); changed source carries only 2 pre-existing, allowed as never warnings it doesn't touch.
  • error-codes.test.ts18 pass with all CS_* unset: the new isProtectErrorCode assertions and the FFI validation-error cases resolve client-side, so they're a real executable guard (the PR note that the suite "was not run locally" understates it — it runs).

Confirmed against the published 0.31.0 types:

  • isProtectErrorCode / ProtectErrorCode exported, ProtectError class gone — all imports match; no stale references remain anywhere in packages/.
  • NewClientOptions/ClientOpts/CredentialOpts — the new wasmNewClient({ authStrategy, encryptConfig, clientOpts: { clientId, clientKey }, eqlVersion }) matches exactly, and the comment's claim that a future keyset belongs in clientOpts is correct.
  • EncryptPayload / BulkDecryptPayload — traced every model-helper and bulk payload builder; each is { id, ...FFI-fields }, so stripping only id leaves precisely the accepted shape. No other undeclared key survives.

Non-blocking suggestions

  1. No direct test for the handleError bug fix. The changeset headlines that a foreign code (ECONNRESET) is no longer surfaced as an encryption code, but that's only exercised at the predicate level. packages/stack/src/dynamodb/helpers.ts:44-58 — add a credential-free unit test calling handleError with { code: 'ECONNRESET' } (→ DYNAMODB_ENCRYPTION_ERROR) and { code: 'UNKNOWN_COLUMN' } (→ preserved). Cheap, pins the promised behaviour.
  2. No direct test for getErrorCode. packages/stack/src/encryption/helpers/error-code.ts:19-22 — a one-liner asserting getErrorCode({ code: 'ECONNRESET' }) is undefined and { code: 'UNKNOWN_COLUMN' } returns the code. Optional; the wrapper is trivial.

Highlights

  • The DynamoDB fix is folded into the bump rather than merely ported — the collapsed branches remove a pre-existing fail-open, and the comment explains why the value-check matters (Node sets code on its own errors).
  • withoutId is a single well-named boundary with a rationale proving the id was inert (results correlate by array index), and the public bulk API still returns id because result mapping reads the original array.
  • Dropping as never and letting the compiler check the option shape is exactly what would catch a misplaced credential/keyset — and the keyset-silently-ignored footgun is called out in both the comment and the test.
  • The CI hex pre-flight uses [[ =~ ]] (whole-string anchor) over grep -q (per-line) specifically to reject deadbeef\n<junk>, and never echoes the key.
  • Compliance is complete: major + stash: patch (skills-only) changesets, and the stash-auth skill updated per the AGENTS.md package→skill map.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: consume protect-ffi 0.31.0 — Approve

Re-reviewed at PR head 66ba41a7, independently of the earlier approval. The head is unchanged since the prior review (both commits predate it), so freshtonic's two non-blocking test nits remain open — they stay non-blocking. I re-ran the full verification myself in an isolated worktree against npm @cipherstash/protect-ffi@0.31.0 rather than trust the prior run.

Verdict: Approve. Clean adoption, no blocking issues.

Verification

  • pnpm --filter @cipherstash/stack build — clean; DTS type-check passes (this is what validates dropping the as never on the wasmNewClient call).
  • Credential-free unit suites (wasm-inline-new-client, wasm-inline-v3, encrypt-lock-context-guards, encrypt-query-match-preflight) — 33 passed, 18 skipped, 0 failed.
  • error-codes.test.ts fails only in its beforeAll (constructs a live client → Invalid CRN when CS_* are unset) — not a code defect; see Nit 2.
  • biome check on all changed source — 0 errors; the only 2 warnings are pre-existing as never in isEncrypted (lines 166/851) that this PR doesn't touch.
  • Checked every changed call against the installed 0.31.0 .d.ts: isProtectErrorCode / PROTECT_ERROR_CODES (has UNKNOWN_COLUMN), NewClientOptions { encryptConfig, clientOpts?, authStrategy?, eqlVersion? }, ClientOpts = CredentialOpts & { keyset? } with clientId/clientKey. All match; no stale ProtectError class references remain in packages/*/src.

Non-blocking

1. Promised bug-fixes still lack a direct test (unchanged from the prior review). The changeset headlines the DynamoDB handleError fix (a foreign ECONNRESET no longer surfaced as an encryption code) and the getErrorCode value-check, but the new tests only exercise isProtectErrorCode — the library predicate, not the two stack wrappers that consume it (packages/stack/src/dynamodb/helpers.ts:55-58, packages/stack/src/encryption/helpers/error-code.ts:19-22). A credential-free unit test asserting handleError({ code: 'ECONNRESET' })DYNAMODB_ENCRYPTION_ERROR, handleError({ code: 'UNKNOWN_COLUMN' }) preserved, and getErrorCode({ code: 'ECONNRESET' })undefined would pin the promised behaviour cheaply.

2. The new predicate tests only run in the credentialed CI job (new observation). The isProtectErrorCode block added at error-codes.test.ts:42-64 is pure and credential-independent, but its parent beforeAll (error-codes.test.ts:39) constructs a real Encryption client. With CS_* unset the whole suite fails at beforeAll (verified locally), so these tests never run without live credentials — not the always-on client-side guard one might assume. Moving them to a describe outside the credential-gated beforeAll (or a standalone file) would let them run everywhere and would resolve Nit 1 for free.

Highlights

  • The DynamoDB fix is genuinely folded into the bump: collapsing the two branches removes a pre-existing fail-open, and the isProtectErrorCode type guard narrows errorObj.code to ProtectErrorCode with no assertion — type-safe and null-safe via optional chaining.
  • withoutId is a single well-named FFI boundary with a rationale proving the id was inert (results correlate by array index), and the public bulk API still returns id because result mapping reads the original array.
  • Removing as never and letting the compiler check the option shape is the point — it's what would catch a misplaced credential or a keyset in the wrong place; the silently-ignored-keyset footgun is called out in both the comment and the test.
  • CI hex pre-flight: presence is asserted first so the hex step never sees an empty value; whole-string [[ =~ ]] anchor rejects deadbeef\n<junk>; never echoes the key.
  • Compliance complete: major + stash: patch (skills-only) changesets, and stash-auth updated per the package→skill map. No CLI surface change, so stash-cli correctly untouched.

…odes

protect-ffi 0.31.0 removed the `ProtectError` class both helpers matched with
`instanceof`, and the collapse of that branch fixed a bug: the old fallback
accepted ANY string-valued `code` and asserted it into `ProtectErrorCode`, so a
Node error (`ECONNRESET` off a dropped socket) was handed back as an encryption
error code. A caller keying retry-vs-fail off `error.code` read a transport
fault as a crypto fault.

That fix only had predicate-level coverage — `isProtectErrorCode` was tested
directly, but its two call sites were reachable only through live ZeroKMS.
Both new blocks fail against the pre-0.31 guard and pass against the current
one, credential-free.

- `handleError` joins `throwPreservingCode` in the DynamoDB pure-helper suite;
  they are the two ends of one seam (the latter exists so the code survives
  `withResult`'s wrapping for the former to read back). Also covers the
  message-extraction ladder and the errorHandler/logger fan-out.
- `getErrorCode` joins `getErrorMessage` in the error-helper suite, including
  the null/undefined inputs its optional chain exists for.
@tobyhede
tobyhede merged commit a79bacc into main Aug 6, 2026
17 checks passed
@tobyhede
tobyhede deleted the feat/protect-ffi-1-consume-0.31 branch August 6, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants