diff --git a/.changeset/supabase-docs-wasm-inline-entry.md b/.changeset/supabase-docs-wasm-inline-entry.md
new file mode 100644
index 000000000..c494f6652
--- /dev/null
+++ b/.changeset/supabase-docs-wasm-inline-entry.md
@@ -0,0 +1,184 @@
+---
+'@cipherstash/stack-supabase': patch
+'stash': patch
+'@cipherstash/wizard': patch
+---
+
+Stop telling customers the Supabase wrapper cannot run in a Worker.
+
+`@cipherstash/stack-supabase` has shipped two entry points since #912. The
+package root introspects your database and runs on Node; the `wasm-inline`
+entry carries the WASM engine, takes declared `schemas` instead of
+introspecting, and runs on Deno, Supabase Edge Functions and Cloudflare
+Workers. Introspection was the only thing that needed a Postgres socket, and
+that entry does not do it.
+
+Two shipping documents were never updated and still described the state before
+that change:
+
+- `packages/stack-supabase/README.md` said "the factory cannot run in an edge
+ Worker or the browser" and did not mention the `wasm-inline` entry anywhere
+ in the file. This is the npm package page.
+- `skills/stash-supabase/SKILL.md` said the same thing in its setup section.
+ The skill ships inside the `stash` tarball, and `stash init` copies it into
+ the customer's own repository, where their coding agent reads it as
+ instruction. The one correct mention of the edge entry was in a callout near
+ the top that the setup steps never pointed at, so a reader following the
+ setup never learned the second entry existed.
+
+The population this misled hardest is the one that needs the edge entry most:
+server code on Lovable, v0, Bolt and Replit runs on an edge runtime, which is
+exactly the case `wasm-inline` was built for and exactly the case these
+documents called impossible.
+
+Both files now describe both entries. The README gains an "Edge runtimes"
+section with the call shape; the skill gains a fifth setup step with the same,
+and the introspection paragraph now scopes its restriction to the native entry
+and points there. Both name the four ways the edge entry differs: `schemas` is
+required, `config` is required, `databaseUrl` is refused, and
+`.withLockContext()` / `.audit()` throw rather than silently dropping an
+identity claim (#797).
+
+The **browser** half of the old sentence was correct and is kept, with the
+reason now given: the WASM client requires a workspace `clientKey` on every
+authentication path, so a browser build would ship the key with it (#804).
+
+Four claims that were wrong in the same neighbourhood are corrected while we
+are here, three of them pre-dating this change:
+
+- **`skills/stash-managed-platforms/SKILL.md` shipped a snippet that does not
+ compile.** It authored the `schemas` object from `@cipherstash/stack/wasm-inline`
+ and handed it to `encryptedSupabase` from `@cipherstash/stack-supabase/wasm-inline`.
+ The adapter types `schemas` from `@cipherstash/stack/eql/v3`, and the two
+ entries ship independent declarations of the column classes whose private
+ `columnName` field TypeScript compares nominally — so `tsc` rejects it while
+ the code runs perfectly, which is why nobody noticed. The schema import now
+ comes from `eql/v3`, and a new guard
+ (`scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs`) fails if any
+ shipped document pairs the two again.
+- **`skills/stash-edge/SKILL.md` is what produced that snippet.** Its "Schema
+ Modules Do Not Cross Entries" section told edge projects to author schemas
+ from `@cipherstash/stack/wasm-inline` with no carve-out. The rule is really
+ "author against the entry whose *client type* consumes the schema": a raw
+ `Encryption` client from `wasm-inline` wants `wasm-inline` tables, but the
+ Supabase adapter wants `eql/v3` tables on both of its entries, WASM engine or
+ not. The section now says so, and the "The Supabase adapter has its own edge
+ entry" note points at it.
+- **`skills/stash-supabase/SKILL.md` described the wrong failure mode for a
+ missing declaration.** Omitting `schemas` on the edge entry cannot produce a
+ no-column client — it is non-optional on the type and throws at construction —
+ and an undeclared *table* throws rather than passing through unencrypted. The
+ hazard is an undeclared **column** on a declared table, which is treated as
+ plaintext; the bullet now says that, along with the one thing that limits it
+ (a plaintext write to an `eql_v3_*` column fails the domain CHECK, though a
+ NULL still passes) and the fact that the native entry's warning about
+ unverified declarations is gated on the introspector and so never fires
+ there. Reads get no equivalent backstop, and the bullet now says so: the
+ `select('*')` refusal looks like one, but a query awaited with no
+ `.select()` at all takes the raw-`*` branch in `query-builder.ts` and
+ returns every column undecrypted.
+- **"Undeclared tables behave exactly as with no `schemas` at all" was false on
+ the native entry too.** Introspection is gated on a resolved database URL,
+ not on the absence of `schemas`, and an ambient `DATABASE_URL` is
+ deliberately ignored once tables are declared. The statement holds only when
+ `databaseUrl` is passed *alongside* `schemas`, which is what it now says.
+
+`config` is corrected everywhere that called all four `CS_*` values mandatory:
+the README, `skills/stash-supabase/SKILL.md`, and — in `skills/stash-edge/SKILL.md`
+— its frontmatter description, its Credentials section, its troubleshooting
+advice, and the native-vs-WASM comparison table. That skill already contradicted
+itself, since its own `config.authStrategy` example passes two values, not four.
+The same sentence in the `EncryptedSupabaseWasmOptions` doc comment
+(`packages/stack-supabase/src/wasm-inline.ts`) is fixed too, comment-only. Only
+`clientId` and `clientKey` are always required. Beyond them the config is a union — the
+access-key path adds `workspaceCrn` + `accessKey`, and the strategy path takes
+a pre-built `config.authStrategy` and makes `workspaceCrn` optional, because a
+built strategy already carries the CRN. `OidcFederationStrategy` is re-exported
+from `@cipherstash/stack/wasm-inline`, so authenticating as the end user works
+on the edge; what does not work is binding data to that user, which stays
+called out in its own `.withLockContext()` bullet.
+
+Tests now anchor the corrected claims against the code rather than against
+prose. `packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts` asserts
+at the type level that the edge `config` accepts both the access-key arm and a
+strategy-only arm without `workspaceCrn`, and rejects `clientId` + `clientKey`
+alone. `supabase-declared-mode.test.ts` gains a case pinning the real hazard: an
+undeclared column on a declared table reaches PostgREST as plaintext on insert,
+update and filter, and is absent from the decrypt call.
+
+Review found four more, one of them a change to a published type:
+
+- **`databaseUrl` was only refused for callers who wrote the options inline.**
+ `EncryptedSupabaseWasmOptions` left the field out, and omission is policed by
+ excess-property checking, which fires on fresh object literals alone. An
+ options object assembled as a `const` and passed by variable — which is what
+ a Node-to-edge port actually holds — type-checked clean and reached the
+ construction-time throw instead, from documents saying the type checker
+ enforced it. The field is now declared `databaseUrl?: never`, mirroring
+ `WasmClientConfig.eqlVersion?: never` in `@cipherstash/stack`, which exists
+ for the identical reason one package along. The runtime throw stays as the
+ backstop for plain JS. New type tests cover the inline and by-variable
+ shapes on both call forms, plus a positive control that the same options
+ object still compiles once `databaseUrl` is dropped.
+- **The `select('*')` correction had landed in only one of its two shipped
+ copies.** `skills/stash-supabase/SKILL.md` carried it;
+ `skills/stash-managed-platforms/SKILL.md`, edited in the same change, still
+ framed declared mode as giving things up "loudly rather than silently" over a
+ bullet naming the refusal — precisely the inference the correction exists to
+ kill. That skill is read as instruction by an agent on Lovable, v0, Bolt or
+ Replit, and the failure it mispromised is silent: raw EQL payloads returned
+ as `data`, no error. The wording is carried across, and a new guard
+ (`scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs`) fails
+ if any shipped document states the refusal without the caveat in the same
+ section. Two copies of one fact drift the moment one of them is edited, and
+ no reviewer diff shows the copy nobody touched.
+- **"Everything after construction is the same wrapper" was false in the first
+ way a reader hits it.** Both `packages/stack-supabase/README.md` and
+ `skills/stash-supabase/SKILL.md` said `from()`, the filters and the response
+ shape are identical across the two entries — the README saying so twenty-five
+ lines under a paragraph telling the same reader `select('*')` just works. The
+ edge entry is always in declared mode, where `select('*')` is refused and
+ `from()` on an undeclared table throws. Both sentences now name the two
+ exceptions, so the quick-start snippet the section tells you to port no
+ longer arrives with a promise it breaks.
+- **The ambient-`DATABASE_URL` warning cannot fire on the edge entry.**
+ `skills/stash-managed-platforms/SKILL.md` said an ambient `DATABASE_URL` is
+ ignored when `schemas` are passed, "with a warning that the declaration is
+ unverified" — in a section whose subject is `wasm-inline`. Both the ambient
+ read and the warning are gated on the introspector, which that build does not
+ have, so nothing there ever tells you a declaration is incomplete. Now scoped
+ to Node, with the edge case stated. The sentence immediately above it had the
+ same fault and is fixed with it: the ⚠️ callout on undeclared columns offered
+ "pass `databaseUrl` so introspection fills the gaps" as the remedy, inside a
+ section about the entry that refuses `databaseUrl` — and it is the remedy a
+ Lovable or Replit agent, which has only the edge entry, would have reached
+ for. It now says introspection is unavailable there and what to do instead.
+
+`@cipherstash/wizard` is bumped alongside `stash` because `skills/` ships in
+both tarballs — `packages/wizard/tsup.config.ts` copies it into `dist/skills`
+and `package.json` lists that under `files`. Without the bump the published
+wizard keeps shipping the old text until some unrelated change moves its
+version.
+
+One more wording correction, of the kind #952 fixed in this package's `.d.ts`:
+`packages/stack-supabase/README.md` and `skills/stash-supabase/SKILL.md` both
+derived "runs on Node only" from introspection — "introspection needs a direct
+Postgres connection, **so** … this entry runs on Node only". Introspection is
+not the cause. The entry binds the native engine, so it is Node-only whether or
+not you declare `schemas`; a reader who took the stated cause at face value
+would conclude that declaring tables makes the root entry edge-capable, which is
+the exact wrong turn the `wasm-inline` entry exists to prevent. Both sentences
+now attribute the restriction to the engine and say that declaring `schemas`
+does not move it. Both files are enrolled in #952's
+`scripts/__tests__/supabase-runtime-claims.test.mjs`, which is what its own
+comment said to do once this branch stopped rewriting the same lines — the
+README and this skill are the two copies that reach a customer, and the guard
+now fails if either grows the claim back. `skills/stash-managed-platforms/SKILL.md`
+stays out, with the reason written down: its causal claims are correct, but
+rewording the unqualified "Worker" in its frontmatter `description` changes
+what the skill matches on, which is not a rider on a documentation fix.
+
+No runtime behaviour changes. The one non-documentation change is the
+`databaseUrl?: never` field on `EncryptedSupabaseWasmOptions`, which is
+type-level: it rejects at compile time a call that already threw at
+construction.
diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md
index f3a980ed8..e55e2e0d2 100644
--- a/packages/stack-supabase/README.md
+++ b/packages/stack-supabase/README.md
@@ -87,9 +87,10 @@ Full guide: [Supabase quickstart →][supabase-docs]
config to maintain — `select('*')` just works, inserts and updates encrypt automatically, and
reads decrypt automatically.
-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 — construct it in your
-server-side code.
+Introspection needs a direct Postgres connection (`DATABASE_URL`), which is why `pg` is an
+optional peer dependency. This entry runs on Node only either way — it binds the native engine,
+and declaring `schemas` doesn't move that — so construct it in your server-side code. For an
+edge runtime, see the second entry point below.
It runs alongside Supabase Auth and RLS, and supports
[identity-locking encryption][identity] — binding a row's data key to the signed-in user's
@@ -98,6 +99,57 @@ JWT claim — via the same lock-context API as the rest of the Stack.
> `encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of `encryptedSupabase`,
> so existing imports keep working.
+## Edge runtimes: `@cipherstash/stack-supabase/wasm-inline`
+
+Deno, Supabase Edge Functions and Cloudflare Workers cannot load a native module or open a raw
+Postgres socket. The `wasm-inline` entry point has neither requirement: the encryption engine is
+a WASM blob inlined into the bundle, and you declare your tables instead of introspecting them.
+
+| Entry point | Engine | Schema | Runs on |
+| --- | --- | --- | --- |
+| `@cipherstash/stack-supabase` | native | introspected from the `public.eql_v3_*` domains | Node |
+| `@cipherstash/stack-supabase/wasm-inline` | WASM, inlined | declared — `schemas` is required | Deno, Supabase Edge Functions, Cloudflare Workers |
+
+After construction the wrapper behaves the same — the filters and the response shape are
+identical — with two exceptions. Declared mode refuses `select('*')` and bare `select()`, so name
+the columns you want; that refusal is not a read backstop, because a query awaited with no
+`.select()` at all still returns every column undecrypted. And `from()` on a table you did not
+declare throws, because there is no introspected table list to fall back on.
+
+```ts
+import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
+import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline'
+
+const users = encryptedTable('users', { email: types.TextSearch('email') })
+
+const es = await encryptedSupabase(supabaseUrl, supabaseKey, {
+ schemas: { users },
+ config: {
+ workspaceCrn: Deno.env.get('CS_WORKSPACE_CRN')!,
+ accessKey: Deno.env.get('CS_CLIENT_ACCESS_KEY')!,
+ clientId: Deno.env.get('CS_CLIENT_ID')!,
+ clientKey: Deno.env.get('CS_CLIENT_KEY')!,
+ },
+})
+```
+
+Four differences from the entry above, three of them enforced by the type checker: `schemas` is
+required, because nothing introspects here; `config` is required, because there is no
+`~/.cipherstash` on an edge runtime to discover credentials from; `databaseUrl` is refused; and
+`.withLockContext()` / `.audit()` throw rather than silently dropping the identity claim — the
+WASM engine does not implement them yet ([#797][issue-797]).
+
+`config` always needs `clientId` and `clientKey`. Past those it is a union: pass
+`workspaceCrn` + `accessKey` for the access-key path shown above, or a pre-built
+`config.authStrategy` — `AccessKeyStrategy` or `OidcFederationStrategy`, both re-exported from
+`@cipherstash/stack/wasm-inline` — which already carries the CRN and so makes `workspaceCrn`
+optional. Authenticating as the end user over OIDC federation therefore works on the edge; what
+does not is binding data to that user with `.withLockContext()`.
+
+This entry is ESM-only, and it is server-side rather than browser-safe: the WASM client requires
+a workspace `clientKey` on every authentication path, so a browser build would ship the key with
+it ([#804][issue-804]).
+
## How it works
@@ -132,3 +184,5 @@ it should, and the EQL install needs no superuser (it works on cloud-hosted Supa
[eql]: https://github.com/cipherstash/encrypt-query-language
[stack-drizzle]: https://www.npmjs.com/package/@cipherstash/stack-drizzle
[stack-prisma]: https://www.npmjs.com/package/@cipherstash/stack-prisma
+[issue-797]: https://github.com/cipherstash/stack/issues/797
+[issue-804]: https://github.com/cipherstash/stack/issues/804
diff --git a/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts b/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts
index e8ce4ca57..c3ce46944 100644
--- a/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts
+++ b/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts
@@ -2,6 +2,12 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makeEncryptedSupabase } from '../src/create'
import type { SupabaseClientLike } from '../src/types'
+import {
+ createMockEncryptionClient,
+ createMockSupabase,
+ fakeEnvelope,
+ isFakeEnvelope,
+} from './helpers/supabase-mock'
/**
* Declared-schemas mode (#708).
@@ -348,3 +354,141 @@ describe('constructing where there is no `process` global', () => {
}
})
})
+
+/**
+ * The hazard declared mode actually carries: an undeclared COLUMN on a
+ * DECLARED table is silently treated as plaintext.
+ *
+ * The two loud failures are already pinned above — an undeclared TABLE throws
+ * (`from()`, line ~148) and `select('*')` is refused (line ~115). Neither is a
+ * blanket-read backstop: a query awaited with no `.select()` at all takes
+ * `query-builder.ts`'s raw-`*` branch and returns every column undecrypted.
+ * What is NOT loud, and is the real risk, is a table you declared
+ * but declared incompletely. Nothing in the client can detect it: with no
+ * introspection there is no column list to compare the declaration against, so
+ * an `eql_v3_*` column missing from `schemas` never enters the encrypt config
+ * and never gets a `::jsonb` cast. `schema-builder.ts` says it plainly —
+ * "Undeclared columns stay synthesized" — and in declared mode nothing was
+ * synthesized.
+ *
+ * The one warning that names this (`create.ts`, "any encrypted column missing
+ * from the declaration is treated as plaintext") is gated on `introspector`,
+ * so it NEVER fires on the edge entry — where declared mode is the only mode
+ * and this is therefore the only failure shape available. The shipped
+ * `stash-supabase` skill described a different hazard entirely (#812), so
+ * these assertions exist to keep the corrected wording honest.
+ *
+ * Asserted on the observable wire payload via the same doubles the builder
+ * suites use, not on internals: what the request body carries, what the select
+ * string casts, and what comes back to the caller.
+ */
+describe('an undeclared column on a declared table', () => {
+ /**
+ * `users` above declares `email` and `age`. This one declares `email` only —
+ * standing in for a table whose `secret` column really is a
+ * `public.eql_v3_text_eq` in the database, and whose author forgot it.
+ */
+ const partial = encryptedTable('users', {
+ email: types.TextSearch('email'),
+ })
+
+ type UntypedRow = Record
+ type LooseBuilder = {
+ select(columns?: string): LooseBuilder
+ insert(data: UntypedRow): LooseBuilder
+ update(data: UntypedRow): LooseBuilder
+ eq(column: string, value: unknown): LooseBuilder
+ } & PromiseLike<{ data: UntypedRow[] | null }>
+
+ /** The decrypt entry point, typed to the two arguments this test reads. */
+ type DecryptSpyTarget = {
+ bulkDecryptModels(
+ rows: UntypedRow[],
+ table: { buildColumnKeyMap(): Record },
+ ): unknown
+ }
+
+ async function declaredWireClient(rows: UntypedRow[] = []) {
+ const supabase = createMockSupabase(rows)
+ const encryption =
+ createMockEncryptionClient() as unknown as DecryptSpyTarget
+ encryptionMock.mockResolvedValue(encryption)
+ const { encryptedSupabase } = await import('../src/index')
+ const client = await encryptedSupabase(
+ supabase.client as unknown as SupabaseClientLike,
+ { schemas: { users: partial } },
+ )
+ const from = (table: string) =>
+ (client as unknown as { from(t: string): LooseBuilder }).from(table)
+ return { from, supabase, encryption }
+ }
+
+ it('is written to the database IN THE CLEAR on insert', async () => {
+ const { from, supabase } = await declaredWireClient()
+
+ await from('users').insert({ email: 'ada@example.com', secret: 'hunter2' })
+
+ const body = supabase.callsFor('insert')[0].args[0] as UntypedRow
+ // The declared column is encrypted...
+ expect(isFakeEnvelope(body.email)).toBe(true)
+ // ...and the undeclared one is not. It leaves the process as plaintext.
+ expect(body.secret).toBe('hunter2')
+ expect(isFakeEnvelope(body.secret)).toBe(false)
+ })
+
+ it('is written in the clear on update too', async () => {
+ const { from, supabase } = await declaredWireClient()
+
+ await from('users').update({ secret: 'hunter2' }).eq('id', 1)
+
+ const body = supabase.callsFor('update')[0].args[0] as UntypedRow
+ expect(body.secret).toBe('hunter2')
+ expect(isFakeEnvelope(body.secret)).toBe(false)
+ })
+
+ it('is filtered on in the clear, so the search term leaks too', async () => {
+ const { from, supabase } = await declaredWireClient()
+
+ await from('users').select('id').eq('secret', 'hunter2')
+
+ // The operand reaches PostgREST unencrypted — which also means it can only
+ // ever match plaintext rows, so the query silently returns nothing.
+ expect(supabase.callsFor('eq')[0].args).toEqual(['secret', 'hunter2'])
+ })
+
+ it('gets no ::jsonb cast on select, so nothing can decrypt it', async () => {
+ const { from, supabase } = await declaredWireClient()
+
+ await from('users').select('id, email, secret')
+
+ const emitted = supabase.callsFor('select')[0].args[0] as string
+ expect(emitted).toContain('email::jsonb')
+ expect(emitted).not.toContain('secret::jsonb')
+ })
+
+ /**
+ * The other half of "never decrypted": the table the adapter hands to the
+ * decrypt call is the MERGED one, and in declared mode that is the
+ * declaration verbatim. A column absent from it is a column nothing looks
+ * for, so the stored ciphertext is returned to the caller untouched — no
+ * error, no warning, and a row type that still claims `string`.
+ *
+ * Asserted on the decrypt call rather than on the returned row because the
+ * shared encryption double decrypts by envelope SHAPE, which the real client
+ * cannot do here: without the `::jsonb` cast asserted above, PostgREST sends
+ * the domain's text rendering and there is no envelope to recognise.
+ */
+ it('is absent from the table handed to the decrypt call', async () => {
+ const { from, encryption } = await declaredWireClient([
+ { id: 1, email: fakeEnvelope('ada@example.com', 'email'), secret: 'x' },
+ ])
+ const decrypt = vi.spyOn(encryption, 'bulkDecryptModels')
+
+ await from('users').select('id, email, secret')
+
+ const table = decrypt.mock.calls[0][1]
+ const known = Object.keys(table.buildColumnKeyMap())
+ expect(known).toContain('email')
+ expect(known).not.toContain('secret')
+ })
+})
diff --git a/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts b/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts
new file mode 100644
index 000000000..db3d76b9c
--- /dev/null
+++ b/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts
@@ -0,0 +1,262 @@
+/**
+ * What `config` on the edge entry actually requires (#812).
+ *
+ * The shipped skill and the package README both said `config` on
+ * `@cipherstash/stack-supabase/wasm-inline` "must carry all four `CS_*`
+ * values", because there is no `~/.cipherstash` to discover credentials from
+ * on an edge runtime. That is true of exactly ONE of the three arms
+ * `WasmClientConfig` accepts (`packages/stack/src/wasm-inline.ts`): the
+ * access-key arm. On the `authStrategy` arm — the one an
+ * `OidcFederationStrategy` takes, i.e. every identity-aware edge deployment —
+ * `accessKey` is `never` and `workspaceCrn` is OPTIONAL, because a pre-built
+ * strategy already carries the CRN. A reader who believed the docs would
+ * either invent an access key they do not have, or conclude the edge entry
+ * cannot do per-user encryption at all.
+ *
+ * The claim is a claim about the TYPE, so it is pinned at the type level: the
+ * runtime never sees the difference until it tries to authenticate. Both arms
+ * must type-check through `encryptedSupabase`'s own `config` option, not
+ * merely through `WasmClientConfig` in isolation —
+ * `EncryptedSupabaseWasmOptions` re-declares that field and could narrow it.
+ *
+ * The negative is the floor the docs SHOULD describe: `clientId` + `clientKey`
+ * with neither an access key nor a strategy satisfies no arm.
+ *
+ * The file has since grown a second claim about the same options object — that
+ * `databaseUrl` cannot be passed at all. See the second `describe` for why
+ * leaving the field out was not, on its own, enough to enforce that.
+ *
+ * Runs under `pnpm --filter @cipherstash/stack-supabase test:types`.
+ * `@cipherstash/stack/wasm-inline` has no `paths` entry in
+ * `tsconfig.json`, so it resolves through the workspace `exports` map to
+ * `packages/stack/dist/wasm-inline.d.ts` — build `@cipherstash/stack` first.
+ */
+
+import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
+import type {
+ AccessKeyStrategy,
+ OidcFederationStrategy,
+ WasmClientConfig,
+} from '@cipherstash/stack/wasm-inline'
+import { describe, expectTypeOf, it } from 'vitest'
+import type { SupabaseClientLike } from '../src/types.js'
+import { encryptedSupabase } from '../src/wasm-inline.js'
+
+declare const supabaseClient: SupabaseClientLike
+
+/**
+ * Strategy INSTANCES, not the classes. `config.authStrategy` takes a built
+ * strategy (`OidcFederationStrategy.create(…)`), and both classes are
+ * re-exported from `@cipherstash/stack/wasm-inline` precisely so an edge
+ * consumer needs no separate `@cipherstash/auth` import.
+ */
+declare const oidc: OidcFederationStrategy
+declare const accessKeyStrategy: AccessKeyStrategy
+
+const users = encryptedTable('users', {
+ email: types.TextSearch('email'),
+})
+
+describe('the edge entry `config` accepts either auth arm', () => {
+ it('accepts the access-key arm — the one the docs described', async () => {
+ await encryptedSupabase(supabaseClient, {
+ schemas: { users },
+ config: {
+ workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id',
+ accessKey: 'CS_CLIENT_ACCESS_KEY',
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ })
+ })
+
+ it('accepts the authStrategy arm with NO workspaceCrn and NO accessKey', async () => {
+ await encryptedSupabase(supabaseClient, {
+ schemas: { users },
+ config: {
+ authStrategy: oidc,
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ })
+ })
+
+ it('accepts an AccessKeyStrategy instance on that same arm', async () => {
+ await encryptedSupabase(supabaseClient, {
+ schemas: { users },
+ config: {
+ authStrategy: accessKeyStrategy,
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ })
+ })
+
+ it('still allows workspaceCrn alongside a strategy — optional, not banned', async () => {
+ await encryptedSupabase(supabaseClient, {
+ schemas: { users },
+ config: {
+ workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id',
+ authStrategy: oidc,
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ })
+ })
+
+ it('rejects clientId + clientKey alone — the genuine required floor', async () => {
+ await encryptedSupabase(supabaseClient, {
+ schemas: { users },
+ // @ts-expect-error — no accessKey and no authStrategy satisfies no arm
+ config: {
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ })
+ })
+
+ it('rejects mixing an access key with a strategy', async () => {
+ await encryptedSupabase(supabaseClient, {
+ schemas: { users },
+ // @ts-expect-error — `accessKey` is `never` on the strategy arm
+ config: {
+ workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id',
+ accessKey: 'CS_CLIENT_ACCESS_KEY',
+ authStrategy: oidc,
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ })
+ })
+
+ /**
+ * The positive control for the two `@ts-expect-error`s above. If
+ * `EncryptedSupabaseWasmOptions['config']` ever widened to `unknown` or the
+ * native `ClientConfig`, both directives would go unused and vitest's
+ * typecheck would report THAT — but only if this file still says which type
+ * is meant to be under test.
+ */
+ it('is `WasmClientConfig`, not the native optional ClientConfig', () => {
+ expectTypeOf<
+ Parameters>[1]['config']
+ >().toEqualTypeOf()
+ })
+})
+
+/**
+ * `databaseUrl` is not merely ABSENT from the edge entry's options — it is
+ * declared `?: never` (`src/wasm-inline.ts`), and the difference between those
+ * two is the whole reason this block exists.
+ *
+ * Absence is enforced by excess-property checking alone, which fires on FRESH
+ * object literals only. An options object assembled as a `const` and passed by
+ * variable — which is what a Node → edge port actually holds, since the native
+ * entry's options are typically built once and reused — loses freshness at the
+ * declaration, carries no excess-property check at the call, and so
+ * type-checked clean before reaching the runtime throw in
+ * `makeEncryptedSupabase` (`src/create.ts`). The docs meanwhile claimed the
+ * type checker enforced it.
+ *
+ * Same failure mode and same fix as `WasmClientConfig.eqlVersion?: never` in
+ * `packages/stack/src/wasm-inline.ts`, whose comment describes exactly this
+ * shared-config-const path; this is its sibling one package along.
+ *
+ * The runtime throw stays regardless — it is the backstop for plain JS, where
+ * there is no type to consult. What is pinned here is the half a type CAN
+ * enforce, in both the shapes a caller writes it in.
+ */
+describe('the edge entry refuses `databaseUrl`', () => {
+ it('rejects it on a fresh object literal — the excess-property case', async () => {
+ await encryptedSupabase(supabaseClient, {
+ schemas: { users },
+ config: {
+ workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id',
+ accessKey: 'CS_CLIENT_ACCESS_KEY',
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ // @ts-expect-error — this entry carries no Postgres driver and cannot introspect
+ databaseUrl: 'postgres://user:pass@localhost:5432/postgres',
+ })
+ })
+
+ it('rejects it on a fresh object literal in the (url, key, options) form', async () => {
+ await encryptedSupabase(
+ 'https://project.supabase.co',
+ 'SUPABASE_ANON_KEY',
+ {
+ schemas: { users },
+ config: {
+ workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id',
+ accessKey: 'CS_CLIENT_ACCESS_KEY',
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ // @ts-expect-error — this entry carries no Postgres driver and cannot introspect
+ databaseUrl: 'postgres://user:pass@localhost:5432/postgres',
+ },
+ )
+ })
+
+ it('rejects it when the options are passed by variable, not as a literal', async () => {
+ // Declared, not inlined: the literal's freshness is spent here, so the call
+ // below gets no excess-property check. This is the shape the `?: never` is
+ // for — without it this call type-checks and fails at run time instead.
+ const options = {
+ schemas: { users },
+ config: {
+ workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id',
+ accessKey: 'CS_CLIENT_ACCESS_KEY',
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ databaseUrl: 'postgres://user:pass@localhost:5432/postgres',
+ }
+
+ await encryptedSupabase(
+ supabaseClient,
+ // @ts-expect-error — `databaseUrl?: never` is what rejects this; absence would not
+ options,
+ )
+ })
+
+ it('rejects it by variable in the (url, key, options) form too', async () => {
+ const options = {
+ schemas: { users },
+ config: {
+ workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id',
+ accessKey: 'CS_CLIENT_ACCESS_KEY',
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ databaseUrl: 'postgres://user:pass@localhost:5432/postgres',
+ }
+
+ await encryptedSupabase(
+ 'https://project.supabase.co',
+ 'SUPABASE_ANON_KEY',
+ // @ts-expect-error — `databaseUrl?: never` is what rejects this; absence would not
+ options,
+ )
+ })
+
+ /**
+ * The positive control for the four `@ts-expect-error`s above: the same
+ * by-variable call, with the offending field removed, must still compile. A
+ * `?: never` that accidentally poisoned the whole options type would make
+ * every call above fail for the wrong reason and this one fail outright.
+ */
+ it('still accepts the same options object once `databaseUrl` is dropped', async () => {
+ const options = {
+ schemas: { users },
+ config: {
+ workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id',
+ accessKey: 'CS_CLIENT_ACCESS_KEY',
+ clientId: 'CS_CLIENT_ID',
+ clientKey: 'CS_CLIENT_KEY',
+ },
+ }
+
+ await encryptedSupabase(supabaseClient, options)
+ })
+})
diff --git a/packages/stack-supabase/src/wasm-inline.ts b/packages/stack-supabase/src/wasm-inline.ts
index ce3d7e763..2c8772d9d 100644
--- a/packages/stack-supabase/src/wasm-inline.ts
+++ b/packages/stack-supabase/src/wasm-inline.ts
@@ -14,20 +14,45 @@ import { adaptWasmEncryption } from './wasm-client-adapter'
* Three differences from the default entry's options, each one a runtime
* requirement made visible to the type checker (#708 review):
*
- * - **`schemas` is required.** This entry cannot introspect, so a client built
- * without a declaration has no columns and nothing to encrypt.
+ * - **`schemas` is required.** This entry cannot introspect, so a declaration is
+ * the only way to discover the encrypted columns. Omitting it is a type
+ * error, and a construction-time throw for callers arriving from plain JS —
+ * not a client that quietly encrypts nothing.
* - **`config` is required, and is a `WasmClientConfig`.** There is no
- * `~/.cipherstash` to discover credentials from on an edge runtime, so all
- * four `CS_*` values must be passed. Typing it as the native (optional)
+ * `~/.cipherstash` to discover credentials from on an edge runtime, so
+ * authentication is passed in: `clientId` and `clientKey` always, then
+ * either `workspaceCrn` + `accessKey` or a pre-built `authStrategy` (which
+ * carries the CRN itself). Typing it as the native (optional)
* `ClientConfig` let a caller omit it and reach a `TypeError` from inside the
* engine, and let native-only fields such as `keyset` type-check while being
* silently ignored.
- * - **`databaseUrl` is absent.** Introspection is the thing this entry cannot
- * do; the option is refused at runtime, and this stops it being written.
+ * - **`databaseUrl` is refused, and refused by the type.** Introspection is the
+ * thing this entry cannot do — it carries no Postgres driver — so the field
+ * is declared `never` rather than merely left out. Omission is policed by
+ * excess-property checking alone, which fires on FRESH object literals: an
+ * options object assembled as a `const` and passed by variable, which is what
+ * a Node → edge port actually holds, type-checked clean and then hit the
+ * runtime throw in `makeEncryptedSupabase`. Mirrors
+ * `WasmClientConfig.eqlVersion?: never` in `packages/stack/src/wasm-inline.ts`
+ * — same gap, same fix, one package along.
*/
export interface EncryptedSupabaseWasmOptions {
schemas: S
config: WasmClientConfig
+ /**
+ * Declared only to be refused: this entry has no introspector to hand a
+ * connection string to. The type is defence in depth, not a replacement for
+ * the throw in `makeEncryptedSupabase` (`./create`), which is still the only
+ * thing a plain JS caller meets — see the third bullet above for why both
+ * are needed.
+ *
+ * As with `eqlVersion` on `WasmClientConfig`, `?: never` still admits an
+ * explicit `databaseUrl: undefined` without `exactOptionalPropertyTypes` (not
+ * enabled in this repo) and cannot be made to reject it. That is harmless
+ * here: the runtime guard tests the VALUE, so `undefined` is exactly the case
+ * it means to let through.
+ */
+ databaseUrl?: never
}
/**
diff --git a/scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs b/scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs
new file mode 100644
index 000000000..e38177b11
--- /dev/null
+++ b/scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs
@@ -0,0 +1,256 @@
+import { execFileSync } from 'node:child_process'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { packageReadmePathspecs } from './lib/package-readmes.mjs'
+import { REPO_ROOT } from './lib/repo-root.mjs'
+
+/**
+ * The declared-mode `select('*')` refusal is NOT a read backstop, and any
+ * shipped document that states the refusal has to say so.
+ *
+ * ## The property
+ *
+ * `encryptedSupabase` built from declared `schemas` refuses `select('*')` and
+ * bare `select()` — `expandStarOrThrow()`, `packages/stack-supabase/src/
+ * query-builder.ts:159-166`. It reads like a safety net: name your columns or
+ * get nothing. It is not one. A query awaited with **no `.select()` call at
+ * all** takes the other branch — `query-builder.ts:703-725` sends a raw `*` —
+ * and `decryptResults` returns it untouched on its `!hasSelect` passthrough
+ * (`packages/stack-supabase/src/query-results.ts:127-130`). Every column comes
+ * back undecrypted, declared or not. That is long-standing behaviour the
+ * source deliberately leaves alone; only the docs were wrong about it.
+ *
+ * So an undeclared encrypted column on a declared table has a backstop on
+ * WRITE (the `eql_v3_*` domain CHECK, which a NULL still passes) and none on
+ * READ. A document that names the refusal without that caveat invites exactly
+ * the inference this repo has already made once in print.
+ *
+ * ## Why nothing else catches it
+ *
+ * - Nothing type-checks a SKILL.md or a README, and these are shipped text.
+ * `skills/` rides inside the `stash` npm tarball and `stash init` copies it
+ * into the customer's repository, where their coding agent reads it as
+ * instruction. `packages/stack-supabase/README.md` renders on the npm
+ * package page.
+ * - The runtime tests pin the two behaviours separately and correctly
+ * (`packages/stack-supabase/__tests__/supabase-declared-mode.test.ts`), but a
+ * passing test says nothing about what a document claims.
+ * - The specific failure was a CORRECTION THAT LANDED IN ONE OF TWO COPIES.
+ * `578783ad` fixed the claim in `skills/stash-supabase/SKILL.md` and left the
+ * same claim standing in `skills/stash-managed-platforms/SKILL.md`, in the
+ * same PR that edited both. Two shipped copies of one fact drift the moment
+ * one of them is edited, and no reviewer diff shows the copy that was not
+ * touched.
+ *
+ * ## What this catches, and what it does not
+ *
+ * Catches: a section of a shipped markdown document whose prose states that
+ * `select('*')` (or bare `select()`) is refused/rejected/throws, where no unit
+ * in that same section pairs "a call omitted" with "comes back undecrypted".
+ *
+ * Scope is the markdown SECTION (nearest preceding ATX heading), not the
+ * sentence: the caveat may be reworded, moved between bullets, or split off
+ * into its own paragraph without failing, but it cannot migrate to a different
+ * part of the document from the claim it corrects. Fenced code blocks are
+ * blanked before scanning, so a snippet demonstrating `select('*')` is not a
+ * claim about it.
+ *
+ * Does NOT catch:
+ *
+ * - The same claim in a `.ts`, `.tsx` or `.sql` file, or in a comment. Prose in
+ * shipped markdown is the surface that misled a reader here.
+ * - A document that omits the refusal entirely and separately implies reads are
+ * safe. There is no phrase to key on for that.
+ * - A caveat that is present but WRONG (say, one claiming the passthrough only
+ * applies to mutations). This asserts the caveat is stated, not that it is
+ * accurate — the accuracy check is reading `query-results.ts`.
+ * - The second false claim fixed alongside this one, that an ambient
+ * `DATABASE_URL` is ignored "with a warning" on the edge entry. Both the
+ * ambient read and the warning are gated on `introspector`
+ * (`packages/stack-supabase/src/create.ts:304,330`), which is `null` on the
+ * `wasm-inline` build. It is left unguarded deliberately: "a warning is
+ * logged" has no stable phrasing to key on, and the correction ("gated on the
+ * introspector") is a word that appears freely in any section discussing
+ * introspection, so every detector for it either misses rewordings or passes
+ * on unrelated prose.
+ */
+
+/**
+ * Files whose contents are SHIPPED — published to npm, copied into a user's
+ * repo, or written there by `stash init`. Deliberately not the whole tree:
+ * CHANGELOGs and `docs/**` are historical records, accurate for their dates.
+ *
+ * Identical to the set in `skills-supabase-edge-schema-entry.test.mjs`, and
+ * derived the same way — `:(glob)` magic so `*` stops at a path separator, and
+ * `packageReadmePathspecs()` for the two package roots that sit deeper than one
+ * level.
+ */
+const SHIPPED_GLOBS = [
+ ':(glob)skills/*/SKILL.md',
+ ...packageReadmePathspecs(),
+ 'README.md',
+ 'AGENTS.md',
+]
+
+/** Tracked files matching the shipped globs, via git so it honours .gitignore. */
+function shippedFiles() {
+ const out = execFileSync('git', ['ls-files', '-z', ...SHIPPED_GLOBS], {
+ cwd: REPO_ROOT,
+ encoding: 'utf8',
+ })
+ return out.split('\0').filter(Boolean)
+}
+
+/**
+ * Blank out fenced code blocks, preserving line numbering.
+ *
+ * A snippet is a demonstration, not a claim, and snippets legitimately contain
+ * both `select('*')` and the word "throws". Blanking rather than deleting keeps
+ * the reported line numbers pointing at the real file, and stops a `#` comment
+ * inside a snippet from being read as a heading.
+ */
+function blankFences(body) {
+ let inFence = false
+ return body
+ .split('\n')
+ .map((line) => {
+ if (/^\s*(?:`{3,}|~{3,})/.test(line)) {
+ inFence = !inFence
+ return ''
+ }
+ return inFence ? '' : line
+ })
+ .join('\n')
+}
+
+/** Sections split at ATX headings, as `{ heading, line, body }`. */
+function sections(body) {
+ const found = []
+ let current = { heading: '(before the first heading)', line: 1, lines: [] }
+ body.split('\n').forEach((line, index) => {
+ if (/^#{1,6}\s/.test(line)) {
+ found.push(current)
+ current = { heading: line.trim(), line: index + 1, lines: [] }
+ }
+ current.lines.push(line)
+ })
+ found.push(current)
+ return found.map(({ heading, line, lines }) => ({
+ heading,
+ line,
+ body: lines.join('\n'),
+ }))
+}
+
+/**
+ * A section's prose as whitespace-normalised units — one per paragraph and one
+ * per list item, so a claim and the bullet below it are not read as one
+ * sentence.
+ */
+function units(sectionBody) {
+ const collected = []
+ let current = []
+ const flush = () => {
+ const text = current.join(' ').replace(/\s+/g, ' ').trim()
+ if (text) collected.push(text)
+ current = []
+ }
+ for (const line of sectionBody.split('\n')) {
+ if (
+ line.trim() === '' ||
+ /^\s*(?:[-*+]|\d+[.)])\s/.test(line) ||
+ /^#{1,6}\s/.test(line)
+ ) {
+ flush()
+ }
+ current.push(line)
+ }
+ flush()
+ return collected
+}
+
+/** `select('*')`, `select("*")` or a bare `select()`, backticked or not. */
+const SELECT_STAR = /select\(\s*(?:['"`]\*['"`])?\s*\)/i
+
+/**
+ * The claim that it is refused. Deliberately narrow: "unavailable" and "does
+ * not work" are excluded because neighbouring bullets in
+ * `skills/stash-supabase/SKILL.md` use them about PostgREST's operator surface,
+ * and a unit is a bullet.
+ */
+const REFUSAL =
+ /\brefus\w*\b|\breject\w*\b|\bthrows?\b|\bthrowing\b|does not support|not supported|\bunsupported\b/i
+
+/** The caveat, in two halves that must appear in the same unit. */
+const OMITTED_CALL =
+ /(?:\bno\b|\bwithout\b|\bnever\b|\bomitt?\w*\b|\bbare\b)[\s\S]{0,40}?`?\.?select\(\s*\)/i
+const UNDECRYPTED =
+ /\bundecrypted\b|\bnot decrypted\b|\bnever decrypted\b|\bnothing is decrypted\b|\bwithout decrypting\b/i
+
+function statesRefusal(unit) {
+ return SELECT_STAR.test(unit) && REFUSAL.test(unit)
+}
+
+function statesCaveat(unit) {
+ return OMITTED_CALL.test(unit) && UNDECRYPTED.test(unit)
+}
+
+/** Sections that claim the refusal without carrying the caveat. */
+function offendingSections(file, body) {
+ return sections(blankFences(body))
+ .filter((section) => {
+ const parts = units(section.body)
+ return parts.some(statesRefusal) && !parts.some(statesCaveat)
+ })
+ .map((section) => `${file}:${section.line} (${section.heading})`)
+}
+
+describe("the select('*') refusal is documented as not a read backstop", () => {
+ const files = shippedFiles()
+
+ it('finds the shipped file set (guards against a silently-empty glob)', () => {
+ expect(files.length).toBeGreaterThan(5)
+ expect(files).toContain('skills/stash-supabase/SKILL.md')
+ expect(files).toContain('skills/stash-managed-platforms/SKILL.md')
+ expect(files).toContain('packages/stack-supabase/README.md')
+ })
+
+ /**
+ * A detector that stops matching anything is a guard that always passes.
+ * `skills/stash-supabase/SKILL.md` is the canonical adapter skill and carries
+ * both halves in one bullet — if this stops finding them, the regexes have
+ * decayed, not the docs.
+ */
+ it('still recognises both halves in the canonical adapter skill', () => {
+ const parts = sections(
+ blankFences(
+ readFileSync(
+ resolve(REPO_ROOT, 'skills/stash-supabase/SKILL.md'),
+ 'utf8',
+ ),
+ ),
+ ).flatMap((section) => units(section.body))
+
+ expect(parts.filter(statesRefusal).length).toBeGreaterThan(0)
+ expect(parts.filter(statesCaveat).length).toBeGreaterThan(0)
+ })
+
+ it.each(files)('%s', (file) => {
+ const offenders = offendingSections(
+ file,
+ readFileSync(resolve(REPO_ROOT, file), 'utf8'),
+ )
+
+ expect(
+ offenders,
+ `${offenders.join(', ')} states that \`select('*')\` is refused in declared mode without ` +
+ 'the caveat that goes with it. The refusal is real, but it is not a read backstop: a query ' +
+ "awaited with no `.select()` call at all takes `query-builder.ts`'s raw-`*` branch and " +
+ '`decryptResults` passes it through on `!hasSelect`, so every column comes back undecrypted, ' +
+ 'declared or not. Say so in the same section — writes have a backstop (the `eql_v3_*` domain ' +
+ 'CHECK, though a NULL still passes) and reads have none. ' +
+ '`skills/stash-supabase/SKILL.md` carries the wording to match.',
+ ).toEqual([])
+ })
+})
diff --git a/scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs b/scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs
new file mode 100644
index 000000000..72976b6c9
--- /dev/null
+++ b/scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs
@@ -0,0 +1,159 @@
+import { execFileSync } from 'node:child_process'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { packageReadmePathspecs } from './lib/package-readmes.mjs'
+import { REPO_ROOT } from './lib/repo-root.mjs'
+
+/**
+ * A schema authored from `@cipherstash/stack/wasm-inline` cannot be handed to
+ * `encryptedSupabase` from `@cipherstash/stack-supabase/wasm-inline`.
+ *
+ * `@cipherstash/stack/wasm-inline` is a separate tsup dts bundle. It
+ * re-declares its own `EncryptedV3Column` / `EncryptedTextSearchColumn`
+ * classes, each carrying a `private readonly columnName`, and TypeScript
+ * compares classes with private fields NOMINALLY. The adapter types its
+ * `schemas` option as `Record` imported from
+ * `@cipherstash/stack/eql/v3` (`packages/stack-supabase/src/schema-builder.ts`),
+ * so pairing the two entries is a hard `tsc --strict` error:
+ *
+ * error TS2322: Type 'EncryptedTable<...>' is not assignable to type 'AnyV3Table'.
+ * ... Types have separate declarations of a private property 'columnName'.
+ *
+ * The rule is therefore NOT "edge project, edge entry, everywhere". It is:
+ * author the schema against the entry whose CLIENT TYPE consumes it. The raw
+ * `Encryption` client from `wasm-inline` consumes `wasm-inline` tables;
+ * `encryptedSupabase` consumes `eql/v3` tables on BOTH its entries, WASM engine
+ * or not.
+ *
+ * Nothing catches the pairing for us:
+ *
+ * - Nothing type-checks a SKILL.md or a README, and these are shipped text —
+ * `skills/` rides inside the `stash` npm tarball and `stash init` copies it
+ * into the customer's own repository, where their coding agent reads it as
+ * instruction. The drift lands in someone else's build, not in ours.
+ * - Runtime is unaffected, which is why it drifted silently in the first
+ * place: `packages/stack-supabase/src/column-map.ts` deliberately probes for
+ * v3 columns STRUCTURALLY rather than with `instanceof`, precisely because
+ * tsup emits the class twice. Copy-pasting the bad snippet produces working
+ * code that will not compile.
+ * - `e2e/wasm/deno.json` runs `deno test --no-check`, so the repo's own edge
+ * e2e would not report it either.
+ */
+
+/** Modules and names that must not co-occur inside one TypeScript block. */
+const ADAPTER_MODULE = '@cipherstash/stack-supabase/wasm-inline'
+const ADAPTER_NAMES = ['encryptedSupabase', 'encryptedSupabaseV3']
+const SCHEMA_MODULE = '@cipherstash/stack/wasm-inline'
+const SCHEMA_NAMES = ['encryptedTable', 'types']
+
+/**
+ * Files whose contents are SHIPPED — published to npm, copied into a user's
+ * repo, or written there by `stash init`. Deliberately not the whole tree:
+ * CHANGELOGs and `docs/**` are historical records, and rewriting history to
+ * appease a lint is worse than the drift it prevents.
+ */
+// `:(glob)` magic so `*` stops at a path separator — without it git's default
+// wildmatch crosses `/` and sweeps in files a level deeper.
+const SHIPPED_GLOBS = [
+ ':(glob)skills/*/SKILL.md',
+ // Derived, not written down: two package roots sit deeper than one level and
+ // `:(glob)` does not cross `/`. See `lib/package-readmes.mjs`.
+ ...packageReadmePathspecs(),
+ 'README.md',
+ 'AGENTS.md',
+]
+
+/** Tracked files matching the shipped globs, via git so it honours .gitignore. */
+function shippedFiles() {
+ const out = execFileSync('git', ['ls-files', '-z', ...SHIPPED_GLOBS], {
+ cwd: REPO_ROOT,
+ encoding: 'utf8',
+ })
+ return out.split('\0').filter(Boolean)
+}
+
+/**
+ * Fenced TypeScript blocks, as `{ line, body }`.
+ *
+ * Per BLOCK, not per file: a document may legitimately import
+ * `@cipherstash/stack/wasm-inline` in one snippet (the raw edge client, which
+ * really does want its own tables) and construct `encryptedSupabase` in
+ * another. Only the two appearing in the same snippet is the defect.
+ */
+function typescriptBlocks(body) {
+ const blocks = []
+ const fence = /^```(ts|typescript)[^\n]*\n([\s\S]*?)^```/gm
+ for (const match of body.matchAll(fence)) {
+ blocks.push({
+ line: body.slice(0, match.index).split('\n').length,
+ body: match[2],
+ })
+ }
+ return blocks
+}
+
+/** Named imports in one block, as `{ module, names }`. */
+function namedImports(block) {
+ const imports = []
+ const stmt = /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g
+ for (const match of block.matchAll(stmt)) {
+ imports.push({
+ module: match[2],
+ names: match[1]
+ .split(',')
+ .map((name) =>
+ name
+ .trim()
+ .split(/\s+as\s+/)[0]
+ .trim(),
+ )
+ .filter(Boolean),
+ })
+ }
+ return imports
+}
+
+/** Does this block import any of `names` from `module`? */
+function importsAny(imports, module, names) {
+ return imports.some(
+ (imported) =>
+ imported.module === module &&
+ imported.names.some((name) => names.includes(name)),
+ )
+}
+
+describe('supabase edge snippets author schemas from @cipherstash/stack/eql/v3', () => {
+ const files = shippedFiles()
+
+ it('finds the shipped file set (guards against a silently-empty glob)', () => {
+ expect(files.length).toBeGreaterThan(5)
+ expect(files).toContain('skills/stash-supabase/SKILL.md')
+ expect(files).toContain('skills/stash-managed-platforms/SKILL.md')
+ expect(files).toContain('skills/stash-edge/SKILL.md')
+ expect(files).toContain('packages/stack-supabase/README.md')
+ })
+
+ it.each(files)('%s', (file) => {
+ const body = readFileSync(resolve(REPO_ROOT, file), 'utf8')
+ const offenders = typescriptBlocks(body)
+ .filter((block) => {
+ const imports = namedImports(block.body)
+ return (
+ importsAny(imports, ADAPTER_MODULE, ADAPTER_NAMES) &&
+ importsAny(imports, SCHEMA_MODULE, SCHEMA_NAMES)
+ )
+ })
+ .map((block) => `${file}:${block.line}`)
+
+ expect(
+ offenders,
+ `${offenders.join(', ')} pairs \`encryptedSupabase\` from ${ADAPTER_MODULE} with a schema ` +
+ `authored from ${SCHEMA_MODULE}. That does not compile: the adapter's \`schemas\` option is ` +
+ "typed from `@cipherstash/stack/eql/v3`, and the two entries' column classes carry private " +
+ 'fields TypeScript compares nominally (TS2322, "separate declarations of a private property ' +
+ "'columnName'\"). Import `encryptedTable` and `types` from `@cipherstash/stack/eql/v3` — the " +
+ 'engine stays WASM either way.',
+ ).toEqual([])
+ })
+})
diff --git a/scripts/__tests__/supabase-runtime-claims.test.mjs b/scripts/__tests__/supabase-runtime-claims.test.mjs
index c60ee582a..4ec96d4b5 100644
--- a/scripts/__tests__/supabase-runtime-claims.test.mjs
+++ b/scripts/__tests__/supabase-runtime-claims.test.mjs
@@ -48,19 +48,32 @@ import { REPO_ROOT } from './lib/repo-root.mjs'
*/
/**
- * The reference doc, plus the three sources whose TSDoc ships in `.d.ts`.
+ * The reference doc, the two shipped documents, and 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.
+ * `packages/stack-supabase/README.md` and `skills/stash-supabase/SKILL.md`
+ * were both held off this list while #951 rewrote the same lines — the README
+ * carried defect 1 verbatim, and #951's first draft kept the false `so` while
+ * dropping the browser half. This comment is part of #951: both sentences now
+ * attribute the restriction to the engine, so both files are enrolled. They
+ * are the two that reach a customer — the README is the npm package page, and
+ * `stash init` copies the skill into the customer's own repository, where
+ * their coding agent reads it as instruction.
+ *
+ * `skills/stash-managed-platforms/SKILL.md` is deliberately NOT enrolled. Its
+ * causal claims are correct, but it trips the unqualified-"Worker" detector
+ * four times: twice in its YAML frontmatter `description`, once in a section
+ * heading (`## encryptedSupabase in a Worker`), and once on "**An edge /
+ * Workers runtime** for server code" — the last a false positive, since the
+ * qualifier is there and only the slash keeps it out of the context pattern.
+ * The frontmatter is load-bearing for skill selection, so rewording it is its
+ * own change with its own review rather than a rider on this one. Enrol the
+ * file then.
*/
const GUARDED = [
'docs/reference/supabase-sdk.md',
+ 'packages/stack-supabase/README.md',
+ 'skills/stash-supabase/SKILL.md',
'packages/stack-supabase/src/index.ts',
'packages/stack-supabase/src/create.ts',
'packages/stack-supabase/src/wasm-inline.ts',
diff --git a/skills/stash-edge/SKILL.md b/skills/stash-edge/SKILL.md
index 4df34ad04..8b84367e4 100644
--- a/skills/stash-edge/SKILL.md
+++ b/skills/stash-edge/SKILL.md
@@ -1,6 +1,6 @@
---
name: stash-edge
-description: Run CipherStash encryption on edge and non-Node runtimes with the `@cipherstash/stack/wasm-inline` entry — Deno, Supabase Edge Functions, Cloudflare Workers, and Bun. Covers the import specifier per runtime, the four mandatory `CS_*` variables and minting them with `stash env`, how keysets and credentials interact on the edge (what must match is the keyset — `stash-zerokms` is canonical), how the WASM client surface differs from the native typed client, why the entry is server-side only and never belongs in a browser bundle, and why an EQL v3 schema module cannot be shared across the two entries. Use when adding encryption to a Supabase Edge Function, a Worker, or a Deno service; when a native module fails to load in a deployed runtime; when wiring `CS_*` secrets into an edge deploy; or when encrypted search returns zero rows on the edge but works locally.
+description: Run CipherStash encryption on edge and non-Node runtimes with the `@cipherstash/stack/wasm-inline` entry — Deno, Supabase Edge Functions, Cloudflare Workers, and Bun. Covers the import specifier per runtime, which `CS_*` variables are mandatory and minting them with `stash env`, how keysets and credentials interact on the edge (what must match is the keyset — `stash-zerokms` is canonical), how the WASM client surface differs from the native typed client, why the entry is server-side only and never belongs in a browser bundle, and why an EQL v3 schema module cannot be shared across the two entries. Use when adding encryption to a Supabase Edge Function, a Worker, or a Deno service; when a native module fails to load in a deployed runtime; when wiring `CS_*` secrets into an edge deploy; or when encrypted search returns zero rows on the edge but works locally.
---
# Encryption on the Edge (WASM entry)
@@ -46,8 +46,11 @@ together.
`@cipherstash/stack-supabase/wasm-inline` (not the package root, which pulls
the native engine) and **declare your `schemas`** — the adapter's default
behaviour is to introspect the database for its column config, which needs a
-Postgres connection. Declaring skips it. See `stash-supabase` and
-`stash-managed-platforms`.
+Postgres connection. Declaring skips it. Those `schemas` are authored from
+`@cipherstash/stack/eql/v3`, not from `@cipherstash/stack/wasm-inline` — the
+one place the "use the edge entry for everything" reflex is wrong, and it
+fails at `tsc`, not at runtime. See "Schema Modules Do Not Cross Entries"
+below, plus `stash-supabase` and `stash-managed-platforms`.
**`@cipherstash/protect` is not one of the options.** It is the deprecated
predecessor of `@cipherstash/stack`; its native `@cipherstash/protect-ffi`
@@ -118,9 +121,15 @@ that config does not apply here and can be left alone.
## Credentials
-The edge client takes **all four** `CS_*` values explicitly. There is no
-credential discovery: `~/.cipherstash` does not exist in a Worker or an Edge
-Function container, and there is no device-code login to fall back on.
+The edge client is passed its credentials explicitly. There is no credential
+discovery: `~/.cipherstash` does not exist in a Worker or an Edge Function
+container, and there is no device-code login to fall back on.
+
+`clientId` and `clientKey` are always required. Past those, `config` is a
+union: the **access-key path** below adds `workspaceCrn` +
+`accessKey` — the four `CS_*` values `stash env` mints — or you pass a
+pre-built `config.authStrategy`, which already carries the CRN and so needs
+neither `workspaceCrn` nor `accessKey` (see `config.authStrategy` below).
> [!IMPORTANT]
> **Server-side only — this entry never goes in a browser bundle.**
@@ -253,7 +262,7 @@ Available: `encrypt`, `decrypt`, `isEncrypted`, `encryptQuery`,
|---|---|---|
| Factory | `Encryption({ schemas })` | `Encryption({ schemas, config })` — same name, different module |
| Schema authoring | `encryptedTable` / `types` from `@cipherstash/stack/v3` | the entry's own re-exports (see below) |
-| Config | discovered from env / `~/.cipherstash` | all four `CS_*` passed explicitly |
+| Config | discovered from env / `~/.cipherstash` | passed explicitly — `clientId` + `clientKey`, then either `workspaceCrn` + `accessKey` or a pre-built `authStrategy` (see below) |
| Typing | signatures derived from the schema | schema-aware, but not the full typed client |
| `.audit()` | chainable on operations | **not available** |
| `.withLockContext()` | chainable on operations | **not available** — see below |
@@ -391,10 +400,11 @@ It works fine at runtime, which is the trap: the tempting fix is
`as never` / `as any` on the schema, which silences a real signal and will
keep silencing it after a genuine schema mismatch appears.
-**Author the schema module against exactly one entry, and use that entry's
-client with it.** For a project whose encryption runs on the edge, that means
-importing `encryptedTable` and `types` from `@cipherstash/stack/wasm-inline`
-in the shared schema module:
+**Author the schema module against the entry whose CLIENT TYPE consumes it.**
+Not "the entry your runtime uses" — the WASM engine is not what decides this,
+the type of the thing you hand the schema to is. For a project that builds a
+raw `Encryption` client from `@cipherstash/stack/wasm-inline`, that entry is
+also where `encryptedTable` and `types` come from:
```ts
// schema.ts — the single source of truth for this project's schema
@@ -416,6 +426,26 @@ something to test, not something the type system will enforce for you. Column
names and domains must match exactly — they are what the database and the
stored payload's `i` identifier are keyed by.
+### The exception: `@cipherstash/stack-supabase/wasm-inline`
+
+The Supabase adapter's edge entry runs the WASM engine but types its `schemas`
+option from `@cipherstash/stack/eql/v3` — the same declaration its native
+entry uses. So a Supabase edge project authors its schema module from
+`eql/v3`, **not** from `@cipherstash/stack/wasm-inline`:
+
+```ts
+// The engine is still WASM. Only the schema's declaration site differs.
+import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline'
+import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
+```
+
+Get this one backwards and you hit the same nominal-private-field rejection,
+reported one level up — `schemas` not assignable to `AnyV3Table`, because the
+column classes inside it carry a private `columnName` from the other entry's
+declarations. Which way round it goes is a property of the client type, so
+check what consumes the schema before you pick the import. `stash-supabase`
+and `stash-managed-platforms` carry the full edge call shape.
+
## Querying from the Edge
Edge functions rarely have an ORM, so encrypted search is usually hand-written
@@ -451,7 +481,7 @@ shared modules.
entry is ESM-only. Move the consumer to ESM.
**Missing `CS_*` at runtime** — the secret store was never populated, or the
-function was served without `--env-file`. Validate all four at handler entry
+function was served without `--env-file`. Validate the ones you pass at handler entry
and return an actionable error rather than letting client construction fail
opaquely; the example in `examples/supabase-worker` does exactly this.
diff --git a/skills/stash-managed-platforms/SKILL.md b/skills/stash-managed-platforms/SKILL.md
index 2e10e1647..799d2f08e 100644
--- a/skills/stash-managed-platforms/SKILL.md
+++ b/skills/stash-managed-platforms/SKILL.md
@@ -156,7 +156,10 @@ By default `encryptedSupabase` derives every column's encryption config by intro
```typescript
import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline'
-import { encryptedTable, types } from '@cipherstash/stack/wasm-inline'
+// Schemas come from `eql/v3`, NOT `@cipherstash/stack/wasm-inline` — the
+// adapter types `schemas` from that entry and the two entries' column classes
+// do not cross. The engine is still WASM.
+import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
const users = encryptedTable('users', {
email: types.TextSearch('email'),
@@ -169,20 +172,21 @@ const supabase = await encryptedSupabase(supabaseClient, {
})
```
-**Two things must both be right**, and each fails independently:
+**Three things must all be right**, and each fails independently:
1. **The entry.** Import from `@cipherstash/stack-supabase/wasm-inline`, not the package root. The root statically imports the native engine, which loads on import whether or not you encrypt anything.
2. **The schemas.** Without them the wrapper still wants a connection.
+3. **Where the schemas come from.** `encryptedTable` and `types` for the adapter come from `@cipherstash/stack/eql/v3` on **both** its entries — the adapter types `schemas` from that entry, and `@cipherstash/stack/wasm-inline` re-declares the same column classes with private fields TypeScript compares nominally. Author them from the WASM entry and `tsc` rejects the `schemas` object ("separate declarations of a private property `columnName`") while the code runs fine, so nothing but a typecheck tells you. Only a **raw** `Encryption` client from `@cipherstash/stack/wasm-inline` wants tables authored from that entry.
-What declared mode gives up, it gives up loudly rather than silently:
+What declared mode gives up:
-- **`select('*')` and bare `select()` are refused.** Nothing enumerated the table's plaintext columns, and an unexpanded `*` reaches PostgREST without the `::jsonb` casts encrypted columns need. List columns explicitly.
+- **`select('*')` and bare `select()` are refused.** Nothing enumerated the table's plaintext columns, and an unexpanded `*` reaches PostgREST without the `::jsonb` casts encrypted columns need. List columns explicitly. **The refusal is not a read backstop.** A query awaited with no `.select()` call at all takes a different path: it sends a raw `*`, nothing is cast, and every column comes back undecrypted — declared or not, raw EQL payloads returned as `data` with no error. Writes have a backstop, in that a plaintext write to a real `eql_v3_*` column fails that domain's CHECK constraint, though a NULL still passes. Reads have none.
- **`from()` on an undeclared table throws.** There is no introspected table list to fall back on.
- **The drift check is gone** — nothing compared your declaration against the real column domains, so a wrong domain surfaces as a `23514` CHECK violation on the first write instead of at construction. On Node you can have both: pass `databaseUrl` **as well as** `schemas`.
-⚠️ **The one tradeoff that is not loud — declare every encrypted column of every table you query.** Nothing introspects, so a column carrying an `eql_v3` domain in the database but missing from your `schemas` is treated as an ordinary plaintext column: a `select` naming it hands you the raw EQL payload as data, and a filter on it sends your **plaintext** value to PostgREST. There is no error, because nothing knows the column is encrypted. If you cannot guarantee the declaration is complete, pass `databaseUrl` so introspection fills the gaps.
+⚠️ **The other silent failure — declare every encrypted column of every table you query.** Nothing introspects, so a column carrying an `eql_v3` domain in the database but missing from your `schemas` is treated as an ordinary plaintext column: a `select` naming it hands you the raw EQL payload as data, and a filter on it sends your **plaintext** value to PostgREST. There is no error, because nothing knows the column is encrypted. Introspection is what fills that gap, and it is not available here — `databaseUrl` is refused on this entry (below), so the declaration is the only thing standing between a schema change and a silent plaintext write. Re-check it whenever a column becomes encrypted. On Node, passing `databaseUrl` alongside `schemas` covers you instead.
-An ambient `DATABASE_URL` will not overrule your declaration — it is ignored when `schemas` are passed, with a warning that the declaration is unverified. Pass `databaseUrl` explicitly if you want introspection.
+On Node, an ambient `DATABASE_URL` will not overrule your declaration — it is ignored when `schemas` are passed, and the native entry logs a warning that the declaration is unverified. Pass `databaseUrl` explicitly if you want introspection. Neither the ambient read nor that warning exists on the `wasm-inline` entry: both are gated on the introspector, which the edge build does not have, so nothing there will ever tell you a declaration is incomplete.
Passing `databaseUrl` to the `wasm-inline` entry is refused outright — it carries no Postgres driver, and saying so beats ignoring the option.
diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md
index efff50643..55c1c785a 100644
--- a/skills/stash-supabase/SKILL.md
+++ b/skills/stash-supabase/SKILL.md
@@ -26,7 +26,7 @@ selects, with support for equality, range, and ordering.
- Using identity-aware encryption (lock contexts) with Supabase
- Building applications where sensitive columns need encryption at rest and in transit
-> **On a managed AI platform — Lovable, v0, Bolt, Replit — read `stash-managed-platforms` first.** Two things there are decided before anything on this page applies: server code runs on an edge runtime, so it needs `@cipherstash/stack/wasm-inline` (`@cipherstash/protect` is the deprecated predecessor and its native module will not load — that dead end has cost an agent a whole turn), and the database role is not `postgres`, which changes how EQL gets installed. `encryptedSupabase` can be constructed inside a Worker, but only from the `@cipherstash/stack-supabase/wasm-inline` entry and only with declared `schemas` — introspection is what needs a Postgres connection, and declaring your tables is what skips it.
+> **On a managed AI platform — Lovable, v0, Bolt, Replit — read `stash-managed-platforms` first.** Two things there are decided before anything on this page applies: server code runs on an edge runtime, so it needs `@cipherstash/stack/wasm-inline` (`@cipherstash/protect` is the deprecated predecessor and its native module will not load — that dead end has cost an agent a whole turn), and the database role is not `postgres`, which changes how EQL gets installed. `encryptedSupabase` can be constructed inside an edge runtime, but only from the `@cipherstash/stack-supabase/wasm-inline` entry and only with declared `schemas` — introspection is what needs a Postgres connection, and declaring your tables is what skips it. Step 5 of Setup has the call shape.
> **What survives PostgREST, in one line** (the full treatment is under [Query behaviour on encrypted columns](#query-behaviour-on-encrypted-columns), a long way down): `eq` / `neq` / `in` / `match()` and the range filters `gt` / `gte` / `lt` / `lte` **do** work on capable domains, and so does `order()` on OPE-backed ordering columns. Encrypted free-text `matches()` and encrypted-JSON `contains()` / `selectorEq()` / `selectorNe()` **do not** — they need `eql_v3.query_*` casts PostgREST cannot emit, and the wrapper fails fast rather than returning wrong rows. Agents guess wrong in both directions on this, so don't infer it; for the predicates that don't survive, use Drizzle, Prisma Next, or SQL in an RPC.
@@ -67,10 +67,12 @@ this is also how **Supabase Edge Functions** get credentials in local dev —
> needs only a grant — so a reader granted the writer's keyset but bound to
> a different one decrypts fine while its searches silently return zero
> rows. `stash-zerokms` is canonical for keyset scoping, `stash-auth` for
-> credentials. Encryption *inside* an
-> Edge Function (Deno, no native modules) uses the
-> `@cipherstash/stack/wasm-inline` entry — see the `stash-edge` skill; SQL
-> written by hand in a migration or RPC is covered by `stash-postgres`.
+> credentials. Inside an
+> Edge Function (Deno, no native modules) the wrapper comes from
+> `@cipherstash/stack-supabase/wasm-inline` — step 5 below. For encryption
+> without the Supabase wrapper it is `@cipherstash/stack/wasm-inline`, see the
+> `stash-edge` skill; SQL written by hand in a migration or RPC is covered by
+> `stash-postgres`.
### 1. Install EQL v3 on the database
@@ -266,13 +268,16 @@ detects EQL v3 columns by their Postgres domain, derives each column's
encryption config from the domain, and builds the encryption client
internally — there is no client-side schema to hand-maintain. Introspection
needs a direct Postgres connection (`options.databaseUrl`, defaulting to
-`DATABASE_URL`), so the factory cannot run in a Worker or the browser.
+`DATABASE_URL`). The engine is a native module, so **this entry runs on
+Node only**. On an edge runtime, import the `wasm-inline` entry instead —
+step 5 below.
-Introspection is not the only thing keeping this out of a browser. On the
-WASM entry, `config.clientKey` is a workspace secret and is required on
-*every* auth path — supplying a per-user `config.authStrategy` does not
-remove it ([#804](https://github.com/cipherstash/stack/issues/804)). Removing
-the `pg` dependency would unblock Workers, not browsers.
+The native engine and `pg` are not the only things keeping this out of a
+browser. On the WASM entry, `config.clientKey` is a workspace secret and is
+required on *every* auth path — supplying a per-user `config.authStrategy`
+does not remove it ([#804](https://github.com/cipherstash/stack/issues/804)).
+Dropping the native engine and the `pg` dependency is what unblocks Deno,
+Supabase Edge Functions and Cloudflare Workers; nothing unblocks a browser.
Options: `{ schemas?, databaseUrl?, config? }` — `config` is the encryption
client config (e.g. `config.authStrategy`, see Authentication below).
@@ -283,8 +288,8 @@ capabilities come from the introspected domains.
### 4. Optional declared schemas (compile-time types)
Declaring tables is optional. Passing `schemas` — a record whose keys must
-equal each table's name — adds compile-time types and verifies the declared
-tables against the database at construction:
+equal each table's name — adds compile-time types, and, when introspection
+also runs, verifies the declared tables against the database at construction:
```typescript
import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
@@ -307,15 +312,113 @@ A declared table gets a typed builder: rows infer each column's plaintext
type (`types.IntegerOrd` → `number`, `types.TimestampOrd` → `Date`),
storage-only columns are excluded from every filter method, and `order()` is
narrowed to orderable columns.
-Undeclared tables behave exactly as with no `schemas` at all. Every v3 column
-is fully described by its `types.*` factory — there are no capability or
-tuning chains on v3 columns.
+
+Whether undeclared tables still work turns on `databaseUrl`, **not** on the
+absence of `schemas`. Introspection is gated on a resolved database URL, so
+passing `databaseUrl` alongside `schemas` gets you both — declared tables keep
+their types and are drift-checked, undeclared ones behave exactly as with no
+`schemas` at all. Pass `schemas` on their own, as above, and even the native
+entry is in declared mode: an ambient `DATABASE_URL` is deliberately ignored,
+because declaring your tables says this client needs no connection, and
+`from("orders")` on an undeclared table throws.
+
+Every v3 column is fully described by its `types.*` factory — there are no
+capability or tuning chains on v3 columns.
A JS property may map to a different DB column name
(`joined: types.TimestampOrd("joined_at")`) — filters, selects, and results
are translated automatically, and `date`/`timestamp` columns decrypt to real
`Date` objects.
+### 5. Edge runtimes — the `wasm-inline` entry
+
+Deno, Supabase Edge Functions and Cloudflare Workers cannot load a native
+module and cannot open a raw Postgres socket. Both of those are properties of
+the entry above, not of the wrapper, so the package ships a second entry that
+has neither:
+
+| Entry | Engine | Schema | Runs on |
+|---|---|---|---|
+| `@cipherstash/stack-supabase` | native | introspected | Node |
+| `@cipherstash/stack-supabase/wasm-inline` | WASM, inlined into the bundle | declared — `schemas` is required | Deno, Supabase Edge Functions, Cloudflare Workers |
+
+After construction the wrapper behaves the same — the filters, the transforms
+and the response shape are identical — with two exceptions, both of them
+declared mode rather than the engine. `select('*')` and bare `select()` are
+refused, so name the columns you want; that refusal is not a read backstop,
+because a query awaited with no `.select()` at all still returns every column
+undecrypted. And `from()` on a table you did not declare throws, because there
+is no introspected table list to fall back on. Both are expanded in the bullets
+below.
+
+```typescript
+import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
+import { encryptedSupabase } from "@cipherstash/stack-supabase/wasm-inline"
+
+const users = encryptedTable("users", {
+ email: types.TextSearch("email"),
+ amount: types.IntegerOrd("amount"),
+})
+
+const es = await encryptedSupabase(supabaseUrl, supabaseKey, {
+ schemas: { users },
+ config: {
+ workspaceCrn: Deno.env.get("CS_WORKSPACE_CRN")!,
+ accessKey: Deno.env.get("CS_CLIENT_ACCESS_KEY")!,
+ clientId: Deno.env.get("CS_CLIENT_ID")!,
+ clientKey: Deno.env.get("CS_CLIENT_KEY")!,
+ },
+})
+
+await es.from("users").select("id, email").eq("email", "a@b.com")
+```
+
+Four differences from the native entry, three of them enforced by the type
+checker:
+
+- **`schemas` is required** — by the type, and by a construction-time throw
+ for callers who reach it from plain JS. Nothing introspects here, so there
+ is no other way to discover your encrypted columns, and `from()` on an
+ undeclared table throws for the same reason. The real hazard is one level
+ down: an undeclared **column** on a declared table never enters the encrypt
+ config and is treated as plaintext, so a filter on it sends your plaintext
+ value to PostgREST and a select on it hands back the raw EQL payload. Writes
+ have a backstop — a plaintext write to a real `eql_v3_*` column fails that
+ domain's CHECK constraint, though a NULL still passes. **Reads have none.**
+ `select('*')` is refused in declared mode, but that is not a safety net: a
+ query awaited with no `.select()` call at all sends a raw `*`, and every
+ column comes back undecrypted, declared or not. Name the columns you want.
+ Do not wait for a warning: the native entry logs one about
+ unverified declarations, but it is gated on the introspector, so on this
+ entry it never fires. Declare every encrypted column of every table you
+ query.
+- **`config` is required.** There is no `~/.cipherstash` on an edge runtime to
+ discover credentials from, so authentication is passed in. `clientId` and
+ `clientKey` are always needed; past those the type is a union, with two
+ supported paths.
+ The **access-key path** adds `workspaceCrn` + `accessKey` — the four `CS_*`
+ values shown above. Mint them with `stash env --name ` and set them
+ with `supabase secrets set`, or pass `--env-file` for `supabase functions
+ serve`. The **strategy path** takes a pre-built `config.authStrategy`
+ instead — `AccessKeyStrategy` or `OidcFederationStrategy`, both re-exported
+ from `@cipherstash/stack/wasm-inline` — and makes `workspaceCrn` optional,
+ since a built strategy already carries the CRN. So authenticating *as the
+ end user* over OIDC federation does work on the edge; what does not is
+ binding data to that user, two bullets below.
+- **`databaseUrl` is not accepted.** The options type declares it
+ `databaseUrl?: never`, so passing one is a type error whether you write the
+ options inline or build them as a `const` first, and a construction-time
+ throw for callers arriving from plain JS.
+- **`.withLockContext()` and `.audit()` throw.** Identity-bound encryption is
+ not implemented on the WASM engine (cipherstash/stack#797); the entry fails
+ loudly rather than dropping the identity claim and writing a value any
+ keyset holder could decrypt. If you need lock contexts, that path stays on
+ Node.
+
+The entry is ESM-only. It is server-side, not browser-safe — the WASM client
+requires a workspace `clientKey` on every authentication path, so shipping one
+to a browser would ship the key with it (cipherstash/stack#804).
+
## Insert (Encrypted Automatically)
```typescript