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 abbfa66d4..e443e05ed 100644 --- a/docs/reference/supabase-sdk.md +++ b/docs/reference/supabase-sdk.md @@ -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. @@ -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' 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/) + }) +})