Skip to content

Commit ad059f3

Browse files
authored
Merge pull request #951 from cipherstash/docs/supabase-wasm-inline-shipping-docs
docs(supabase): document the wasm-inline entry in the two files that ship
2 parents dcc8c95 + 4d71746 commit ad059f3

11 files changed

Lines changed: 1286 additions & 52 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
---
2+
'@cipherstash/stack-supabase': patch
3+
'stash': patch
4+
'@cipherstash/wizard': patch
5+
---
6+
7+
Stop telling customers the Supabase wrapper cannot run in a Worker.
8+
9+
`@cipherstash/stack-supabase` has shipped two entry points since #912. The
10+
package root introspects your database and runs on Node; the `wasm-inline`
11+
entry carries the WASM engine, takes declared `schemas` instead of
12+
introspecting, and runs on Deno, Supabase Edge Functions and Cloudflare
13+
Workers. Introspection was the only thing that needed a Postgres socket, and
14+
that entry does not do it.
15+
16+
Two shipping documents were never updated and still described the state before
17+
that change:
18+
19+
- `packages/stack-supabase/README.md` said "the factory cannot run in an edge
20+
Worker or the browser" and did not mention the `wasm-inline` entry anywhere
21+
in the file. This is the npm package page.
22+
- `skills/stash-supabase/SKILL.md` said the same thing in its setup section.
23+
The skill ships inside the `stash` tarball, and `stash init` copies it into
24+
the customer's own repository, where their coding agent reads it as
25+
instruction. The one correct mention of the edge entry was in a callout near
26+
the top that the setup steps never pointed at, so a reader following the
27+
setup never learned the second entry existed.
28+
29+
The population this misled hardest is the one that needs the edge entry most:
30+
server code on Lovable, v0, Bolt and Replit runs on an edge runtime, which is
31+
exactly the case `wasm-inline` was built for and exactly the case these
32+
documents called impossible.
33+
34+
Both files now describe both entries. The README gains an "Edge runtimes"
35+
section with the call shape; the skill gains a fifth setup step with the same,
36+
and the introspection paragraph now scopes its restriction to the native entry
37+
and points there. Both name the four ways the edge entry differs: `schemas` is
38+
required, `config` is required, `databaseUrl` is refused, and
39+
`.withLockContext()` / `.audit()` throw rather than silently dropping an
40+
identity claim (#797).
41+
42+
The **browser** half of the old sentence was correct and is kept, with the
43+
reason now given: the WASM client requires a workspace `clientKey` on every
44+
authentication path, so a browser build would ship the key with it (#804).
45+
46+
Four claims that were wrong in the same neighbourhood are corrected while we
47+
are here, three of them pre-dating this change:
48+
49+
- **`skills/stash-managed-platforms/SKILL.md` shipped a snippet that does not
50+
compile.** It authored the `schemas` object from `@cipherstash/stack/wasm-inline`
51+
and handed it to `encryptedSupabase` from `@cipherstash/stack-supabase/wasm-inline`.
52+
The adapter types `schemas` from `@cipherstash/stack/eql/v3`, and the two
53+
entries ship independent declarations of the column classes whose private
54+
`columnName` field TypeScript compares nominally — so `tsc` rejects it while
55+
the code runs perfectly, which is why nobody noticed. The schema import now
56+
comes from `eql/v3`, and a new guard
57+
(`scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs`) fails if any
58+
shipped document pairs the two again.
59+
- **`skills/stash-edge/SKILL.md` is what produced that snippet.** Its "Schema
60+
Modules Do Not Cross Entries" section told edge projects to author schemas
61+
from `@cipherstash/stack/wasm-inline` with no carve-out. The rule is really
62+
"author against the entry whose *client type* consumes the schema": a raw
63+
`Encryption` client from `wasm-inline` wants `wasm-inline` tables, but the
64+
Supabase adapter wants `eql/v3` tables on both of its entries, WASM engine or
65+
not. The section now says so, and the "The Supabase adapter has its own edge
66+
entry" note points at it.
67+
- **`skills/stash-supabase/SKILL.md` described the wrong failure mode for a
68+
missing declaration.** Omitting `schemas` on the edge entry cannot produce a
69+
no-column client — it is non-optional on the type and throws at construction —
70+
and an undeclared *table* throws rather than passing through unencrypted. The
71+
hazard is an undeclared **column** on a declared table, which is treated as
72+
plaintext; the bullet now says that, along with the one thing that limits it
73+
(a plaintext write to an `eql_v3_*` column fails the domain CHECK, though a
74+
NULL still passes) and the fact that the native entry's warning about
75+
unverified declarations is gated on the introspector and so never fires
76+
there. Reads get no equivalent backstop, and the bullet now says so: the
77+
`select('*')` refusal looks like one, but a query awaited with no
78+
`.select()` at all takes the raw-`*` branch in `query-builder.ts` and
79+
returns every column undecrypted.
80+
- **"Undeclared tables behave exactly as with no `schemas` at all" was false on
81+
the native entry too.** Introspection is gated on a resolved database URL,
82+
not on the absence of `schemas`, and an ambient `DATABASE_URL` is
83+
deliberately ignored once tables are declared. The statement holds only when
84+
`databaseUrl` is passed *alongside* `schemas`, which is what it now says.
85+
86+
`config` is corrected everywhere that called all four `CS_*` values mandatory:
87+
the README, `skills/stash-supabase/SKILL.md`, and — in `skills/stash-edge/SKILL.md`
88+
— its frontmatter description, its Credentials section, its troubleshooting
89+
advice, and the native-vs-WASM comparison table. That skill already contradicted
90+
itself, since its own `config.authStrategy` example passes two values, not four.
91+
The same sentence in the `EncryptedSupabaseWasmOptions` doc comment
92+
(`packages/stack-supabase/src/wasm-inline.ts`) is fixed too, comment-only. Only
93+
`clientId` and `clientKey` are always required. Beyond them the config is a union — the
94+
access-key path adds `workspaceCrn` + `accessKey`, and the strategy path takes
95+
a pre-built `config.authStrategy` and makes `workspaceCrn` optional, because a
96+
built strategy already carries the CRN. `OidcFederationStrategy` is re-exported
97+
from `@cipherstash/stack/wasm-inline`, so authenticating as the end user works
98+
on the edge; what does not work is binding data to that user, which stays
99+
called out in its own `.withLockContext()` bullet.
100+
101+
Tests now anchor the corrected claims against the code rather than against
102+
prose. `packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts` asserts
103+
at the type level that the edge `config` accepts both the access-key arm and a
104+
strategy-only arm without `workspaceCrn`, and rejects `clientId` + `clientKey`
105+
alone. `supabase-declared-mode.test.ts` gains a case pinning the real hazard: an
106+
undeclared column on a declared table reaches PostgREST as plaintext on insert,
107+
update and filter, and is absent from the decrypt call.
108+
109+
Review found four more, one of them a change to a published type:
110+
111+
- **`databaseUrl` was only refused for callers who wrote the options inline.**
112+
`EncryptedSupabaseWasmOptions` left the field out, and omission is policed by
113+
excess-property checking, which fires on fresh object literals alone. An
114+
options object assembled as a `const` and passed by variable — which is what
115+
a Node-to-edge port actually holds — type-checked clean and reached the
116+
construction-time throw instead, from documents saying the type checker
117+
enforced it. The field is now declared `databaseUrl?: never`, mirroring
118+
`WasmClientConfig.eqlVersion?: never` in `@cipherstash/stack`, which exists
119+
for the identical reason one package along. The runtime throw stays as the
120+
backstop for plain JS. New type tests cover the inline and by-variable
121+
shapes on both call forms, plus a positive control that the same options
122+
object still compiles once `databaseUrl` is dropped.
123+
- **The `select('*')` correction had landed in only one of its two shipped
124+
copies.** `skills/stash-supabase/SKILL.md` carried it;
125+
`skills/stash-managed-platforms/SKILL.md`, edited in the same change, still
126+
framed declared mode as giving things up "loudly rather than silently" over a
127+
bullet naming the refusal — precisely the inference the correction exists to
128+
kill. That skill is read as instruction by an agent on Lovable, v0, Bolt or
129+
Replit, and the failure it mispromised is silent: raw EQL payloads returned
130+
as `data`, no error. The wording is carried across, and a new guard
131+
(`scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs`) fails
132+
if any shipped document states the refusal without the caveat in the same
133+
section. Two copies of one fact drift the moment one of them is edited, and
134+
no reviewer diff shows the copy nobody touched.
135+
- **"Everything after construction is the same wrapper" was false in the first
136+
way a reader hits it.** Both `packages/stack-supabase/README.md` and
137+
`skills/stash-supabase/SKILL.md` said `from()`, the filters and the response
138+
shape are identical across the two entries — the README saying so twenty-five
139+
lines under a paragraph telling the same reader `select('*')` just works. The
140+
edge entry is always in declared mode, where `select('*')` is refused and
141+
`from()` on an undeclared table throws. Both sentences now name the two
142+
exceptions, so the quick-start snippet the section tells you to port no
143+
longer arrives with a promise it breaks.
144+
- **The ambient-`DATABASE_URL` warning cannot fire on the edge entry.**
145+
`skills/stash-managed-platforms/SKILL.md` said an ambient `DATABASE_URL` is
146+
ignored when `schemas` are passed, "with a warning that the declaration is
147+
unverified" — in a section whose subject is `wasm-inline`. Both the ambient
148+
read and the warning are gated on the introspector, which that build does not
149+
have, so nothing there ever tells you a declaration is incomplete. Now scoped
150+
to Node, with the edge case stated. The sentence immediately above it had the
151+
same fault and is fixed with it: the ⚠️ callout on undeclared columns offered
152+
"pass `databaseUrl` so introspection fills the gaps" as the remedy, inside a
153+
section about the entry that refuses `databaseUrl` — and it is the remedy a
154+
Lovable or Replit agent, which has only the edge entry, would have reached
155+
for. It now says introspection is unavailable there and what to do instead.
156+
157+
`@cipherstash/wizard` is bumped alongside `stash` because `skills/` ships in
158+
both tarballs — `packages/wizard/tsup.config.ts` copies it into `dist/skills`
159+
and `package.json` lists that under `files`. Without the bump the published
160+
wizard keeps shipping the old text until some unrelated change moves its
161+
version.
162+
163+
One more wording correction, of the kind #952 fixed in this package's `.d.ts`:
164+
`packages/stack-supabase/README.md` and `skills/stash-supabase/SKILL.md` both
165+
derived "runs on Node only" from introspection — "introspection needs a direct
166+
Postgres connection, **so** … this entry runs on Node only". Introspection is
167+
not the cause. The entry binds the native engine, so it is Node-only whether or
168+
not you declare `schemas`; a reader who took the stated cause at face value
169+
would conclude that declaring tables makes the root entry edge-capable, which is
170+
the exact wrong turn the `wasm-inline` entry exists to prevent. Both sentences
171+
now attribute the restriction to the engine and say that declaring `schemas`
172+
does not move it. Both files are enrolled in #952's
173+
`scripts/__tests__/supabase-runtime-claims.test.mjs`, which is what its own
174+
comment said to do once this branch stopped rewriting the same lines — the
175+
README and this skill are the two copies that reach a customer, and the guard
176+
now fails if either grows the claim back. `skills/stash-managed-platforms/SKILL.md`
177+
stays out, with the reason written down: its causal claims are correct, but
178+
rewording the unqualified "Worker" in its frontmatter `description` changes
179+
what the skill matches on, which is not a rider on a documentation fix.
180+
181+
No runtime behaviour changes. The one non-documentation change is the
182+
`databaseUrl?: never` field on `EncryptedSupabaseWasmOptions`, which is
183+
type-level: it rejects at compile time a call that already threw at
184+
construction.

‎packages/stack-supabase/README.md‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ Full guide: [Supabase quickstart →][supabase-docs]
8787
config to maintain — `select('*')` just works, inserts and updates encrypt automatically, and
8888
reads decrypt automatically.
8989

90-
Introspection needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an optional peer
91-
dependency and the factory cannot run in an edge Worker or the browser — construct it in your
92-
server-side code.
90+
Introspection needs a direct Postgres connection (`DATABASE_URL`), which is why `pg` is an
91+
optional peer dependency. This entry runs on Node only either way — it binds the native engine,
92+
and declaring `schemas` doesn't move that — so construct it in your server-side code. For an
93+
edge runtime, see the second entry point below.
9394

9495
It runs alongside Supabase Auth and RLS, and supports
9596
[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.
9899
> `encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of `encryptedSupabase`,
99100
> so existing imports keep working.
100101
102+
## Edge runtimes: `@cipherstash/stack-supabase/wasm-inline`
103+
104+
Deno, Supabase Edge Functions and Cloudflare Workers cannot load a native module or open a raw
105+
Postgres socket. The `wasm-inline` entry point has neither requirement: the encryption engine is
106+
a WASM blob inlined into the bundle, and you declare your tables instead of introspecting them.
107+
108+
| Entry point | Engine | Schema | Runs on |
109+
| --- | --- | --- | --- |
110+
| `@cipherstash/stack-supabase` | native | introspected from the `public.eql_v3_*` domains | Node |
111+
| `@cipherstash/stack-supabase/wasm-inline` | WASM, inlined | declared — `schemas` is required | Deno, Supabase Edge Functions, Cloudflare Workers |
112+
113+
After construction the wrapper behaves the same — the filters and the response shape are
114+
identical — with two exceptions. Declared mode refuses `select('*')` and bare `select()`, so name
115+
the columns you want; that refusal is not a read backstop, because a query awaited with no
116+
`.select()` at all still returns every column undecrypted. And `from()` on a table you did not
117+
declare throws, because there is no introspected table list to fall back on.
118+
119+
```ts
120+
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
121+
import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline'
122+
123+
const users = encryptedTable('users', { email: types.TextSearch('email') })
124+
125+
const es = await encryptedSupabase(supabaseUrl, supabaseKey, {
126+
schemas: { users },
127+
config: {
128+
workspaceCrn: Deno.env.get('CS_WORKSPACE_CRN')!,
129+
accessKey: Deno.env.get('CS_CLIENT_ACCESS_KEY')!,
130+
clientId: Deno.env.get('CS_CLIENT_ID')!,
131+
clientKey: Deno.env.get('CS_CLIENT_KEY')!,
132+
},
133+
})
134+
```
135+
136+
Four differences from the entry above, three of them enforced by the type checker: `schemas` is
137+
required, because nothing introspects here; `config` is required, because there is no
138+
`~/.cipherstash` on an edge runtime to discover credentials from; `databaseUrl` is refused; and
139+
`.withLockContext()` / `.audit()` throw rather than silently dropping the identity claim — the
140+
WASM engine does not implement them yet ([#797][issue-797]).
141+
142+
`config` always needs `clientId` and `clientKey`. Past those it is a union: pass
143+
`workspaceCrn` + `accessKey` for the access-key path shown above, or a pre-built
144+
`config.authStrategy` — `AccessKeyStrategy` or `OidcFederationStrategy`, both re-exported from
145+
`@cipherstash/stack/wasm-inline` — which already carries the CRN and so makes `workspaceCrn`
146+
optional. Authenticating as the end user over OIDC federation therefore works on the edge; what
147+
does not is binding data to that user with `.withLockContext()`.
148+
149+
This entry is ESM-only, and it is server-side rather than browser-safe: the WASM client requires
150+
a workspace `clientKey` on every authentication path, so a browser build would ship the key with
151+
it ([#804][issue-804]).
152+
101153
## How it works
102154

103155
<p align="center">
@@ -132,3 +184,5 @@ it should, and the EQL install needs no superuser (it works on cloud-hosted Supa
132184
[eql]: https://github.com/cipherstash/encrypt-query-language
133185
[stack-drizzle]: https://www.npmjs.com/package/@cipherstash/stack-drizzle
134186
[stack-prisma]: https://www.npmjs.com/package/@cipherstash/stack-prisma
187+
[issue-797]: https://github.com/cipherstash/stack/issues/797
188+
[issue-804]: https://github.com/cipherstash/stack/issues/804

0 commit comments

Comments
 (0)