Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/lucky-poems-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@cipherstash/stack-supabase': patch
---

Correct the runtime story in the TSDoc that ships as `.d.ts`.

Three claims a user sees on hover were wrong:

- `makeEncryptedSupabase` said "Declare your schemas and it runs anywhere; omit
them and we discover them for you, which needs a database connection and is
therefore Node-only." Declaring `schemas` does skip introspection entirely —
no Postgres connection, no `pg`, no `databaseUrl` — but it does not make the
default entry edge-capable. **The entry point decides where the wrapper runs;
`schemas` decides only whether Postgres is involved.**
- The default entry's doc named `@cipherstash/protect-ffi` as the Node-API
binary loaded on import. It is the one package in that graph that
deliberately does not load on import; the module-evaluation-time load belongs
to `@cipherstash/auth`.
- `./wasm-inline`'s doc called introspection "half of what made the default
entry Node-only". The engine is what makes it Node-only, and its emitted
bundle also carries an `import("pg")` specifier a bundler resolves at build
time. Introspection is a separate axis.

Documentation only — no runtime behaviour changes.
37 changes: 31 additions & 6 deletions docs/reference/supabase-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
are transparently encrypted on mutations, `::jsonb`-cast on selects, encrypted
in filter terms, and decrypted in results.

One entry point, EQL v3 only:
Two entry points, EQL v3 only:

| Entry point | Schema DSL | Column storage |
|---|---|---|
| `encryptedSupabase` | `@cipherstash/stack/eql/v3` (EQL v3) | native `public.eql_v3_*` domains |
| Entry point | Engine | Schema | Runtime |
|---|---|---|---|
| `@cipherstash/stack-supabase` | native | introspected from `public.eql_v3_*` domains | Node |
| `@cipherstash/stack-supabase/wasm-inline` | WASM | declared — `schemas` is required | edge (Deno, Supabase Edge Functions, Cloudflare Workers) |

Both author columns with `@cipherstash/stack/eql/v3` and store them in native
`public.eql_v3_*` domains. The entry point you import selects the encryption
engine, and the engine is what fixes the runtime. Schema mode splits the same
way — only the native entry carries a Postgres driver, so only it can
introspect — but it is a separate axis: declaring `schemas` on the native entry
removes its need for Postgres and leaves it on Node.

Rows already written as EQL v2 still decrypt through `@cipherstash/stack`; what
is gone is the ability to author new v2 columns here.
Expand All @@ -30,8 +38,25 @@ free-text by bloom-filter containment).
connect time**: it detects EQL v3 columns by their Postgres domain, derives
each column's encryption config from the domain, and builds the encryption
client internally. Introspection needs a direct Postgres connection
(`options.databaseUrl`, defaulting to `DATABASE_URL`), so the factory cannot
run in a Worker or the browser.
(`options.databaseUrl`, defaulting to `DATABASE_URL`).

This entry is Node-only. That is a property of the engine it binds, not of
introspection: it takes `Encryption` from `@cipherstash/stack`, whose module
graph statically imports `@cipherstash/auth` — a Node-API module whose Node
entry resolves its platform binding at module evaluation — and its own emitted
bundle carries an `import("pg")` specifier that a bundler resolves at build
time. Both are properties of the import, so they hold on a client that never
issues a query, and declaring `schemas` moves neither. It is not browser-safe
either: it wants a `databaseUrl` and the workspace credentials behind it, and
neither belongs in a browser.

Declaring `schemas` buys the Postgres half only — no introspection, no
connection, no `databaseUrl` — and the drift check goes with it. For Deno,
Supabase Edge Functions, or Cloudflare Workers, import
`@cipherstash/stack-supabase/wasm-inline`: it binds the WASM engine, carries no
Postgres driver, and requires `schemas` because it cannot introspect. It is
still server-side — not browser-safe, because the WASM client requires a
workspace `clientKey` on every auth path (cipherstash/stack#804).

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

const DIST = resolve(dirname(fileURLToPath(import.meta.url)), '../dist')
const HERE = dirname(fileURLToPath(import.meta.url))
const DIST = resolve(HERE, '../dist')
const WASM_ENTRY = resolve(DIST, 'wasm-inline.js')
const NATIVE_ENTRY = resolve(DIST, 'index.js')

/** `@cipherstash/stack`'s own emitted root — the engine the native entry binds. */
const STACK_PACKAGE = resolve(HERE, '../../stack')
const STACK_ROOT_ENTRY = resolve(STACK_PACKAGE, 'dist/index.js')

/**
* Strip comments before scanning.
*
Expand All @@ -49,6 +56,29 @@ function specifiers(file: string): string[] {
return [...found].sort()
}

/**
* Every bare specifier reachable from `entry` through its own relative chunks.
*
* tsup code-splits, so an entry's own file names only the chunks it happens to
* start in; the packages it depends on are spread across them. Reading one file
* answers "what does this module import", which is not the question — the
* question is what the module GRAPH pulls in, because that is what evaluates.
*/
function reachableBareSpecifiers(entry: string): string[] {
const seen = new Set<string>()
const bare = new Set<string>()
const walk = (file: string): void => {
if (seen.has(file)) return
seen.add(file)
for (const specifier of specifiers(file)) {
if (specifier.startsWith('.')) walk(resolve(dirname(file), specifier))
else bare.add(specifier)
}
}
walk(entry)
return [...bare].sort()
}

const built = existsSync(WASM_ENTRY) && existsSync(NATIVE_ENTRY)
const describeBuilt = built ? describe : describe.skip

Expand Down Expand Up @@ -88,3 +118,120 @@ describeBuilt('the wasm-inline entry, as emitted', () => {
expect(specifiers(NATIVE_ENTRY)).toContain('pg')
})
})

/**
* What makes the native entry Node-only, asserted rather than asserted-about.
*
* The three `not.toContain` lines above are guarded by a comment claiming the
* package root "is what statically pulls `@cipherstash/protect-ffi` AND
* `@cipherstash/auth` (both Node-API)". Nothing checked it. If
* `@cipherstash/stack` ever stopped importing one of them, those assertions
* would keep passing while proving nothing about it — the classic vacuous
* negative, and this file's own positive control (which checks only
* `@cipherstash/stack` and `pg`) did not reach far enough to catch it.
*
* It is also the executable grounding for the runtime claims in this package's
* TSDoc and in `docs/reference/supabase-sdk.md`, which
* `scripts/__tests__/supabase-runtime-claims.test.mjs` polices as prose. Two
* things had been written down wrong there and both are settled here:
*
* - **Which package loads a binary at import.** Not `@cipherstash/protect-ffi`:
* `packages/protect-ffi/src/index.cts` writes `import native =
* require('./load.cjs')` specifically so `__importStar` cannot enumerate the
* `@neon-rs/load` proxy into resolving the platform binary, and
* `packages/protect-ffi/src/nativeLoading.test.ts` guards that. It is
* `@cipherstash/auth`, whose Node entry evaluates its loader at module scope.
* - **That none of it depends on introspection.** These are import-time
* properties of the module graph. Declaring `schemas` skips introspection
* entirely and moves none of them.
*/
const stackBuilt = existsSync(STACK_ROOT_ENTRY)
const describeStackBuilt = stackBuilt ? describe : describe.skip

describeStackBuilt('the engine the native entry binds', () => {
it('reaches both Node-API packages, which is what the wasm assertions deny', () => {
const reachable = reachableBareSpecifiers(STACK_ROOT_ENTRY)
expect(
reachable,
`${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.`,
).toContain('@cipherstash/auth')
expect(reachable).toContain('@cipherstash/protect-ffi')
})

/**
* The two Node-API packages load their binaries at opposite times, and the
* prose in this package used to name the wrong one. The difference is one
* structural property, readable in both loaders and asserted in both
* directions here — a one-sided check would pass on a tree where they had
* BOTH gone lazy, which is the case that makes the prose wrong.
*
* `@cipherstash/auth`: the platform `require` is reached from an expression
* that runs at module scope, so `import '@cipherstash/auth'` dlopens.
* `@cipherstash/protect-ffi`: every platform `require` is wrapped in an arrow
* and handed to `@neon-rs/load`'s proxy, which resolves nothing until a
* property is read.
*/
const DEFERRED_REQUIRE = /=>\s*(?:\r?\n\s*)?require\(/

it('gets its import-time native load from @cipherstash/auth, which defers nothing', () => {
// Resolved the way Node resolves it from inside `@cipherstash/stack`, so
// this reads the `node` condition's entry — the one an edge bundler would
// NOT pick (both packages also publish a non-`node` WASM condition, which
// is why "it loads a Node-API binary" is not unconditionally true at the
// resolution layer either).
const authEntry = createRequire(
resolve(STACK_PACKAGE, 'package.json'),
).resolve('@cipherstash/auth')

// One hop is enough: the entry requires its platform loader, and the
// loader is where the call lives.
const chain = [authEntry]
for (const match of code(authEntry).matchAll(
/\brequire\(\s*["'](\.[^"']+)["']\s*\)/g,
)) {
chain.push(resolve(dirname(authEntry), match[1]))
}
const bodies = chain
.filter((file) => existsSync(file))
.map((file) => code(file))

// Name-independent on purpose: `module.exports = <anything>()` is the
// property — exports that ARE the result of a call. A rename must not fail
// this; a change of loading strategy must, because that is exactly when
// the prose needs revisiting.
expect(
bodies.filter((body) =>
/^\s*module\.exports\s*=\s*\w+\(\s*\)\s*;?\s*$/m.test(body),
),
`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.`,
).not.toHaveLength(0)

expect(
bodies.filter((body) => DEFERRED_REQUIRE.test(body)),
"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.",
).toHaveLength(0)
})

it('does not get it from protect-ffi, whose loader defers every platform require', () => {
const ffi = resolve(HERE, '../../protect-ffi/src')

// The source, not the emit: `lib/` is another package's build output and
// may not exist when this suite runs.
expect(
readFileSync(resolve(ffi, 'load.cts'), 'utf-8'),
'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.',
).toMatch(DEFERRED_REQUIRE)

expect(
readFileSync(resolve(ffi, 'index.cts'), 'utf-8'),
'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.',
).toMatch(/import\s+native\s*=\s*require\(/)

// The guard that owns this property in full. Duplicating its assertions
// here would be a second, weaker copy of it.
expect(
existsSync(resolve(ffi, 'nativeLoading.test.ts')),
'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.',
).toBe(true)
})
})
25 changes: 16 additions & 9 deletions packages/stack-supabase/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ import { verifyDeclaredSchemas } from './verify'
* `@cipherstash/stack` entry, and `./wasm-inline` supplies it from
* `@cipherstash/stack/wasm-inline`. Everything else about the wrapper is
* identical, so it lives here once. The split exists because the native entry
* statically imports `@cipherstash/protect-ffi` — a Node-API binary that
* cannot load on an edge runtime — and a static import loads whether or not
* the code path is taken.
* statically imports the native engine, whose module graph reaches
* `@cipherstash/auth` — a Node-API module whose Node entry resolves its
* platform binding at module evaluation — and a static import evaluates
* whether or not the code path is taken. (`@cipherstash/protect-ffi` is the
* graph's other Node-API package, and deliberately resolves nothing until
* first use.)
*
* Every `@cipherstash/stack` import in this module is either type-only or on a
* native-free subpath (`adapter-kit`, `eql/v3`, `encryption` types). A value
Expand Down Expand Up @@ -127,12 +130,16 @@ export function makeEncryptedSupabase(
* legacy payloads still decrypt through the core client (`decrypt` /
* `decryptModel`). Handle mixed-generation data explicitly on the caller side.
*
* **Declare your schemas and it runs anywhere; omit them and we discover them
* for you, which needs a database connection and is therefore Node-only.**
* Passing `schemas` skips introspection entirely — no Postgres connection, no
* `pg`, no `databaseUrl` — at the cost of the drift check and of `select('*')`,
* which is refused because nothing enumerated the table's plaintext columns.
* Pass `databaseUrl` alongside `schemas` to keep both.
* **The entry point decides where this runs; `schemas` decides only whether
* Postgres is involved.** The default entry binds the native engine and is
* Node-only; `./wasm-inline` binds the WASM engine and runs on Deno, Supabase
* Edge Functions and Cloudflare Workers. Neither of those moves when you
* declare your tables. Passing `schemas` skips introspection entirely — no
* Postgres connection, no `pg`, no `databaseUrl` — at the cost of the drift
* check and of `select('*')`, which is refused because nothing enumerated the
* table's plaintext columns. Pass `databaseUrl` alongside `schemas` to keep
* both. Omitting `schemas` needs a connection, which only the default entry
* can open.
*
* A column is an EQL v3 column when its type is one of the `public` domains the
* EQL v3 bundle installs. The domain names the capabilities, and introspection
Expand Down
20 changes: 15 additions & 5 deletions packages/stack-supabase/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@ import { eqlRequiresQueryDomains, introspect } from './introspect'
* The default (Node) entry.
*
* Binds the factory to `Encryption` from the native `@cipherstash/stack`
* entry, which loads `@cipherstash/protect-ffi` — a Node-API binary. That
* import is static and top-level, so it happens on import of this module
* whether or not any encryption runs; on an edge runtime it fails there,
* before any of this package's own code. Import
* `@cipherstash/stack-supabase/wasm-inline` instead on those runtimes (#708).
* entry. That import is static and top-level, so the engine's whole module
* graph evaluates on import of this module whether or not any encryption runs
* — and that graph statically imports `@cipherstash/auth`, whose Node entry
* resolves its platform binding at module evaluation. On Deno, Supabase Edge
* Functions or Cloudflare Workers it fails there, before any of this package's
* own code. Import `@cipherstash/stack-supabase/wasm-inline` instead on those
* runtimes (#708).
*
* Not `@cipherstash/protect-ffi`, the graph's other Node-API package: it
* deliberately resolves nothing until first use — see
* `packages/protect-ffi/src/index.cts` and the `nativeLoading.test.ts` beside
* it. And the engine is not the only thing pinning this entry to Node: its own
* emitted bundle carries an `import("pg")` specifier for introspection, which
* a bundler resolves at build time. `__tests__/wasm-entry-edge-safety.test.ts`
* asserts both against the emitted files.
*/
export const encryptedSupabase = makeEncryptedSupabase(
// 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.
Expand Down
8 changes: 5 additions & 3 deletions packages/stack-supabase/src/wasm-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ export interface EncryptedSupabaseWasmFactory {
* binary, so the module graph loads on Deno, Supabase Edge Functions and
* Cloudflare Workers.
*
* The engine is only half of what made the default entry Node-only; the other
* half is introspection, which opens a Postgres connection. This entry cannot
* introspect at all, so `schemas` is required rather than optional.
* The engine is what made the default entry Node-only, and this entry does not
* carry it. Introspection is a separate axis: this one cannot introspect at
* all — it has no Postgres driver — so `schemas` is required rather than
* optional. Declaring them on the DEFAULT entry drops introspection too, and
* leaves that entry exactly as Node-bound as it was.
*
* The client is not passed through as-is: `adaptWasmEncryption` reconciles the
* two engines' protocols, which differ in ways that are silent at construction
Expand Down
Loading
Loading