From 41a997a381d3067a8bd10c32733701c1d902829d Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 26 Aug 2026 18:27:50 +1000 Subject: [PATCH 1/2] docs(reference): the Supabase SDK has two entry points, and the edge one runs in a Worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/reference/supabase-sdk.md` still described the state before #912: one entry point, and a factory that "cannot run in a Worker or the browser". #912 added `@cipherstash/stack-supabase/wasm-inline`, which carries no Postgres driver and takes declared `schemas` instead of introspecting. Introspection was the only thing that needed a Postgres socket, so that entry does run in a Worker. The reference never said so. Corrects both halves: - the "One entry point" table now lists both, with the engine, how each learns the schema, and where each runs; - the factory paragraph now scopes its restriction to the native entry and points at the edge entry as the way to run in a Worker. The browser half of the old sentence was correct and is kept, with the reason named: the WASM client requires a workspace `clientKey` on every auth path (cipherstash/stack#804). Internal reference documentation — no package ships `docs/`, so no changeset. The same stale sentence is still live in `skills/stash-supabase/SKILL.md` and `packages/stack-supabase/README.md`, both of which DO ship; those are tracked separately. Claude-Session: https://claude.ai/code/session_01E1J2nVGJWVkqvLepDfinRf --- docs/reference/supabase-sdk.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/reference/supabase-sdk.md b/docs/reference/supabase-sdk.md index abbfa66d4..c2a8806f3 100644 --- a/docs/reference/supabase-sdk.md +++ b/docs/reference/supabase-sdk.md @@ -4,11 +4,16 @@ 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. They differ only in how the wrapper learns the +schema, and therefore in where it can run. Rows already written as EQL v2 still decrypt through `@cipherstash/stack`; what is gone is the ability to author new v2 columns here. @@ -30,8 +35,14 @@ 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`), so this entry cannot run +in a Worker. + +Introspection is the only thing that needs Postgres. To run in a Worker, import +`@cipherstash/stack-supabase/wasm-inline` and declare your tables in `schemas` +instead — that entry carries no Postgres driver and never introspects. It is +still server-side: it is 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' From f31a20229236c7d3977041c3a02b66948d5592ab Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 10:09:17 +1000 Subject: [PATCH 2/2] fix(supabase): pin the Node-only claim to the engine, not introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/lucky-poems-repeat.md | 24 + docs/reference/supabase-sdk.md | 32 +- .../__tests__/wasm-entry-edge-safety.test.ts | 157 ++++++- packages/stack-supabase/src/create.ts | 25 +- packages/stack-supabase/src/index.ts | 20 +- packages/stack-supabase/src/wasm-inline.ts | 8 +- .../supabase-runtime-claims.test.mjs | 421 ++++++++++++++++++ 7 files changed, 656 insertions(+), 31 deletions(-) create mode 100644 .changeset/lucky-poems-repeat.md create mode 100644 scripts/__tests__/supabase-runtime-claims.test.mjs diff --git a/.changeset/lucky-poems-repeat.md b/.changeset/lucky-poems-repeat.md new file mode 100644 index 000000000..b7f49eaf4 --- /dev/null +++ b/.changeset/lucky-poems-repeat.md @@ -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. diff --git a/docs/reference/supabase-sdk.md b/docs/reference/supabase-sdk.md index c2a8806f3..e443e05ed 100644 --- a/docs/reference/supabase-sdk.md +++ b/docs/reference/supabase-sdk.md @@ -12,8 +12,11 @@ Two entry points, EQL v3 only: | `@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. They differ only in how the wrapper learns the -schema, and therefore in where it can run. +`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. @@ -35,13 +38,24 @@ 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 this entry cannot run -in a Worker. - -Introspection is the only thing that needs Postgres. To run in a Worker, import -`@cipherstash/stack-supabase/wasm-inline` and declare your tables in `schemas` -instead — that entry carries no Postgres driver and never introspects. It is -still server-side: it is not browser-safe, because the WASM client requires a +(`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 diff --git a/packages/stack-supabase/__tests__/wasm-entry-edge-safety.test.ts b/packages/stack-supabase/__tests__/wasm-entry-edge-safety.test.ts index 935cc2e17..bb95e19ba 100644 --- a/packages/stack-supabase/__tests__/wasm-entry-edge-safety.test.ts +++ b/packages/stack-supabase/__tests__/wasm-entry-edge-safety.test.ts @@ -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' @@ -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. * @@ -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() + const bare = new Set() + 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 @@ -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 = ()` 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) + }) +}) diff --git a/packages/stack-supabase/src/create.ts b/packages/stack-supabase/src/create.ts index 4394c8d4e..8472eb2ca 100644 --- a/packages/stack-supabase/src/create.ts +++ b/packages/stack-supabase/src/create.ts @@ -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 @@ -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 diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index d25be7cc8..87eac81fb 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -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. diff --git a/packages/stack-supabase/src/wasm-inline.ts b/packages/stack-supabase/src/wasm-inline.ts index 78bc5a639..ce3d7e763 100644 --- a/packages/stack-supabase/src/wasm-inline.ts +++ b/packages/stack-supabase/src/wasm-inline.ts @@ -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 diff --git a/scripts/__tests__/supabase-runtime-claims.test.mjs b/scripts/__tests__/supabase-runtime-claims.test.mjs new file mode 100644 index 000000000..c60ee582a --- /dev/null +++ b/scripts/__tests__/supabase-runtime-claims.test.mjs @@ -0,0 +1,421 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * Why the Supabase adapter's two entry points differ, asserted against the + * prose that says so. + * + * Three claims kept being written down wrong, in the reference doc and in the + * TSDoc that ships as `.d.ts`, and none of them is checkable by a type checker + * or by any test that merely calls the API on Node. + * + * **1. Introspection is not why the native entry is Node-only.** The doc said + * "Introspection needs a direct Postgres connection …, so this entry cannot run + * in a Worker", and `wasm-inline.ts` said "The engine is only half of what made + * the default entry Node-only; the other half is introspection". Both invert + * the dependency. Passing `schemas` skips introspection entirely + * (`create.ts`'s declared mode, tested in `supabase-declared-mode.test.ts`) and + * the entry is *still* Node-only, because it binds the native engine and + * because its emitted bundle carries an `import("pg")` specifier a bundler + * resolves at build time. The reader who believes the causal version reaches + * for `schemas` expecting an edge-capable client and gets a build failure. + * + * **2. Importing `@cipherstash/protect-ffi` does not load a Node-API binary.** + * `index.ts` and `create.ts` both named it as the import-time native load. It + * is the one package in the graph that deliberately does NOT do that: + * `packages/protect-ffi/src/index.cts` writes `import native = + * require('./load.cjs')` precisely so `__importStar` cannot enumerate the + * `@neon-rs/load` proxy into resolving the platform binary, and + * `packages/protect-ffi/src/nativeLoading.test.ts` guards it. The module- + * evaluation-time `dlopen` in that graph belongs to `@cipherstash/auth`, whose + * Node entry ends `module.exports = loadBinding()`. + * `packages/stack-supabase/__tests__/wasm-entry-edge-safety.test.ts` holds the + * mechanical half of this; here we only stop the wrong name being written back. + * + * **3. "a Worker" is ambiguous, and false under the reading most people take + * first.** The native entry runs fine in Node `worker_threads`. What it cannot + * do is run on an edge runtime — Deno, Supabase Edge Functions, Cloudflare + * Workers — which is the list the doc's own table two screens up already + * spells out. Every other document in this repo spells it out too. + * + * The detectors below are unit-tested in both directions before they are + * pointed at the real files. A prose guard that cannot fail is worse than no + * guard, and one that fires on correct wording gets deleted by the next person + * who trips it — so each has a negative case pinning the shape it must NOT + * flag. + */ + +/** + * The reference doc, plus the three sources whose TSDoc ships in `.d.ts`. + * + * `packages/stack-supabase/README.md` belongs on this list and is NOT on it + * yet. It ships in the tarball and carries defect 1 verbatim — "Introspection + * needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an optional + * peer dependency and the factory cannot run in an edge Worker or the browser" + * — but the same lines are being rewritten on the branch behind #951, which + * keeps the false `so` while dropping the browser half. Editing them from two + * branches is a conflict for no gain. **Add the path here when #951 lands**; + * the guard will name whatever survives the merge. + */ +const GUARDED = [ + 'docs/reference/supabase-sdk.md', + 'packages/stack-supabase/src/index.ts', + 'packages/stack-supabase/src/create.ts', + 'packages/stack-supabase/src/wasm-inline.ts', +] + +function read(file) { + return readFileSync(join(REPO_ROOT, file), 'utf8') +} + +/** + * The prose of a file, with everything a reader does not read removed. + * + * Markdown: fenced blocks go — they are the only place a `.` is followed by + * whitespace without ending a sentence. TypeScript: only `/** … *\/` blocks are + * prose at all, so code and line comments are dropped and the leading `*` + * gutter is stripped. + * + * Inline code spans are UNWRAPPED, not deleted. Deleting them is the obvious + * move and it silently disarmed the protect-ffi guard: every mention of the + * package in this repo's prose is inside backticks, so stripping the spans + * removed the exact token the guard matches on and `index.ts` — which names it + * outright — passed. Nothing needs them gone: identifiers like + * `options.databaseUrl` carry no space after the dot, and the sentence split + * below requires one. + */ +function prose(file, source) { + const unwrapInlineCode = (text) => text.replace(/`([^`\n]*)`/g, '$1') + if (file.endsWith('.md')) { + return unwrapInlineCode(source.replace(/^```[\s\S]*?^```/gm, '\n\n')) + } + return unwrapInlineCode( + [...source.matchAll(/\/\*\*([\s\S]*?)\*\//g)] + .map(([, block]) => block.replace(/^[ \t]*\*[ \t]?/gm, '')) + .join('\n\n'), + ) +} + +function sentences(text) { + return text + .split(/\n\s*\n/) + .flatMap((para) => para.replace(/\s+/g, ' ').split(/(?<=[.!?])\s+/)) + .map((s) => s.trim()) + .filter(Boolean) +} + +/** Index of the first match at or after `from`, or -1. */ +function indexFrom(text, pattern, from) { + const rest = text.slice(from).search(pattern) + return rest < 0 ? -1 : from + rest +} + +/** A claim about WHERE code can run. */ +const RUNTIME_CLAIM = + /\b(?:cannot|can(?:no|')?t|could(?: no|n')t)\s+(?:run|be\s+\w+)\b|\bNode[- ]only\b|\bruns?\s+anywhere\b|\bwhere\s+(?:it|this|they|each)\s+(?:can\s+)?runs?\b/i + +/** Schema discovery and the Postgres connection — the thing that is NOT the reason. */ +const SCHEMA_CAUSE = + /\b(?:introspect\w*|schemas?|Postgres connection|database connection)\b/i + +/** Forward causal connectives: "X, so Y". */ +const CAUSAL = /\b(?:so|therefore|hence|thus|and so|which is why)\b/i + +/** Backward causal connectives: "Y because X". */ +const BECAUSE = /\b(?:because|since|as it)\b/i + +/** + * Explanatory nouns — the third way to attribute a cause, with no connective at + * all. `wasm-inline.ts` used it: "The engine is only half of what made the + * default entry Node-only; the other half is introspection". + */ +const ATTRIBUTION = /\b(?:half|reason|cause|why|what made|what makes)\b/i + +/** + * Contrast that breaks the inference. + * + * "Declaring `schemas` removes the Postgres dependency, so no `databaseUrl` is + * needed — but the entry is still Node-only" is CORRECT and contains every + * token the forward pattern looks for; so is "the reason it is Node-only is the + * engine, not introspection". Without this the guard would fire on the very + * sentences the fix wants written. + */ +const CONTRAST = + /\b(?:but|still|even so|regardless|nonetheless|however|anyway|without|not|nor|rather than)\b/i + +/** + * Sentences deriving a runtime restriction from schema discovery. + * + * Three shapes, because English attributes a cause three ways and the tree + * carried one of each: forward connective ("X, so Y") in the doc, backward + * ("Y because X"), and bare apposition ("the other half is X") in + * `wasm-inline.ts`. A guard covering only the first would have passed two of + * the three files it is pointed at. + */ +function falseRuntimeCause(text) { + return sentences(text).filter((sentence) => { + const cause = sentence.search(SCHEMA_CAUSE) + const claim = sentence.search(RUNTIME_CLAIM) + if (cause < 0 || claim < 0) return false + + // "X, so Y" + const forward = indexFrom(sentence, CAUSAL, cause) + if (forward >= 0) { + const after = indexFrom(sentence, RUNTIME_CLAIM, forward) + if (after >= 0 && !CONTRAST.test(sentence.slice(forward, after))) + return true + } + + // "Y because X" — contrast-checked like the forward shape, so that "it is + // Node-only, and not because it introspects" stays sayable. + const backward = indexFrom(sentence, BECAUSE, claim) + if ( + backward >= 0 && + indexFrom(sentence, SCHEMA_CAUSE, backward) >= 0 && + !CONTRAST.test(sentence.slice(claim, backward)) + ) { + return true + } + + // "the other half of what made it Y is X" + const [first, second] = cause < claim ? [cause, claim] : [claim, cause] + if ( + ATTRIBUTION.test(sentence) && + !CONTRAST.test(sentence.slice(first, second)) + ) { + return true + } + return false + }) +} + +/** Another edge runtime named nearby, which makes "Workers" unambiguous. */ +const EDGE_RUNTIME_CONTEXT = /\bDeno\b|\bEdge Functions?\b|\bedge runtimes?\b/i + +/** + * Uses of "Worker" that do not identify the runtime family. + * + * Two ways to qualify one, because the property is whether a reader can tell + * which runtime is meant — not whether a particular word was typed. "Cloudflare + * Workers" says it outright; "On Workers, Deno isolates and Edge Functions" + * says it by the company it keeps, and `create.ts` already writes it that way. + * + * `worker_threads` is not a match: `_` is a word character, so `\bworkers?\b` + * cannot end inside it — which is the distinction the whole guard is about. + */ +function unqualifiedWorkerMentions(text) { + const hits = [] + for (const sentence of sentences(text)) { + if (EDGE_RUNTIME_CONTEXT.test(sentence)) continue + for (const match of sentence.matchAll(/\bworkers?\b/gi)) { + const preceding = sentence.slice( + Math.max(0, match.index - 16), + match.index, + ) + if (!/Cloudflare\s+$/.test(preceding)) hits.push(sentence) + } + } + return hits +} + +/** + * Sentences blaming `@cipherstash/protect-ffi` for an import-time native load. + * + * The negation escape is load-bearing: correcting this text means being able to + * say what protect-ffi does *not* do, and a bare "names it near a load verb" + * rule would forbid the correction along with the error. + */ +const LOAD_VERB = /\b(?:loads?|loading|loaded|dlopen)\b/i +const NEGATED_LOAD = + /\b(?:not|never|n't|no|nothing|avoids?|defers?|deferred|without|until|lazily|lazy)\b[\s\S]{0,60}?\b(?:loads?|loading|loaded|dlopen)\b/i + +function protectFfiImportLoadClaims(text) { + return sentences(text).filter( + (sentence) => + /@cipherstash\/protect-ffi/.test(sentence) && + LOAD_VERB.test(sentence) && + !NEGATED_LOAD.test(sentence), + ) +} + +describe('false-runtime-cause detection', () => { + it.each([ + 'Introspection needs a direct Postgres connection, so this entry cannot run in a Worker.', + 'They differ only in how the wrapper learns the schema, and therefore in where it can run.', + 'The other half is introspection, which opens a Postgres connection, so the entry is Node-only.', + 'This entry is Node-only because it introspects the database.', + // `create.ts`'s exported-factory TSDoc, verbatim. Note what carries it: the + // bare "Declare your schemas and it runs anywhere" half states the false + // claim by implication rather than by connective, and no pattern that + // treats "and" as causal could stay usable. The clause that follows is + // what makes this one mechanically reachable. + '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.', + // `wasm-inline.ts`, verbatim: apposition, no connective anywhere. + 'The engine is only half of what made the default entry Node-only; the other half is introspection, which opens a Postgres connection.', + ])('flags %s', (sentence) => { + expect(falseRuntimeCause(sentence)).toHaveLength(1) + }) + + it.each([ + // The correction: the restriction is attributed to the engine, and the + // schema half is explicitly separated from it. + 'This entry binds the native engine, so it is Node-only.', + 'Passing schemas removes the Postgres dependency, so no databaseUrl is needed, but the entry is still Node-only.', + 'It cannot run on an edge runtime because it binds the native engine.', + 'Only the native entry can introspect, and that is a separate axis from where it runs.', + 'The reason it is Node-only is the native engine, not introspection.', + 'This entry is Node-only, and not because it introspects.', + 'The entry point decides where this runs; schemas decides only whether Postgres is involved.', + ])('does not flag %s', (sentence) => { + expect(falseRuntimeCause(sentence)).toEqual([]) + }) +}) + +describe('unqualified-Worker detection', () => { + it('flags a bare Worker', () => { + expect( + unqualifiedWorkerMentions('this entry cannot run in a Worker.'), + ).toHaveLength(1) + }) + + it('accepts the runtime family spelled out', () => { + expect( + unqualifiedWorkerMentions( + 'edge (Deno, Supabase Edge Functions, Cloudflare Workers)', + ), + ).toEqual([]) + }) + + it('does not flag Node worker_threads, which is the reading that makes the bare word false', () => { + expect( + unqualifiedWorkerMentions('runs fine in Node worker_threads.'), + ).toEqual([]) + }) + + it('accepts Workers named alongside the other edge runtimes', () => { + expect( + unqualifiedWorkerMentions( + 'On Workers, Deno isolates and Edge Functions there is no process.', + ), + ).toEqual([]) + }) +}) + +describe('protect-ffi import-load misattribution detection', () => { + it.each([ + 'Binds the factory to Encryption from the native @cipherstash/stack entry, which loads @cipherstash/protect-ffi — a Node-API binary.', + 'The native entry statically imports @cipherstash/protect-ffi — a Node-API binary that cannot load on an edge runtime.', + ])('flags %s', (sentence) => { + expect(protectFfiImportLoadClaims(sentence)).toHaveLength(1) + }) + + it.each([ + '@cipherstash/protect-ffi deliberately does not load its platform binary at module evaluation.', + '@cipherstash/protect-ffi is the Rust core the native engine encrypts through.', + '@cipherstash/auth loads its platform binding at module evaluation.', + ])('does not flag %s', (sentence) => { + expect(protectFfiImportLoadClaims(sentence)).toEqual([]) + }) +}) + +describe('the Supabase two-entry runtime story, as written', () => { + it('guards the files it means to (a silently-empty read passes everything)', () => { + for (const file of GUARDED) { + expect( + prose(file, read(file)).length, + `${file} yielded no prose`, + ).toBeGreaterThan(500) + } + }) + + it.each(GUARDED)( + '%s does not derive the runtime from schema discovery', + (file) => { + expect( + falseRuntimeCause(prose(file, read(file))), + `${file} attributes a runtime restriction to introspection or to declaring \`schemas\`. The native entry is Node-only because it binds the native engine and because its emitted bundle carries an import("pg") specifier — both true whether or not \`schemas\` is passed. See packages/stack-supabase/__tests__/wasm-entry-edge-safety.test.ts.`, + ).toEqual([]) + }, + ) + + it.each(GUARDED)( + '%s names the edge runtimes rather than "a Worker"', + (file) => { + expect( + unqualifiedWorkerMentions(prose(file, read(file))), + `${file} says "Worker" without naming the runtime family. The native entry works in Node worker_threads; what it cannot do is run on Deno, Supabase Edge Functions, or Cloudflare Workers — the list docs/reference/supabase-sdk.md's own table already spells out.`, + ).toEqual([]) + }, + ) + + it.each(GUARDED)( + '%s does not blame protect-ffi for an import-time load', + (file) => { + expect( + protectFfiImportLoadClaims(prose(file, read(file))), + `${file} says importing \`@cipherstash/protect-ffi\` loads a native binary. It does not: packages/protect-ffi/src/index.cts uses \`import native = require('./load.cjs')\` so the @neon-rs/load proxy is never enumerated into resolving the platform binary, guarded by packages/protect-ffi/src/nativeLoading.test.ts. The module-evaluation-time load in that graph is \`@cipherstash/auth\`'s.`, + ).toEqual([]) + }, + ) +}) + +describe('both entries carry a browser prohibition', () => { + /** The Quick start prose, paragraph by paragraph, up to its first example. */ + function quickStartParagraphs() { + const doc = read('docs/reference/supabase-sdk.md') + const start = doc.indexOf('## Quick start') + expect( + start, + 'docs/reference/supabase-sdk.md has no "## Quick start" heading', + ).toBeGreaterThan(-1) + const fence = doc.indexOf('\n```', start) + expect( + fence, + '"## Quick start" is followed by no code example', + ).toBeGreaterThan(start) + return doc + .slice(start, fence) + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter((p) => p && !p.startsWith('#')) + } + + /** + * PR #952 rewrote this section into two entry-point paragraphs and moved the + * browser caveat onto the edge one only, so a reader of the native paragraph + * saw no browser prohibition at all. Both need one, for different reasons — + * the native entry wants a `databaseUrl` and workspace credentials, the WASM + * client requires a workspace `clientKey` on every auth path (#804). + */ + it('the native-entry prose says it is not browser-safe', () => { + const native = quickStartParagraphs().filter((p) => !/wasm-inline/.test(p)) + expect( + native, + 'no Quick start paragraph describes the native entry', + ).not.toHaveLength(0) + expect( + native.some((p) => /browser/i.test(p)), + 'No Quick start paragraph about the default entry mentions the browser. #952 dropped "or the browser" from the native restriction and attached the caveat to the edge entry alone.', + ).toBe(true) + }) + + it('the edge-entry prose keeps its clientKey grounding', () => { + const edge = quickStartParagraphs().filter((p) => /wasm-inline/.test(p)) + expect( + edge, + 'no Quick start paragraph describes the edge entry', + ).not.toHaveLength(0) + const body = edge.join('\n') + expect( + body, + 'the edge entry must still be called out as not browser-safe', + ).toMatch(/browser/i) + expect( + body, + 'the #804 clientKey grounding is what makes that claim checkable', + ).toMatch(/clientKey/) + expect(body).toMatch(/804/) + }) +})