Skip to content

Commit f31a202

Browse files
committed
fix(supabase): pin the Node-only claim to the engine, not introspection
The reference doc and three TSDoc blocks all derived the default entry's runtime from schema discovery: introspection needs Postgres, therefore the entry cannot run on an edge runtime. That inference is false in both directions. Declaring `schemas` removes the Postgres dependency entirely (create.ts:303-306, :364-367) and the entry is still Node-only; and the entry would be Node-only with no introspection code in it at all. What actually pins it is the import: `Encryption` from `@cipherstash/stack` pulls a module graph that statically imports `@cipherstash/auth`, whose Node entry resolves its platform binding at module evaluation, and the emitted bundle carries an `import("pg")` specifier a bundler resolves at build time. Neither moves when you declare schemas. The default entry's doc also named `@cipherstash/protect-ffi` as the binary loaded on import. It is the one package in that graph that deliberately does not: `packages/protect-ffi/src/index.cts` uses `import native = require(...)` specifically so `__importStar` cannot force the neon proxy to resolve, and `nativeLoading.test.ts` guards it. Two smaller corrections in the same pass: bare "a Worker" is ambiguous and false under the Node `worker_threads` reading — the native entry runs fine there — so the edge runtimes are now named, as the table already named them; and the browser prohibition is restored to the native entry, which the previous revision moved onto the edge entry, leaving the native paragraph implying the browser was fine. Guarded by scripts/__tests__/supabase-runtime-claims.test.mjs (three detectors, unit-tested in both directions, applied to the four prose sources), and by three new assertions in wasm-entry-edge-safety.test.ts that tie the corrected prose to the emitted bundles — its header comment repeated the protect-ffi misattribution and would otherwise have contradicted them. Not touched, to avoid conflicting with open PRs: skills/stash-supabase and packages/stack-supabase/README.md carry defect 1 verbatim but are being rewritten on #951 at those exact lines, and the browser-capability claims in examples/ and packages/stack/tsup.config.ts belong to #953. The README path is recorded in the guard's GUARDED list comment so it is added when #951 lands. Claude-Session: https://claude.ai/code/session_01FVKXa6GjUHN5xvJq2912KA
1 parent 41a997a commit f31a202

7 files changed

Lines changed: 656 additions & 31 deletions

File tree

‎.changeset/lucky-poems-repeat.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@cipherstash/stack-supabase': patch
3+
---
4+
5+
Correct the runtime story in the TSDoc that ships as `.d.ts`.
6+
7+
Three claims a user sees on hover were wrong:
8+
9+
- `makeEncryptedSupabase` said "Declare your schemas and it runs anywhere; omit
10+
them and we discover them for you, which needs a database connection and is
11+
therefore Node-only." Declaring `schemas` does skip introspection entirely —
12+
no Postgres connection, no `pg`, no `databaseUrl` — but it does not make the
13+
default entry edge-capable. **The entry point decides where the wrapper runs;
14+
`schemas` decides only whether Postgres is involved.**
15+
- The default entry's doc named `@cipherstash/protect-ffi` as the Node-API
16+
binary loaded on import. It is the one package in that graph that
17+
deliberately does not load on import; the module-evaluation-time load belongs
18+
to `@cipherstash/auth`.
19+
- `./wasm-inline`'s doc called introspection "half of what made the default
20+
entry Node-only". The engine is what makes it Node-only, and its emitted
21+
bundle also carries an `import("pg")` specifier a bundler resolves at build
22+
time. Introspection is a separate axis.
23+
24+
Documentation only — no runtime behaviour changes.

‎docs/reference/supabase-sdk.md‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ Two entry points, EQL v3 only:
1212
| `@cipherstash/stack-supabase/wasm-inline` | WASM | declared — `schemas` is required | edge (Deno, Supabase Edge Functions, Cloudflare Workers) |
1313

1414
Both author columns with `@cipherstash/stack/eql/v3` and store them in native
15-
`public.eql_v3_*` domains. They differ only in how the wrapper learns the
16-
schema, and therefore in where it can run.
15+
`public.eql_v3_*` domains. The entry point you import selects the encryption
16+
engine, and the engine is what fixes the runtime. Schema mode splits the same
17+
way — only the native entry carries a Postgres driver, so only it can
18+
introspect — but it is a separate axis: declaring `schemas` on the native entry
19+
removes its need for Postgres and leaves it on Node.
1720

1821
Rows already written as EQL v2 still decrypt through `@cipherstash/stack`; what
1922
is gone is the ability to author new v2 columns here.
@@ -35,13 +38,24 @@ free-text by bloom-filter containment).
3538
connect time**: it detects EQL v3 columns by their Postgres domain, derives
3639
each column's encryption config from the domain, and builds the encryption
3740
client internally. Introspection needs a direct Postgres connection
38-
(`options.databaseUrl`, defaulting to `DATABASE_URL`), so this entry cannot run
39-
in a Worker.
40-
41-
Introspection is the only thing that needs Postgres. To run in a Worker, import
42-
`@cipherstash/stack-supabase/wasm-inline` and declare your tables in `schemas`
43-
instead — that entry carries no Postgres driver and never introspects. It is
44-
still server-side: it is not browser-safe, because the WASM client requires a
41+
(`options.databaseUrl`, defaulting to `DATABASE_URL`).
42+
43+
This entry is Node-only. That is a property of the engine it binds, not of
44+
introspection: it takes `Encryption` from `@cipherstash/stack`, whose module
45+
graph statically imports `@cipherstash/auth` — a Node-API module whose Node
46+
entry resolves its platform binding at module evaluation — and its own emitted
47+
bundle carries an `import("pg")` specifier that a bundler resolves at build
48+
time. Both are properties of the import, so they hold on a client that never
49+
issues a query, and declaring `schemas` moves neither. It is not browser-safe
50+
either: it wants a `databaseUrl` and the workspace credentials behind it, and
51+
neither belongs in a browser.
52+
53+
Declaring `schemas` buys the Postgres half only — no introspection, no
54+
connection, no `databaseUrl` — and the drift check goes with it. For Deno,
55+
Supabase Edge Functions, or Cloudflare Workers, import
56+
`@cipherstash/stack-supabase/wasm-inline`: it binds the WASM engine, carries no
57+
Postgres driver, and requires `schemas` because it cannot introspect. It is
58+
still server-side — not browser-safe, because the WASM client requires a
4559
workspace `clientKey` on every auth path (cipherstash/stack#804).
4660

4761
```typescript

‎packages/stack-supabase/__tests__/wasm-entry-edge-safety.test.ts‎

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync, readFileSync } from 'node:fs'
2+
import { createRequire } from 'node:module'
23
import { dirname, resolve } from 'node:path'
34
import { fileURLToPath } from 'node:url'
45
import { describe, expect, it } from 'vitest'
@@ -9,20 +10,26 @@ import { describe, expect, it } from 'vitest'
910
* `@cipherstash/stack-supabase/wasm-inline` is edge-capable only if its module
1011
* graph reaches neither the native engine nor the Postgres driver. Both are
1112
* import-time properties, not runtime ones: a static import of the native
12-
* entry loads `@cipherstash/protect-ffi` whether or not any encryption runs,
13-
* and a dynamic `import('pg')` is still a specifier a bundler resolves at
14-
* build time. Neither failure is visible from any test that merely *calls* the
15-
* API on Node, where both resolve fine.
13+
* entry evaluates the engine's whole graph — `@cipherstash/auth` included,
14+
* which resolves its platform binding right there — whether or not any
15+
* encryption runs, and a dynamic `import('pg')` is still a specifier a bundler
16+
* resolves at build time. Neither failure is visible from any test that merely
17+
* *calls* the API on Node, where both resolve fine.
1618
*
1719
* So this asserts on the emitted file. It is a build-output gate, and it skips
1820
* when `dist/` is absent so `pnpm test` stays green without a prior build —
1921
* the same shape as the adapter-kit edge-safety gate added in #799.
2022
*/
2123

22-
const DIST = resolve(dirname(fileURLToPath(import.meta.url)), '../dist')
24+
const HERE = dirname(fileURLToPath(import.meta.url))
25+
const DIST = resolve(HERE, '../dist')
2326
const WASM_ENTRY = resolve(DIST, 'wasm-inline.js')
2427
const NATIVE_ENTRY = resolve(DIST, 'index.js')
2528

29+
/** `@cipherstash/stack`'s own emitted root — the engine the native entry binds. */
30+
const STACK_PACKAGE = resolve(HERE, '../../stack')
31+
const STACK_ROOT_ENTRY = resolve(STACK_PACKAGE, 'dist/index.js')
32+
2633
/**
2734
* Strip comments before scanning.
2835
*
@@ -49,6 +56,29 @@ function specifiers(file: string): string[] {
4956
return [...found].sort()
5057
}
5158

59+
/**
60+
* Every bare specifier reachable from `entry` through its own relative chunks.
61+
*
62+
* tsup code-splits, so an entry's own file names only the chunks it happens to
63+
* start in; the packages it depends on are spread across them. Reading one file
64+
* answers "what does this module import", which is not the question — the
65+
* question is what the module GRAPH pulls in, because that is what evaluates.
66+
*/
67+
function reachableBareSpecifiers(entry: string): string[] {
68+
const seen = new Set<string>()
69+
const bare = new Set<string>()
70+
const walk = (file: string): void => {
71+
if (seen.has(file)) return
72+
seen.add(file)
73+
for (const specifier of specifiers(file)) {
74+
if (specifier.startsWith('.')) walk(resolve(dirname(file), specifier))
75+
else bare.add(specifier)
76+
}
77+
}
78+
walk(entry)
79+
return [...bare].sort()
80+
}
81+
5282
const built = existsSync(WASM_ENTRY) && existsSync(NATIVE_ENTRY)
5383
const describeBuilt = built ? describe : describe.skip
5484

@@ -88,3 +118,120 @@ describeBuilt('the wasm-inline entry, as emitted', () => {
88118
expect(specifiers(NATIVE_ENTRY)).toContain('pg')
89119
})
90120
})
121+
122+
/**
123+
* What makes the native entry Node-only, asserted rather than asserted-about.
124+
*
125+
* The three `not.toContain` lines above are guarded by a comment claiming the
126+
* package root "is what statically pulls `@cipherstash/protect-ffi` AND
127+
* `@cipherstash/auth` (both Node-API)". Nothing checked it. If
128+
* `@cipherstash/stack` ever stopped importing one of them, those assertions
129+
* would keep passing while proving nothing about it — the classic vacuous
130+
* negative, and this file's own positive control (which checks only
131+
* `@cipherstash/stack` and `pg`) did not reach far enough to catch it.
132+
*
133+
* It is also the executable grounding for the runtime claims in this package's
134+
* TSDoc and in `docs/reference/supabase-sdk.md`, which
135+
* `scripts/__tests__/supabase-runtime-claims.test.mjs` polices as prose. Two
136+
* things had been written down wrong there and both are settled here:
137+
*
138+
* - **Which package loads a binary at import.** Not `@cipherstash/protect-ffi`:
139+
* `packages/protect-ffi/src/index.cts` writes `import native =
140+
* require('./load.cjs')` specifically so `__importStar` cannot enumerate the
141+
* `@neon-rs/load` proxy into resolving the platform binary, and
142+
* `packages/protect-ffi/src/nativeLoading.test.ts` guards that. It is
143+
* `@cipherstash/auth`, whose Node entry evaluates its loader at module scope.
144+
* - **That none of it depends on introspection.** These are import-time
145+
* properties of the module graph. Declaring `schemas` skips introspection
146+
* entirely and moves none of them.
147+
*/
148+
const stackBuilt = existsSync(STACK_ROOT_ENTRY)
149+
const describeStackBuilt = stackBuilt ? describe : describe.skip
150+
151+
describeStackBuilt('the engine the native entry binds', () => {
152+
it('reaches both Node-API packages, which is what the wasm assertions deny', () => {
153+
const reachable = reachableBareSpecifiers(STACK_ROOT_ENTRY)
154+
expect(
155+
reachable,
156+
`${STACK_ROOT_ENTRY} no longer reaches @cipherstash/auth. The "imports neither the native engine nor anything that loads it" assertions above are then vacuous for that package, and the import-time-load claims in src/index.ts and docs/reference/supabase-sdk.md need rewriting.`,
157+
).toContain('@cipherstash/auth')
158+
expect(reachable).toContain('@cipherstash/protect-ffi')
159+
})
160+
161+
/**
162+
* The two Node-API packages load their binaries at opposite times, and the
163+
* prose in this package used to name the wrong one. The difference is one
164+
* structural property, readable in both loaders and asserted in both
165+
* directions here — a one-sided check would pass on a tree where they had
166+
* BOTH gone lazy, which is the case that makes the prose wrong.
167+
*
168+
* `@cipherstash/auth`: the platform `require` is reached from an expression
169+
* that runs at module scope, so `import '@cipherstash/auth'` dlopens.
170+
* `@cipherstash/protect-ffi`: every platform `require` is wrapped in an arrow
171+
* and handed to `@neon-rs/load`'s proxy, which resolves nothing until a
172+
* property is read.
173+
*/
174+
const DEFERRED_REQUIRE = /=>\s*(?:\r?\n\s*)?require\(/
175+
176+
it('gets its import-time native load from @cipherstash/auth, which defers nothing', () => {
177+
// Resolved the way Node resolves it from inside `@cipherstash/stack`, so
178+
// this reads the `node` condition's entry — the one an edge bundler would
179+
// NOT pick (both packages also publish a non-`node` WASM condition, which
180+
// is why "it loads a Node-API binary" is not unconditionally true at the
181+
// resolution layer either).
182+
const authEntry = createRequire(
183+
resolve(STACK_PACKAGE, 'package.json'),
184+
).resolve('@cipherstash/auth')
185+
186+
// One hop is enough: the entry requires its platform loader, and the
187+
// loader is where the call lives.
188+
const chain = [authEntry]
189+
for (const match of code(authEntry).matchAll(
190+
/\brequire\(\s*["'](\.[^"']+)["']\s*\)/g,
191+
)) {
192+
chain.push(resolve(dirname(authEntry), match[1]))
193+
}
194+
const bodies = chain
195+
.filter((file) => existsSync(file))
196+
.map((file) => code(file))
197+
198+
// Name-independent on purpose: `module.exports = <anything>()` is the
199+
// property — exports that ARE the result of a call. A rename must not fail
200+
// this; a change of loading strategy must, because that is exactly when
201+
// the prose needs revisiting.
202+
expect(
203+
bodies.filter((body) =>
204+
/^\s*module\.exports\s*=\s*\w+\(\s*\)\s*;?\s*$/m.test(body),
205+
),
206+
`No module in @cipherstash/auth's Node entry chain (${chain.join(', ')}) invokes its binding loader at module scope. If auth has gone lazy, nothing in this graph dlopens at import, and the runtime prose in src/index.ts, src/create.ts and docs/reference/supabase-sdk.md describes a failure mode that no longer exists.`,
207+
).not.toHaveLength(0)
208+
209+
expect(
210+
bodies.filter((body) => DEFERRED_REQUIRE.test(body)),
211+
"A module in @cipherstash/auth's Node entry chain now defers a require behind an arrow, which is protect-ffi's lazy shape. Re-check which package this package's TSDoc should be naming.",
212+
).toHaveLength(0)
213+
})
214+
215+
it('does not get it from protect-ffi, whose loader defers every platform require', () => {
216+
const ffi = resolve(HERE, '../../protect-ffi/src')
217+
218+
// The source, not the emit: `lib/` is another package's build output and
219+
// may not exist when this suite runs.
220+
expect(
221+
readFileSync(resolve(ffi, 'load.cts'), 'utf-8'),
222+
'packages/protect-ffi/src/load.cts no longer wraps its platform requires in arrows. If protect-ffi now resolves a binary at module scope it becomes a second import-time load, and the correction this file grounds is only half right.',
223+
).toMatch(DEFERRED_REQUIRE)
224+
225+
expect(
226+
readFileSync(resolve(ffi, 'index.cts'), 'utf-8'),
227+
'packages/protect-ffi/src/index.cts no longer uses `import native = require(...)`. That form is the other half of why importing protect-ffi resolves no platform binary — `import * as` would emit `__importStar`, which enumerates the proxy and forces the load.',
228+
).toMatch(/import\s+native\s*=\s*require\(/)
229+
230+
// The guard that owns this property in full. Duplicating its assertions
231+
// here would be a second, weaker copy of it.
232+
expect(
233+
existsSync(resolve(ffi, 'nativeLoading.test.ts')),
234+
'packages/protect-ffi/src/nativeLoading.test.ts is gone. It is what holds protect-ffi to deferred loading; without it the two checks above are the only thing left, and they read source rather than emit.',
235+
).toBe(true)
236+
})
237+
})

‎packages/stack-supabase/src/create.ts‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ import { verifyDeclaredSchemas } from './verify'
2323
* `@cipherstash/stack` entry, and `./wasm-inline` supplies it from
2424
* `@cipherstash/stack/wasm-inline`. Everything else about the wrapper is
2525
* identical, so it lives here once. The split exists because the native entry
26-
* statically imports `@cipherstash/protect-ffi` — a Node-API binary that
27-
* cannot load on an edge runtime — and a static import loads whether or not
28-
* the code path is taken.
26+
* statically imports the native engine, whose module graph reaches
27+
* `@cipherstash/auth` — a Node-API module whose Node entry resolves its
28+
* platform binding at module evaluation — and a static import evaluates
29+
* whether or not the code path is taken. (`@cipherstash/protect-ffi` is the
30+
* graph's other Node-API package, and deliberately resolves nothing until
31+
* first use.)
2932
*
3033
* Every `@cipherstash/stack` import in this module is either type-only or on a
3134
* native-free subpath (`adapter-kit`, `eql/v3`, `encryption` types). A value
@@ -127,12 +130,16 @@ export function makeEncryptedSupabase(
127130
* legacy payloads still decrypt through the core client (`decrypt` /
128131
* `decryptModel`). Handle mixed-generation data explicitly on the caller side.
129132
*
130-
* **Declare your schemas and it runs anywhere; omit them and we discover them
131-
* for you, which needs a database connection and is therefore Node-only.**
132-
* Passing `schemas` skips introspection entirely — no Postgres connection, no
133-
* `pg`, no `databaseUrl` — at the cost of the drift check and of `select('*')`,
134-
* which is refused because nothing enumerated the table's plaintext columns.
135-
* Pass `databaseUrl` alongside `schemas` to keep both.
133+
* **The entry point decides where this runs; `schemas` decides only whether
134+
* Postgres is involved.** The default entry binds the native engine and is
135+
* Node-only; `./wasm-inline` binds the WASM engine and runs on Deno, Supabase
136+
* Edge Functions and Cloudflare Workers. Neither of those moves when you
137+
* declare your tables. Passing `schemas` skips introspection entirely — no
138+
* Postgres connection, no `pg`, no `databaseUrl` — at the cost of the drift
139+
* check and of `select('*')`, which is refused because nothing enumerated the
140+
* table's plaintext columns. Pass `databaseUrl` alongside `schemas` to keep
141+
* both. Omitting `schemas` needs a connection, which only the default entry
142+
* can open.
136143
*
137144
* A column is an EQL v3 column when its type is one of the `public` domains the
138145
* EQL v3 bundle installs. The domain names the capabilities, and introspection

‎packages/stack-supabase/src/index.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,21 @@ import { eqlRequiresQueryDomains, introspect } from './introspect'
77
* The default (Node) entry.
88
*
99
* Binds the factory to `Encryption` from the native `@cipherstash/stack`
10-
* entry, which loads `@cipherstash/protect-ffi` — a Node-API binary. That
11-
* import is static and top-level, so it happens on import of this module
12-
* whether or not any encryption runs; on an edge runtime it fails there,
13-
* before any of this package's own code. Import
14-
* `@cipherstash/stack-supabase/wasm-inline` instead on those runtimes (#708).
10+
* entry. That import is static and top-level, so the engine's whole module
11+
* graph evaluates on import of this module whether or not any encryption runs
12+
* — and that graph statically imports `@cipherstash/auth`, whose Node entry
13+
* resolves its platform binding at module evaluation. On Deno, Supabase Edge
14+
* Functions or Cloudflare Workers it fails there, before any of this package's
15+
* own code. Import `@cipherstash/stack-supabase/wasm-inline` instead on those
16+
* runtimes (#708).
17+
*
18+
* Not `@cipherstash/protect-ffi`, the graph's other Node-API package: it
19+
* deliberately resolves nothing until first use — see
20+
* `packages/protect-ffi/src/index.cts` and the `nativeLoading.test.ts` beside
21+
* it. And the engine is not the only thing pinning this entry to Node: its own
22+
* emitted bundle carries an `import("pg")` specifier for introspection, which
23+
* a bundler resolves at build time. `__tests__/wasm-entry-edge-safety.test.ts`
24+
* asserts both against the emitted files.
1525
*/
1626
export const encryptedSupabase = makeEncryptedSupabase(
1727
// biome-ignore lint/plugin: `EncryptionFactory` names only the shape `construct` uses; the native factory's real signature is a generic tuple overload that cannot be expressed as a plain function type without re-declaring it here.

‎packages/stack-supabase/src/wasm-inline.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ export interface EncryptedSupabaseWasmFactory {
5656
* binary, so the module graph loads on Deno, Supabase Edge Functions and
5757
* Cloudflare Workers.
5858
*
59-
* The engine is only half of what made the default entry Node-only; the other
60-
* half is introspection, which opens a Postgres connection. This entry cannot
61-
* introspect at all, so `schemas` is required rather than optional.
59+
* The engine is what made the default entry Node-only, and this entry does not
60+
* carry it. Introspection is a separate axis: this one cannot introspect at
61+
* all — it has no Postgres driver — so `schemas` is required rather than
62+
* optional. Declaring them on the DEFAULT entry drops introspection too, and
63+
* leaves that entry exactly as Node-bound as it was.
6264
*
6365
* The client is not passed through as-is: `adaptWasmEncryption` reconciles the
6466
* two engines' protocols, which differ in ways that are silent at construction

0 commit comments

Comments
 (0)