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