Skip to content

fix(stack,stack-supabase): edge-safe shared seam — process-free adapter-kit + structural v3 columns - #799

Merged
tobyhede merged 7 commits into
remove-v2from
feat/edge-safe-shared-seam
Jul 27, 2026
Merged

fix(stack,stack-supabase): edge-safe shared seam — process-free adapter-kit + structural v3 columns#799
tobyhede merged 7 commits into
remove-v2from
feat/edge-safe-shared-seam

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Plan 1 of the portable encryptedSupabase series (docs/superpowers/plans/2026-07-24-edge-safe-shared-seam.md). Two independent defect fixes in the shared seam every first-party adapter's Worker / Edge build imports. Both prerequisites for the rest of the series; neither needs any of it.

Base: remove-v2 (the EQL v3 integration branch this line of work targets), not main — the INDEX notes plan 1 defers its CI gate to plan 4 and the four plans ship as one PR, so this should not land standalone against main.

Fix 1 — @cipherstash/stack/adapter-kit importable without a process global

The shared core logger read process.env.STASH_STACK_LOG unguarded while initialising at module scope, so importing adapter-kit — which re-exports that logger — threw ReferenceError: process is not defined before any user code ran, on any runtime with no process (Workers, Deno isolates, Supabase Edge Functions). The read is now guarded.

Not Supabase-only: @cipherstash/stack-supabase, @cipherstash/stack-drizzle and @cipherstash/prisma-next all value-import adapter-kit, so all three are fixed here.

No behaviour change on Node — the logger is guarded, not deferred: STASH_STACK_LOG, its values, its error default, and the point at which it is configured are unchanged.

New regression gate: packages/stack/__tests__/helpers/process-free-realm.mjs evaluates an emitted ESM graph in a node:vm realm with no process; logger-edge-safety.test.ts spawns it against dist/adapter-kit.js (skips when dist/ is absent). packages/stack/turbo.json makes test depend on the package's own build so the gate can't silently skip in CI.

Fix 2 — recognise EQL v3 columns structurally, not by class identity

ColumnMap gated on builder instanceof EncryptedV3Column. tsup emits that class twice (dist/adapter-kit.js and dist/wasm-inline.js are separate esbuild runs), so a table authored with encryptedTable/types from @cipherstash/stack/wasm-inline — what the edge examples use — failed the instanceof for every column. v3Columns came out empty and the adapter treated encrypted columns as plaintext: filter operands reached PostgREST in the clear, while ::jsonb casts and result decryption kept working (different path), so the failure was silent. Tables authored from @cipherstash/stack/eql/v3 were never affected — same class copy the adapter imports.

The check is now a structural four-method probe (isV3ColumnLike), mirroring hasBuildColumnKeyMap — the repo's canonical answer to the same two-copies-of-a-class hazard. Four probes, not two, so a v2 column builder is still rejected.

Tests

  • @cipherstash/stack-supabase: 466 → 489. The two fixes above added 5 (structural-recognition, v2-rejection, wire-level plaintext-leak, fail-closed-at-construction, and a deterministic full-types.*-catalog completeness guard); review feedback added 18 more — see below.
  • @cipherstash/stack-supabase test:types: 44, no type errors.
  • New edge-safety subprocess gate passes against the emitted dist/adapter-kit.js.

Changesets

  • @cipherstash/stack: patch (logger guard) + minor (new adapter-kit export)
  • @cipherstash/stack-supabase: patch × 2
  • stash: patch (skill update)

Review feedback addressed

Three commits on top of the original two fixes. No changeset: test-only, plus one export on a module-private function that does not reach the published surface, plus repo tooling.

Assertions pinned to the fail-closed message (02b1734d). Both .toThrow(/\[supabase v3\]/) matchers were prefix-only, and 32 messages in this package satisfy that prefix — two thrown by ColumnMap itself. Measured rather than assumed: with assertNoPropertyDbNameCollision throwing unconditionally so the fail-closed probe is bypassed, both tests stayed green. They now pin the identity clause and both interpolations, and the same bypass mutant fails both. Extended to supabase-v3-wire.test.ts as well as the flagged site — it is the one guarding the harm (no query string emitted), not just the mechanism.

isV3ColumnLike unit-tested one member at a time (d34a16b6, new __tests__/column-map-predicate.test.ts, +16 tests). No existing test could distinguish a four-probe gate from a two-probe one: every builder reaching the predicate either satisfied all four probes or missed two at once, so nothing was ever one member away from passing. A mutation harness confirmed nine surviving mutants — deleting any single conjunct, weakening any single typeof to true, or dropping the object/null guard all left the 471-test suite green. All nine now die, and precisely: a conjunct deletion fails exactly its two tests, a typeof weakening exactly its one, and the guard's two halves are split so a mutant naming one half fails 1 test vs 4.

The predicate is exported to allow this. Verified not to widen the published surface: dist/index.d.ts and .d.cts contain no mention of it, and both bundles' runtime exports remain exactly encryptedSupabase, encryptedSupabaseV3.

Edge-safety harness reworded (f503d8d0). A new bare specifier now reads as "allowlist it here" rather than "process-global regression" — the gate passes only because everything the adapter-kit graph reaches is bundled via noExternal, which is load-bearing (the chunk it imports carries inlined evlog). Also made turbo.json's dependsOn extend rather than replace the root's, and corrected a comment describing a bundling-isolation.test.ts that does not exist yet.

No skill change in those three commits — none touches a public API, the CLI surface, or a user-facing workflow (Fix 3 below does, and updates stash-supabase accordingly). The stash-supabase "factory cannot run in a Worker" guidance remains accurate throughout: introspect.ts still opens a direct pg connection, so it stays a re-check for the later portable-entry plan.

Fix 3 — diagnose an EQL v2 table instead of crashing

Found while adding the predicate tests above, fixed on request.

A v2 EncryptedTable is structurally identical to a v3 one — same tableName, same columnBuilders — and only buildColumnKeyMap() tells them apart, so nothing inspecting shape caught the swap. Two paths each died with a raw TypeError naming an internal method rather than the version mismatch:

  • The reachable one. encryptedSupabase({ schemas }) with v2 tables — the realistic mistake when migrating — sailed past the record-key check and died in verifyDeclaredSchemas as builder.getEqlType is not a function.
  • Defence-in-depth. Constructing the query builder directly died one layer down in ColumnMap, on the constructor's first statement, as table.buildColumnKeyMap is not a function. Not reachable from the factory (mergeDeclaredTables rebuilds a fresh v3 table), but symmetric with the column-level fail-closed guard already there — which cannot cover it, since it runs after the constructor has already crashed.

Both now name the table and state the fix, and each guard is load-bearing: removing either puts its original TypeError back.

The check routes through hasBuildColumnKeyMap rather than a second hand-written spelling, per that function's own doctrine. It is re-exported from @cipherstash/stack/adapter-kit — the first-party adapter seam this PR is about — and deliberately not promoted to ./types: choosing a wire version is adapter plumbing, not end-user API. Verified edge-safe: dist/adapter-kit.js still evaluates in a process-free realm with no bare specifiers (OK 14 exports).

skills/stash-supabase gains the error under "Legacy: EQL v2", where a migrating reader will hit it.

Notes for review

A prior /code-review pass flagged a "duplicate const v2" (verified false positive — one block-scoped declaration, tsc clean) and a probabilistic fc.constantFrom sample in the catalog test (applied — swapped to a deterministic loop over Object.keys(types) for guaranteed full-catalog coverage, dropping the lone fast-check import).

@tobyhede
tobyhede requested a review from a team as a code owner July 26, 2026 00:39
@changeset-bot

changeset-bot Bot commented Jul 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5761f6b

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 Minor
@cipherstash/stack-supabase Minor
stash Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Minor
@cipherstash/stack-drizzle Minor
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Minor

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 Jul 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b3fc1c5a-5be7-4440-a467-52de683c4091

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/edge-safe-shared-seam

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.

Addresses review on #799.

- ColumnMap now throws at construction when a builder in an AnyV3Table's
  columnBuilders fails the structural v3 probe, instead of silently
  omitting it. An omitted column dropped out of v3Columns and its filter
  operands went to PostgREST as plaintext — the exact leak this work
  closes, from the other direction. Regression tests prove construction
  throws and no PostgREST request is issued.
- Harden the logger env guard against a process defined without env.
Comment thread packages/stack-supabase/__tests__/supabase-schema-builder.test.ts Outdated
Comment thread packages/stack-supabase/src/column-map.ts Outdated

@coderdan coderdan 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.

Some test improvement suggestions but otherwise looks good.

@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.

Reviewed both fixes in the shared adapter seam, verifying each claim against source and running the gates myself.

Approving. Two well-scoped, well-tested defect fixes.

Verified

  • Fix 1 guards the module-scope process.env read in the core logger so @cipherstash/stack/adapter-kit (re-exported to Supabase, Drizzle, Prisma Next) imports cleanly on process-free runtimes. The edge-safety gate asserts against the real emitted dist/adapter-kit.js, not a proxy, and turbo.json wires testbuild so it can't silently skip in CI.
  • Fix 2 replaces a cross-bundle-fragile instanceof EncryptedV3Column gate with a structural four-method probe and fails closed on unrecognised builders — closing a silent plaintext-leak path. The design is sound: columnBuilders is typed to hold only v3 columns, and the completeness test iterating all types.* domains confirms every real v3 column passes the probe, so the throw can't misfire on valid tables.
  • code:check → 0 errors; @cipherstash/stack builds; full stack-supabase suite + test:types pass. ESM/CJS export maps intact (./adapter-kit keeps both import/require with types; EncryptedV3Column is still exported — only the import in column-map.ts was removed). Both changesets present and correctly scoped as patches.

Non-blocking nits

  • __tests__/helpers/process-free-realm.mjs rejects all bare specifiers — stricter than production resolution; works only because the current externals are noExternal/bundled. Documented as intentional, but a future legitimate external would fail this gate and need a harness update rather than signalling a real regression.
  • PR description says the suite goes 466 → 470; the actual run reports 471. Trivial doc drift.
  • No skill change needed here (public surface unchanged) — but the stash-supabase "factory can't run in a Worker" guidance is worth a re-check in the later portable-entry plans that actually make the factory edge-capable.

tobyhede added 4 commits July 27, 2026 15:14
…v3] prefix

Both assertions on the unrecognised-builder path matched `/\[supabase v3\]/`,
which 32 messages across this package satisfy — two of them thrown by
`ColumnMap` itself. Neither test could tell which error it caught.

Demonstrated rather than assumed: making `assertNoPropertyDbNameCollision`
throw unconditionally, so the fail-closed probe is bypassed entirely, left both
tests GREEN. (Deleting the probe outright does turn them red — construction
then succeeds and nothing throws. It is the bypass, not the deletion, the loose
matcher was blind to.) `supabase-v3-wire.test.ts` is the more serious of the
two: it guards the harm — no query string emitted — not just the mechanism.

Both now pin the identity clause and both interpolations. Stops short of the
advisory tail, which carries six unescaped `/` and would make the matcher a
syntax error rather than a stricter test. Re-running the same bypass mutant now
fails both.

Also corrects a misattributed citation the assertion's comment repeats: the v2
`build()`/`getName()` at `schema/index.ts:257,264` are `EncryptedField`'s.
`EncryptedColumn` opens at :275, so its own are at :442,449.
…t a time

`isV3ColumnLike` had no test that could distinguish a four-probe gate from a
two-probe one. Every builder reaching it either satisfied all four probes (the
40 catalog domains, the wasm-authored double) or missed two at once (the v2
stub) — so no fixture was ever one member away from passing.

Measured with a mutation harness: deleting any single conjunct, weakening any
single `typeof` to `true`, or dropping the object/null guard entirely left the
whole 471-test suite green. Nine surviving mutants of those enumerated. (The
`'x' in builder` half of each conjunct is an equivalent mutant — for any
non-exotic object the `typeof` half subsumes it — so those four are correctly
left alive.)

The new cases are each one member apart from a conforming builder, so each
fails if and only if its own probe goes. All nine die, and precisely: deleting
a conjunct fails exactly its two tests, weakening a `typeof` exactly its one,
and the two halves of the object/null guard are split so a mutant that drops
one half names which half it dropped (1 test vs 4).

Also covered: prototype-borne members (the real `Encrypted*Column` classes
carry all four on the prototype, so `in` must not become `Object.hasOwn`), and
a real `encryptedColumn().equality()` v2 builder rather than a hand-rolled
stub — the case the four-probe design exists for. Neither kills a mutant the
others miss; both are kept because they turn a 335-test avalanche and an
opaque `true !== false` into one precise, self-describing failure.

`isV3ColumnLike` is exported to allow this. `column-map.ts` is not re-exported
from `src/index.ts` and the package publishes only `.`, so the published
surface is unchanged: the built `dist/index.d.ts` and `.d.cts` contain no
mention of it, and both bundles' runtime exports remain exactly
`encryptedSupabase, encryptedSupabaseV3`.
…fect

The process-free realm harness rejects every bare specifier, which is stricter
than the resolution any real runtime performs. It passes today only because
everything the adapter-kit graph reaches is bundled via tsup `noExternal` —
load-bearing, not incidental: the chunk adapter-kit imports carries inlined
`evlog`, so without that entry it would emit a bare import. `@cipherstash/auth`
and `@cipherstash/protect-ffi` stay external and are simply unreachable from
that graph; if either became reachable, the gate would fail with a message
reading like a self-containment defect.

The header and the throw now say what the constraint rests on and what to do
about it. No behaviour change — verified by pointing the harness at
`dist/index.js`, whose bare `@cipherstash/auth` import produces the reworded
error.

`turbo.json`'s override also replaced the root `dependsOn` rather than
extending it, resolving `test` to `["build"]` and dropping the inherited
`["^build"]`. This is defence-in-depth rather than a live bug: ordering
survives transitively via `build`'s own `^build`, as `packages/prisma-next`
demonstrates with the identical override and real workspace deps. Made
explicit anyway, matching how the root spells `test:e2e`; prisma-next is left
alone deliberately, since the same reasoning says it is not broken either.

Lastly, `logger-edge-safety.test.ts` described a `bundling-isolation.test.ts`
that spawns this harness against the WASM entry as though it existed. It does
not yet — reworded to future tense, and the turbo/bare-invocation skip
asymmetry noted where a reader will hit it.
A v2 `EncryptedTable` is structurally identical to a v3 one — same `tableName`,
same `columnBuilders` — and only `buildColumnKeyMap()` tells them apart. So
nothing that inspected shape caught the swap, and TypeScript only helps callers
who are actually type-checking.

Two paths, two raw TypeErrors, both naming an internal method rather than the
version mismatch behind it:

- `encryptedSupabase({ schemas })` sailed past the record-key check and died in
  `verifyDeclaredSchemas` as `builder.getEqlType is not a function`. This is the
  reachable one — the realistic mistake is migrating from v2 and passing the old
  `schemas` through.
- Constructing the query builder directly died one layer down in `ColumnMap`, on
  the constructor's very first statement, as `table.buildColumnKeyMap is not a
  function`. Not reachable from the factory today (`mergeDeclaredTables` rebuilds
  a fresh v3 table), so this half is defence-in-depth at the seam — symmetric
  with the column-level fail-closed guard already there, which cannot cover it
  because it runs after the constructor has already crashed.

Both now name the table and state the fix. Each guard is load-bearing: removing
either puts its original TypeError back.

The check routes through `hasBuildColumnKeyMap` rather than a second
hand-written spelling, per that function's own doctrine — "a second hand-written
spelling of the check is how a v2 envelope eventually gets built for a v3 table
once the marker drifts". It is re-exported from `@cipherstash/stack/adapter-kit`,
the first-party adapter seam, and deliberately NOT promoted to `./types`:
deciding which wire version a table targets is adapter plumbing, not end-user
API. Verified edge-safe — `dist/adapter-kit.js` still evaluates in a
process-free realm with no bare specifiers (`OK 14 exports`).

`skills/stash-supabase` gains the error under "Legacy: EQL v2", where a
migrating reader will hit it.
@tobyhede
tobyhede merged commit a0acce8 into remove-v2 Jul 27, 2026
9 checks passed
@tobyhede
tobyhede deleted the feat/edge-safe-shared-seam branch July 27, 2026 23:21
coderdan added a commit that referenced this pull request Jul 28, 2026
The precondition for retrying #798 stage 4, not a consequence of it.

Stage 4 was reverted because the WASM entry threw `ReferenceError: process is
not defined` on import — it had begun importing `@/utils/logger`, whose
`initStackLogger()` read `process.env` at module scope. The useful part is not
the defect but what missed it: 1073 unit tests, the type tests and the
bundle-isolation test were all green while the entry was unusable in every
runtime it exists for.

Nothing could have caught it. `wasm-inline-bundle-isolation.test.ts` reads
import SPECIFIERS, so a global is invisible to it. The Deno e2e runs under
Deno, which provides `process`. The unit suites run under Node, likewise.

So this points the harness #799 built for `dist/adapter-kit.js` at
`dist/wasm-inline.js` — which that test's own docblock anticipated ("the
portable-entry plan will point the same harness at the WASM entry"). It matters
more here than there: the shared operation layer widens this entry's transitive
graph, and anything that graph reaches ships to the edge.

The harness gains an allowed-externals argument. `adapter-kit` has no bare
specifiers; the WASM entry has two, the `/wasm-inline` subpaths of protect-ffi
and auth, which Deno and Workers resolve through an import map (and
`e2e/wasm/deno.json` maps exactly those). They are STUBBED rather than loaded:
the question is whether the graph evaluates without `process`, and loading the
real binding adds a megabyte of unrelated behaviour and its own globals for no
extra signal. Stub exports are `undefined` on purpose — module-scope code that
calls into an external at import time is itself a portability defect and should
fail loudly. Anything not named is still rejected, so a new external on this
entry fails here too, which is the same native-leak class the isolation test
guards.

Verified by breaking it: injecting an unguarded module-scope `process.env` read
into `wasm-inline.ts` and rebuilding makes this fail with the ReferenceError
above. Passing today at 56 exports.
tobyhede added a commit that referenced this pull request Jul 28, 2026
Addresses review on #799.

- ColumnMap now throws at construction when a builder in an AnyV3Table's
  columnBuilders fails the structural v3 probe, instead of silently
  omitting it. An omitted column dropped out of v3Columns and its filter
  operands went to PostgREST as plaintext — the exact leak this work
  closes, from the other direction. Regression tests prove construction
  throws and no PostgREST request is issued.
- Harden the logger env guard against a process defined without env.
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.

3 participants