Skip to content

fix(stack-supabase): read tables that have no encrypted columns - #1544

Draft
cipherstash-agent wants to merge 3 commits into
mainfrom
claude/project-thread-qzt4qf
Draft

cipherstash-agent wants to merge 3 commits into
mainfrom
claude/project-thread-qzt4qf

Conversation

@cipherstash-agent

Copy link
Copy Markdown
Contributor

Requested by CipherStash · project thread

Summary

Every read of a Supabase table with no encrypted columns failed. The adapter hands Encryption({ schemas }) only the tables that carry an encrypted column, but then asked the client to decrypt every table's rows with that table, and both engines refuse a table they were not initialised with. So es.from('audit_log').select('*'), and any insert, update or upsert followed by .select(), returned decryptModel received a table this client was not initialized with (or threw, with throwOnError). On the wasm-inline entry, writes to such a table failed the same way. Fixes CIP-5151.

Changes

  • column-map.ts: tableHasEncryptedColumns(table) is now the one rule for "plaintext table". create.ts uses it to choose what Encryption receives, and ColumnMap.hasEncryptedColumns uses it in the query pipeline, so the two cannot drift apart.
  • query-mutation.ts and query-results.ts: for a plaintext table, the encrypt and decrypt steps pass rows through without calling the encryption client. Filters already skipped the client, because terms are collected only for encrypted columns.
  • query-results.ts: before returning a plaintext table's rows, the adapter checks them for an encrypted value and refuses the read if it finds one, with an error that names the field and never the value. A row can still hold ciphertext from an embedded relation to an encrypted table (select('id, users(email)')), or from an eql_v3_* column that a declared-mode table declared with {} leaves out. Those reads used to fail. Without this check they would return ciphertext typed as data.
  • @cipherstash/stack/adapter-kit exports isEncryptedPayload, the check model decryption already uses, so the adapter uses the same definition of an encrypted value.
  • Fixed the stale create.ts comment that said the builder calls decryptModel(row) with no table.
  • The stash-supabase skill now covers plaintext tables and the embedded-relation limit. There are changesets for @cipherstash/stack-supabase, @cipherstash/stack and stash.

Verification

  • New __tests__/supabase-plaintext-table.test.ts. The shared unit double ignores the table argument, which is why the existing suites missed this bug. The new suite uses a double that enforces registration the way the real engines do. It covers introspection and declared modes, single and bulk reads, throwOnError, returning inserts, and an encrypted table on the same instance. All ten read and returning-mutation tests failed on main with the production error and pass now. The four ciphertext-guard tests failed before the guard was added.
  • New integration/plaintext-table.integration.test.ts runs the same cases against real ZeroKMS, Postgres and PostgREST in both modes. It needs credentials, so I have not run it locally and it runs in the integration job.
  • pnpm --filter @cipherstash/stack-supabase test: 587 passed, 7 skipped. pnpm run code:check: no errors.
  • tsup for stack-supabase fails locally on Cannot find module '@cipherstash/stack/wasm-inline' (the WASM build is missing). It fails the same way on main, so this branch did not cause it.

Review notes

A medium code review ran on this change, and the follow-up commit fixes its two correctness findings (the ciphertext guard) and one test-double nit. Three findings were left as they are:

  • The bypass is checked at two call sites. These are the only two places that hand the client a table. The filter path never calls the client for a plaintext table, because it collects terms only for encrypted columns.
  • hasEncryptedColumns duplicates encryptedColumnNames.length > 0. It reads the same predicate as create.ts on purpose. That keeps it tied to what Encryption actually received.
  • There is no wasm-inline integration coverage. This suite has no harness for that entry. The bypass is in code both entries share, and the unit double reproduces the WASM refusal.

Possible follow-up: decrypt embedded encrypted relations instead of refusing them. That needs a table-less decrypt on the WASM client, which it does not have today.


Generated by Claude Code

The adapter hands Encryption({ schemas }) only the tables that carry an
encrypted column, but its query pipeline still sent every table's rows to
decryptModel / bulkDecryptModels with that table. Both engines refuse a table
they were not initialised with, so every select() on a plaintext-only table,
and every returning mutation on one, failed with "decryptModel received a
table this client was not initialized with". On the WASM entry, writes failed
too, because its encryptModel checks the table first.

The rule for "has encrypted columns" now lives in one function,
tableHasEncryptedColumns, which both decides what Encryption receives and,
through ColumnMap.hasEncryptedColumns, makes the encrypt and decrypt steps pass
a plaintext table's rows straight through. The two cannot drift apart.

The unit suites missed this because their encryption double ignores the table
argument. The new suite uses a double that enforces registration the way the
real engines do, and a live integration suite covers both construction modes.

Fixes CIP-5151.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jmn7npvR2b5FZLdXNNmCN
…ertext

The passthrough for tables with no encrypted columns returned rows exactly as
PostgREST sent them. A row can still carry an EQL payload: from an embedded
relation to an encrypted table, or from an eql_v3 column that a declared-mode
table declared with {} leaves out. Those reads failed before the passthrough;
after it they returned ciphertext typed as data, which is the hazard
assertTableIsModelled exists to prevent.

The passthrough now walks the rows with isEncryptedPayload, the same predicate
model decryption uses to find ciphertext, and fails with an error that names
the field and never the value. adapter-kit exports the predicate so there is
one spelling of "encrypted value".

The unit double also labels encrypt refusals EncryptionError, matching the
engines. The stash-supabase skill documents plaintext tables and the embedded
relation limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jmn7npvR2b5FZLdXNNmCN
@changeset-bot

changeset-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3334cb7

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

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

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

@cipherstash-agent cipherstash-agent self-assigned this Nov 6, 2026
@coderabbitai

coderabbitai Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

…ot skip it

The integration harness's no-skips reporter fails the run on any skipped test,
and the plaintext-table suite skipped select('*') in declared mode. Declared
mode refuses select('*') because it has no column list to expand, so the test
now asserts that refusal there and the read everywhere else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jmn7npvR2b5FZLdXNNmCN

This branch has not been deployed

No deployments
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