diff --git a/.changeset/adapter-release-readiness.md b/.changeset/adapter-release-readiness.md new file mode 100644 index 000000000..f633bd8df --- /dev/null +++ b/.changeset/adapter-release-readiness.md @@ -0,0 +1,50 @@ +--- +'@cipherstash/stack': minor +'@cipherstash/stack-supabase': minor +'stash': patch +'@cipherstash/wizard': patch +--- + +Finish the EQL v2-removal release gates and adapter correctness pass. + +- **Supabase encrypts leaves nested inside a PostgREST boolean group.** This + is a disclosure fix, not a formatting one. The `.or()` string parser had + no group recursion, so `.or('and(createdAt.gte.2026-01-01,note.eq.x)')` + came back from the top-level split as one part and the leaf parser cut it + at the first dot into the pseudo-column `and(createdAt`. That name matched + no encrypted column, so the whole expression took the verbatim branch: the + operand `2026-01-01` reached PostgREST **as plaintext, against an + encrypted column**, under the JS property name `createdAt` rather than the + DB column name `created_at`. Every encrypted leaf nested inside `and(...)` + / `or(...)` / `not.and(...)` leaked its operand to the database and + returned wrong results. Nested groups and `referencedTable` are now + preserved while each encrypted leaf is substituted in place. +- Supabase never sends nullish encrypted search operands as plaintext, honours + escaped LIKE metacharacters, rejects CSV result mode before decryption, and + diagnoses the removed object-form factory call. The bundled `stash-supabase` + skill no longer lists `csv()` among the transforms passed through to + Supabase — it throws, and the skill now says so and shows serializing the + decrypted rows instead. +- Native, WASM, and Supabase model decryption reconstruct valid date and + timestamp values consistently, including nested paths, aliases, and bulk + results, while leaving invalid values unchanged. That last clause is a + behavioural change on the native typed client and the Supabase adapter, + which previously pushed every date-like column through `new Date(...)` + unconditionally: a stored value that does not parse used to come back as an + Invalid `Date` and now comes back as the raw string, matching what the WASM + entry already did. The declared column type is still `Date`, so code that + assumed `instanceof Date` held for every date column — or called a `Date` + method on it unguarded, so that `.getTime()` used to yield `NaN` and now + throws a `TypeError` — has to handle the raw value. +- `stash init` names the concrete `public.eql_v3_*` domain family and gives + `public.eql_v3_text_search` as a valid Supabase example. +- CLI and wizard skill selection stay in parity for every integration, + including the Prisma Next skill, and verify that each selected skill has a + `SKILL.md`. + +The final 1.0 integration surface is `Encryption` from +`@cipherstash/stack/v3`, the `@cipherstash/stack-drizzle` package root, and +`encryptedSupabase` from `@cipherstash/stack-supabase`. DynamoDB decrypt +operations retain `.audit()` on the typed `Encryption` client. Existing EQL v2 +ciphertext remains readable through the core client; authoring and adapter +writes use EQL v3. diff --git a/.changeset/decrypt-chaining-docs.md b/.changeset/decrypt-chaining-docs.md index 19bcbb8ea..36afc506b 100644 --- a/.changeset/decrypt-chaining-docs.md +++ b/.changeset/decrypt-chaining-docs.md @@ -31,8 +31,9 @@ other two to guess: for Drizzle, the `encryptedSupabase` wrapper's own filters for Supabase, the `eql*` column operators for Prisma Next, and `client.encryptQuery(...)` for a plain Postgres project. -- **Schema authoring.** The `types.*` column factories for Drizzle, the - `eql_v3_encrypted` domain in migration SQL for Supabase, the `cipherstash.*` +- **Schema authoring.** The `types.*` column factories for Drizzle, a concrete + `public.eql_v3_*` domain such as `public.eql_v3_text_search` in migration SQL + for Supabase, the `cipherstash.*` field constructors in `schema.prisma` for Prisma Next, and `encryptedTable` for plain Postgres. Prisma Next was previously sent at `types.*` / `encryptedTable` — the client `stash schema build` explicitly refuses to diff --git a/.changeset/dynamodb-eql-v3.md b/.changeset/dynamodb-eql-v3.md index 54b960c92..8ba065f9d 100644 --- a/.changeset/dynamodb-eql-v3.md +++ b/.changeset/dynamodb-eql-v3.md @@ -6,10 +6,9 @@ `encryptedDynamoDB` now accepts EQL v3 tables. Pass a table built with `encryptedTable` + the `types.*` domains from -`@cipherstash/stack/v3` (or `@cipherstash/stack/eql/v3`) to any of -`encryptModel`, `bulkEncryptModels`, `decryptModel`, `bulkDecryptModels`. Both -the typed client from `EncryptionV3` and the nominal client from -`Encryption({ config: { eqlVersion: 3 } })` are accepted. +`@cipherstash/stack/v3` to any of `encryptModel`, `bulkEncryptModels`, +`decryptModel`, or `bulkDecryptModels`. Build the typed client with +`Encryption({ schemas: [table] })`. EQL v2 tables continue to be **readable** — `decryptModel` / `bulkDecryptModels` still accept one, so existing items stay accessible. Writing @@ -36,8 +35,8 @@ Notes on capability: path — `{ 'profile.ssn': types.TextEq('profile.ssn') }`. The model is matched by dotted path, so `{ profile: { ssn } }` resolves, and the nested attribute keeps its `__hmac` for key conditions. -- Audit metadata on `decryptModel` / `bulkDecryptModels` requires the nominal - client; the `EncryptionV3` client has no audit surface on decrypt. +- The typed `Encryption` client supports `.audit()` on `decryptModel` and + `bulkDecryptModels`, including when used through the DynamoDB adapter. The DynamoDB adapter also gains its first test coverage — across the v2 and v3 paths, where it previously had none. diff --git a/.changeset/nextjs-stack-metadata.md b/.changeset/nextjs-stack-metadata.md new file mode 100644 index 000000000..2df589862 --- /dev/null +++ b/.changeset/nextjs-stack-metadata.md @@ -0,0 +1,8 @@ +--- +'@cipherstash/nextjs': patch +--- + +Correct the published package metadata to reference `@cipherstash/stack` +instead of the removed `@cipherstash/protect` package. The package now also +ships with its own source typecheck command and keeps its Vitest mock typing +compatible with the repository-pinned test runner. diff --git a/.changeset/remove-eql-v2-drizzle-root.md b/.changeset/remove-eql-v2-drizzle-root.md index 64d918746..eb06bd693 100644 --- a/.changeset/remove-eql-v2-drizzle-root.md +++ b/.changeset/remove-eql-v2-drizzle-root.md @@ -11,17 +11,16 @@ Remove the EQL v2 authoring surface from `@cipherstash/stack-drizzle` and collap - The EQL v2 root exports are gone: `encryptedType`, the v2 `extractEncryptionSchema`, the v2 `createEncryptionOperators` (including the `like` / `ilike` operators), and `EncryptionConfigError`. Authoring or querying `eql_v2_encrypted` columns through Drizzle is no longer supported. - The `./v3` subpath is **removed** from the package `exports` map and `typesVersions`. The EQL v3 implementation is now the package root, and the `*V3` names are de-suffixed (`createEncryptionOperatorsV3` → `createEncryptionOperators`, `extractEncryptionSchemaV3` → `extractEncryptionSchema`). This is a **hard break with no alias**: post-collapse the root names would collide with the removed v2 names, and keeping an alias would silently type-check v2 call sites against v3 semantics. -**Migration** — import the v3 surface from the package root instead of `./v3`, and drop the `V3` suffix: - -```diff -- import { types, extractEncryptionSchemaV3, createEncryptionOperatorsV3 } from '@cipherstash/stack-drizzle/v3' -+ import { types, extractEncryptionSchema, createEncryptionOperators } from '@cipherstash/stack-drizzle' -``` +**Migration** — import `types`, `extractEncryptionSchema`, and +`createEncryptionOperators` from the `@cipherstash/stack-drizzle` package root. The `types.*` column factories, `makeEqlV3Column` / `getEqlV3Column` / `isEqlV3Column`, the codec helpers (`v3ToDriver` / `v3FromDriver` / `EqlV3CodecError`), and `EncryptionOperatorError` are unchanged apart from moving to the root. Existing EQL v2 ciphertext remains decryptable via `@cipherstash/stack` — only the Drizzle-side v2 authoring and query-building is removed. -**`stash` (patch):** `stash init --drizzle` scaffolded the removed surface — the generated encryption-client file and the Drizzle placeholder both imported `extractEncryptionSchemaV3` from `@cipherstash/stack-drizzle/v3`, so a freshly-initialised project would not resolve against this release. Both now emit the collapsed root import. The bundled `stash-drizzle` and `stash-encryption` skills are updated to match. +**`stash` (patch):** `stash init --drizzle` now emits the package-root +`extractEncryptionSchema` import. The bundled `stash-drizzle` and +`stash-encryption` skills are updated to match. -**`@cipherstash/stack` (patch):** README only — its Drizzle section documented the removed `./v3` subpath and the `*V3` export names. +**`@cipherstash/stack` (patch):** README only — its Drizzle section now +documents the final package-root exports. diff --git a/.changeset/remove-eql-v2-migrate-classifier.md b/.changeset/remove-eql-v2-migrate-classifier.md index e6ae23fb6..04d74dda1 100644 --- a/.changeset/remove-eql-v2-migrate-classifier.md +++ b/.changeset/remove-eql-v2-migrate-classifier.md @@ -1,5 +1,5 @@ --- -'@cipherstash/migrate': patch +'@cipherstash/migrate': minor --- Drop EQL v2 from the domain-type classifier. `classifyEqlDomain` (and the diff --git a/.changeset/remove-eql-v2-packages.md b/.changeset/remove-eql-v2-packages.md index 3cc56bc0a..772a72e62 100644 --- a/.changeset/remove-eql-v2-packages.md +++ b/.changeset/remove-eql-v2-packages.md @@ -1,6 +1,5 @@ --- '@cipherstash/stack': patch -'@cipherstash/nextjs': patch --- Remove the EQL v2-only published packages `@cipherstash/protect`, diff --git a/.changeset/remove-eql-v2-supabase-authoring.md b/.changeset/remove-eql-v2-supabase-authoring.md index ad783dc52..0bf7fb5f5 100644 --- a/.changeset/remove-eql-v2-supabase-authoring.md +++ b/.changeset/remove-eql-v2-supabase-authoring.md @@ -6,22 +6,18 @@ Remove the EQL v2 authoring surface and de-suffix the v3 API to the canonical unsuffixed names (part of the EQL v2 removal, #707). - **`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory** - (formerly `encryptedSupabaseV3`). `encryptedSupabaseV3` remains as a + (formerly `encryptedSupabaseV3`). `encryptedSupabaseV3` remains a type-identical `@deprecated` alias, so existing imports keep working. - **The legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })` wrapper is removed** — with it the two-argument `from(tableName, schema)` form and the hand-written client-side v2 schema. Its `EncryptedSupabaseConfig` and the v2 `EncryptedSupabaseInstance`/`EncryptedQueryBuilder` type shapes are gone; the unsuffixed type names now denote the v3 surface. -- **The `*V3` type exports are de-suffixed** to their canonical names — - `EncryptedSupabaseV3Options` → `EncryptedSupabaseOptions`, - `EncryptedSupabaseV3Instance` → `EncryptedSupabaseInstance`, - `TypedEncryptedSupabaseV3Instance` → `TypedEncryptedSupabaseInstance`, - `EncryptedQueryBuilderV3` → `EncryptedQueryBuilder`, - `EncryptedQueryBuilderV3Untyped` → `EncryptedQueryBuilderUntyped`, - `V3FilterableKeys` → `FilterableKeys`, `V3OrderableKeys` → `OrderableKeys`, and - the rest of the `*V3` key-helper types. Each keeps a type-identical - `@deprecated` `*V3` alias. +- **The public types use canonical unsuffixed names:** + `EncryptedSupabaseOptions`, `EncryptedSupabaseInstance`, + `TypedEncryptedSupabaseInstance`, `EncryptedQueryBuilder`, + `EncryptedQueryBuilderUntyped`, `FilterableKeys`, and `OrderableKeys`. Each + keeps a type-identical `@deprecated` `*V3` alias. **Reading existing v2 data.** Only the v2 *authoring/emission* surface is removed — no v2 ciphertext is stranded. Decryption in `@cipherstash/stack` is @@ -37,9 +33,6 @@ Internally the v3 query builder (`query-builder-v3.ts`) was folded into the base `EncryptedQueryBuilderImpl`, which is now natively EQL v3; no runtime behaviour or wire encoding changed. -**Migration:** rename `encryptedSupabaseV3` → `encryptedSupabase` (or keep using -the alias). If you still use the v2 `encryptedSupabase({ encryptionClient, -supabaseClient }).from(table, schema)` wrapper, migrate the table to an -`eql_v3_*` column domain and switch to the introspecting factory — -`await encryptedSupabase(supabaseUrl, supabaseKey)` — see the `stash-supabase` -skill and https://cipherstash.com/docs. +**Migration:** use `await encryptedSupabase(supabaseUrl, supabaseKey)` with +`eql_v3_*` column domains. See the `stash-supabase` skill and +https://cipherstash.com/docs. diff --git a/.changeset/remove-eql-v2-supabase-skill.md b/.changeset/remove-eql-v2-supabase-skill.md index c4c33dedd..c632acc7f 100644 --- a/.changeset/remove-eql-v2-supabase-skill.md +++ b/.changeset/remove-eql-v2-supabase-skill.md @@ -4,8 +4,8 @@ Update the bundled `stash-supabase` agent skill for the EQL v2 removal (#707): `encryptedSupabase` is now the connect-time-introspecting EQL v3 factory (with -`encryptedSupabaseV3` kept as a `@deprecated` alias), and the legacy v2 -`encryptedSupabase({ encryptionClient, supabaseClient })` authoring wrapper has -been removed. The skill's examples, exported-type list, and migration/cutover +`encryptedSupabaseV3` kept as a type-identical `@deprecated` alias), and the +legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })` authoring +wrapper has been removed. The skill's examples, exported-type list, and migration/cutover guidance are corrected accordingly. Skills ship inside the `stash` tarball, so the stale v2 guidance would otherwise land in a user's project. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ece7ea51..4a656554d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -166,13 +166,14 @@ jobs: # workspace dependencies through `dist/*.d.ts`, hence the turbo filters # (`^build` builds the dependencies first). # - # NOT gated yet, and deliberately: `@cipherstash/stack` (147 errors under - # its own tsconfig), `@cipherstash/stack-drizzle` (63), `stash` (21) and - # `@cipherstash/stack-supabase` (11). Their `test:types` scripts only - # cover `__tests__/**/*.test-d.ts`, so `src`, the runtime test suites and - # `integration/**` compile nowhere. Most of the drizzle and supabase count - # is one root cause — `V3_MATRIX`'s `indexes` union in - # `@cipherstash/test-kit` — see #778. + # NOT fully gated yet, and deliberately: `@cipherstash/stack` (147 errors + # under its own tsconfig), `stash` (21) and `@cipherstash/stack-supabase` + # (11). Their `test:types` scripts only cover `__tests__/**/*.test-d.ts`, + # so `src` and the runtime test suites compile nowhere. + # `@cipherstash/stack-drizzle` now type-tests every `integration/**` + # source as well; its remaining `src` / runtime-test errors share one + # root cause — `V3_MATRIX`'s `indexes` union in `@cipherstash/test-kit` + # — see #778. - name: Typecheck (migrate) run: pnpm exec turbo run typecheck --filter @cipherstash/migrate diff --git a/docs/reference/supabase-sdk.md b/docs/reference/supabase-sdk.md index 220ea7734..a33b27e17 100644 --- a/docs/reference/supabase-sdk.md +++ b/docs/reference/supabase-sdk.md @@ -58,7 +58,9 @@ capabilities come from the introspected domains. The builder surface is `.select/.insert/.update/.upsert/.delete`, `.eq/.neq/.in/.is/.gt/.gte/.lt/.lte/.match/.or/.not/.filter`, -transforms (`.order/.limit/.range/.single/.maybeSingle/.csv/.abortSignal/.throwOnError`), +transforms (`.order/.limit/.range/.single/.maybeSingle/.abortSignal/.throwOnError` +— `.csv()` is declared but always throws, since PostgREST serializes rows +before the wrapper can decrypt them), plus `.withLockContext(lockContext)` and `.audit(config)`. For free-text search it exposes `.matches()` (fuzzy bloom token search) on encrypted columns, keeps `.contains()` for native (exact) containment on plaintext diff --git a/e2e/README.md b/e2e/README.md index 3e2e625ac..c3ee77197 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -25,6 +25,7 @@ pnpm --filter @cipherstash/e2e exec vitest run tests/package-managers.e2e.test.t | `tests/package-managers.e2e.test.ts` | The `init` providers and the wizard binary render `bunx`/`pnpm dlx`/`yarn dlx`/`npx` based on detected package manager. | | `tests/supply-chain.e2e.test.ts` | Lockfile/registry/CODEOWNERS controls from `skills/stash-supply-chain-security` are still enforced. | | `tests/prisma-example-readme.e2e.test.ts` | Parses `examples/prisma/README.md`'s "Run it" section and asserts every command (skipping `stash auth login`) exits 0. | +| `tests/skill-map-parity.e2e.test.ts` | The CLI's and the wizard's `SKILL_MAP` install the same skills for each equivalent integration. Lives here because this is the only workspace declaring both packages. | ## Auth-dependent suites diff --git a/e2e/package.json b/e2e/package.json index dd10ac340..0d328cec2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@types/semver": "7.7.1", "semver": "^7.8.0", + "typescript": "catalog:repo", "vitest": "catalog:repo", "yaml": "^2.9.0" } diff --git a/e2e/tests/skill-map-parity.e2e.test.ts b/e2e/tests/skill-map-parity.e2e.test.ts new file mode 100644 index 000000000..3766a48d1 --- /dev/null +++ b/e2e/tests/skill-map-parity.e2e.test.ts @@ -0,0 +1,60 @@ +/** + * The CLI and the wizard each own a `SKILL_MAP`, and the two must agree: both + * install skills into the same `.claude/skills` / `.codex/skills` directory of + * the same user project, so a user who ran `npx stash init` and a user who ran + * the wizard would otherwise end up with different guidance for the same + * integration. The maps drift silently — nothing imports one from the other. + * + * This assertion lives HERE, not in either package's own suite, because it is + * the only workspace that declares both `stash` and `@cipherstash/wizard` as + * dependencies. It reads source rather than a built binary (the same idiom as + * `package-managers.e2e.test.ts` Suite A) so it needs no build step, and + * `pnpm --filter @cipherstash/e2e run typecheck` compiles `tests/**` — which + * makes a relocated module a compile error in CI rather than a module-not-found + * inside an unrelated package's unit run. + * + * The integration names differ by design: the CLI names the packages + * (`prisma-next`, `postgresql`), the wizard names the user's situation + * (`prisma`, `generic`). The mapping below is the whole contract. + */ + +import { describe, expect, it } from 'vitest' + +import { SKILL_MAP as CLI_SKILL_MAP } from '../../packages/cli/src/commands/init/lib/install-skills.js' +import type { Integration as CliIntegration } from '../../packages/cli/src/commands/init/types.js' +import { SKILL_MAP as WIZARD_SKILL_MAP } from '../../packages/wizard/src/lib/install-skills.js' +import type { Integration as WizardIntegration } from '../../packages/wizard/src/lib/types.js' + +// Exhaustive over the wizard union: a new wizard integration added without a +// CLI counterpart fails to compile here, rather than shipping a divergent set. +const EQUIVALENT: Record = { + drizzle: 'drizzle', + supabase: 'supabase', + prisma: 'prisma-next', + generic: 'postgresql', +} + +describe('CLI and wizard SKILL_MAP parity', () => { + it('installs the same skills for every equivalent integration', () => { + for (const [wizardName, cliName] of Object.entries(EQUIVALENT) as Array< + [WizardIntegration, CliIntegration] + >) { + expect( + WIZARD_SKILL_MAP[wizardName], + `${wizardName} (wizard) vs ${cliName} (cli)`, + ).toEqual(CLI_SKILL_MAP[cliName]) + } + }) + + // Both unions are closed, so parity over `EQUIVALENT` is only complete while + // it covers every CLI integration too — a CLI-only integration would slip + // through the loop above unnoticed. + it('covers every integration on both sides', () => { + expect(Object.keys(WIZARD_SKILL_MAP).sort()).toEqual( + Object.keys(EQUIVALENT).sort(), + ) + expect(Object.keys(CLI_SKILL_MAP).sort()).toEqual( + Object.values(EQUIVALENT).sort(), + ) + }) +}) diff --git a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts index 052496cc0..5da026907 100644 --- a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts @@ -39,7 +39,23 @@ const ALL_INTEGRATIONS: Integration[] = [ 'postgresql', ] +// Parity with the wizard's own SKILL_MAP is asserted in +// `e2e/tests/skill-map-parity.e2e.test.ts` — the only workspace that declares +// both `stash` and `@cipherstash/wizard`, and whose `typecheck` script compiles +// the cross-package import. describe('SKILL_MAP', () => { + it('selects only skills that contain a bundled SKILL.md', () => { + for (const integration of ALL_INTEGRATIONS) { + const available = new Set(availableSkills(integration)) + const skills = SKILL_MAP[integration] + for (const skill of skills) { + expect(available.has(skill), `${integration}: ${skill}/SKILL.md`).toBe( + true, + ) + } + } + }) + it('has a non-empty entry for every integration (no undefined → crash)', () => { for (const integration of ALL_INTEGRATIONS) { const skills = SKILL_MAP[integration] diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index ee1c302b7..b80a6854e 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -188,7 +188,8 @@ describe('renderSetupPrompt — orient + route (implement mode)', () => { }) it('names the eql_v3 domain for supabase', () => { - expect(render('supabase')).toContain('eql_v3_encrypted') + expect(render('supabase')).toContain('email public.eql_v3_text_search') + expect(render('supabase')).not.toContain('eql_v3_encrypted') }) it('names the cipherstash.* field constructors for prisma-next', () => { diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 3deefd3f3..404ede44f 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -100,7 +100,7 @@ function schemaAuthoringGuidance(integration: Integration): string { case 'drizzle': return 'Declare it with the `types.*` column factories from `@cipherstash/stack-drizzle` on the existing `pgTable`' case 'supabase': - return 'Declare the column with the `eql_v3_encrypted` domain in the migration SQL (`encryptedSupabase` derives its encryption config by introspecting those domains)' + return 'Declare the column with a concrete `public.eql_v3_*` domain in the migration SQL — for example, `email public.eql_v3_text_search` (`encryptedSupabase` derives its encryption config by introspecting those domains)' case 'prisma-next': return 'Declare the field with the `cipherstash.*` constructors in `prisma/schema.prisma` (`cipherstash.TextSearch()`, `cipherstash.DoubleOrd()`, …), with the `cipherstash` extension pack wired up per `@cipherstash/prisma-next/control`' default: diff --git a/packages/stack-drizzle/integration/lock-context.integration.test.ts b/packages/stack-drizzle/integration/lock-context.integration.test.ts index 489a78100..fd5dbd568 100644 --- a/packages/stack-drizzle/integration/lock-context.integration.test.ts +++ b/packages/stack-drizzle/integration/lock-context.integration.test.ts @@ -32,12 +32,8 @@ * a distinct `sub`) and asserts B cannot read A's row, with A reading it as the * control. */ -import { OidcFederationStrategy } from '@cipherstash/stack' -import { - type AnyV3Table, - type EncryptionClientFor, - EncryptionV3, -} from '@cipherstash/stack/v3' +import { type AuthFailure, OidcFederationStrategy } from '@cipherstash/stack' +import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' import { databaseUrl, unwrapResult, V3_MATRIX } from '@cipherstash/test-kit' import { clerkJwtProvider } from '@cipherstash/test-kit/integration-clerk' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' @@ -69,13 +65,13 @@ const secretTable = pgTable(TABLE_NAME, { rowKey: text('row_key').notNull(), testRunId: text('test_run_id').notNull(), secret: makeEqlV3Column(V3_MATRIX['public.eql_v3_text_eq'].builder('secret')), -} as never) +}) const schema = extractEncryptionSchema(secretTable) type SelectRow = { rowKey: string } -let client: EncryptionClientFor +let client: EncryptionClientFor let ops: ReturnType let db: ReturnType @@ -133,6 +129,12 @@ const IDENTITY_DENIAL = const INFRA_FAULT = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|timed? ?out|network error/i +function authFailureMessage(failure: AuthFailure): string { + return 'error' in failure && failure.error instanceof Error + ? failure.error.message + : failure.type +} + /** Run-scoped SELECT of row keys under an already-encrypted SQL condition. */ async function selectRowKeys(condition: SQL): Promise { const rows = (await db @@ -157,7 +159,7 @@ beforeAll(async () => { // `config.authStrategy` expects (it calls `.getToken()` on it). const federation = OidcFederationStrategy.create(crn, clerkJwtProvider()) if (federation.failure) { - throw new Error(`[federation]: ${federation.failure.message}`) + throw new Error(`[federation]: ${authFailureMessage(federation.failure)}`) } client = await EncryptionV3({ schemas: [schema], @@ -314,7 +316,9 @@ describe('v3 drizzle operators with lock context (live pg)', () => { clerkJwtProvider('CLERK_MACHINE_TOKEN_B'), ) if (federationB.failure) { - throw new Error(`[federation B]: ${federationB.failure.message}`) + throw new Error( + `[federation B]: ${authFailureMessage(federationB.failure)}`, + ) } const clientB = await EncryptionV3({ schemas: [schema], diff --git a/packages/stack-drizzle/integration/null-persistence.integration.test.ts b/packages/stack-drizzle/integration/null-persistence.integration.test.ts index 6a0e0f418..a9cc7d5d7 100644 --- a/packages/stack-drizzle/integration/null-persistence.integration.test.ts +++ b/packages/stack-drizzle/integration/null-persistence.integration.test.ts @@ -13,11 +13,7 @@ * as SQL NULL, and the present cell still decrypts to its plaintext. */ -import { - type AnyV3Table, - type EncryptionClientFor, - EncryptionV3, -} from '@cipherstash/stack/v3' +import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' import { integer, pgTable, text } from 'drizzle-orm/pg-core' @@ -54,7 +50,7 @@ const nullableTable = pgTable(TABLE_NAME, { matchText: makeEqlV3Column( V3_MATRIX['public.eql_v3_text_match'].builder('match_text'), ), -} as never) +}) // Tier metadata: property (drizzle) + DB column + a present-row plaintext. const TIERS = [ @@ -88,7 +84,7 @@ const schema = extractEncryptionSchema(nullableTable) type SelectRow = { rowKey: string } -let client: EncryptionClientFor +let client: EncryptionClientFor let ops: ReturnType let db: ReturnType diff --git a/packages/stack-drizzle/integration/relational.integration.test.ts b/packages/stack-drizzle/integration/relational.integration.test.ts index 5fac50151..892e73077 100644 --- a/packages/stack-drizzle/integration/relational.integration.test.ts +++ b/packages/stack-drizzle/integration/relational.integration.test.ts @@ -19,11 +19,7 @@ * `stash eql install`. This suite throws rather than skips when unconfigured. */ -import { - type AnyV3Table, - type EncryptionClientFor, - EncryptionV3, -} from '@cipherstash/stack/v3' +import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' import { type DomainSpec, databaseUrl, @@ -82,9 +78,9 @@ const ROWS = [ROW_A, ROW_B, ROW_C] as const // and the seed INSERT raises — so a table built from every row can only ever run // against a superuser database. Filtering here is what lets this suite run on // both the plain-Postgres and Supabase variants. -const matrixEntries = typedEntries(V3_MATRIX).filter(([, spec]) => - isCovered(spec), -) +const matrixEntries: Array = typedEntries( + V3_MATRIX, +).filter(([, spec]) => isCovered(spec)) const matrixColumns = Object.fromEntries( matrixEntries.map(([eqlType, spec]) => [ slug(eqlType), @@ -140,41 +136,13 @@ type RowKey = (typeof ROWS)[number] type MatrixPlainRow = Record type SelectRow = { rowKey: string } type Db = ReturnType -type Client = EncryptionClientFor +type Client = EncryptionClientFor type Ops = ReturnType -type ComparisonOperator = 'gt' | 'gte' | 'lt' | 'lte' - let client: Client let ops: Ops let db: Db -const equalityDomains = matrixEntries.filter( - ([, spec]) => spec.indexes.unique || spec.indexes.ore || spec.indexes.ope, -) -const orderDomains = matrixEntries.filter( - ([, spec]) => spec.indexes.ore || spec.indexes.ope, -) const matchDomains = matrixEntries.filter(([, spec]) => spec.indexes.match) -const noEqualityDomains = matrixEntries.filter( - ([, spec]) => !spec.indexes.unique && !spec.indexes.ore && !spec.indexes.ope, -) -const noOrderDomains = matrixEntries.filter( - ([, spec]) => !spec.indexes.ore && !spec.indexes.ope, -) -const noMatchDomains = matrixEntries.filter(([, spec]) => !spec.indexes.match) -const comparisonOperators: Array< - [ComparisonOperator, (cmp: number) => boolean] -> = [ - ['gt', (cmp) => cmp > 0], - ['gte', (cmp) => cmp >= 0], - ['lt', (cmp) => cmp < 0], - ['lte', (cmp) => cmp <= 0], -] -const comparisonDomains = orderDomains.flatMap(([eqlType, spec]) => - comparisonOperators.map( - ([operator, predicate]) => [eqlType, spec, operator, predicate] as const, - ), -) const matrixColumn = (eqlType: EqlV3TypeName): SQLWrapper => (matrixTable as unknown as Record)[slug(eqlType)] @@ -215,6 +183,36 @@ function encryptedInsertRows(): MatrixPlainRow[] { }) } +/** + * Re-establish the Drizzle insert type on rows that came back from + * `bulkEncryptModels`, which is typed against the runtime-shaped `AnyV3Table` + * and so returns `Record[]`. + * + * The assertion is the narrow part of the contract: it checks ONLY that the + * two plaintext scope keys — `rowKey` and `testRunId`, the ones every query + * and the run-scoped cleanup filter on — survived encryption as strings. The + * encrypted columns are NOT validated; their shape is what the assertions in + * the tests themselves are for. Named for that scope so the cast at the end + * does not read as a checked conversion of the whole row. + */ +function assertScopeKeys( + rows: unknown[], +): T[] { + for (const row of rows) { + if ( + row === null || + typeof row !== 'object' || + !('rowKey' in row) || + typeof row.rowKey !== 'string' || + !('testRunId' in row) || + typeof row.testRunId !== 'string' + ) { + throw new Error('Encrypted integration row lost plaintext scope keys') + } + } + return rows as T[] +} + beforeAll(async () => { client = await EncryptionV3({ schemas: [schema, bigintSchema] }) ops = createEncryptionOperators(client) @@ -267,8 +265,8 @@ beforeAll(async () => { ) `) - const encryptedRows = unwrapResult( - await client.bulkEncryptModels(encryptedInsertRows(), schema), + const encryptedRows = assertScopeKeys( + unwrapResult(await client.bulkEncryptModels(encryptedInsertRows(), schema)), ) await db.insert(matrixTable).values(encryptedRows) await db.insert(accountsTable).values([ @@ -276,30 +274,33 @@ beforeAll(async () => { { rowKey: ROW_B, label: 'secondary', testRunId: RUN }, ]) - // A3 end-to-end, cast-free: encrypt a native bigint model, insert the - // resulting envelope rows (typed against the column's `Encrypted` data slot), - // no `as never` anywhere. + // A3 end-to-end: encrypt a native bigint model and insert the resulting + // envelope rows against the column's `Encrypted` data slot. The extracted + // schema is intentionally runtime-shaped (`AnyV3Table`), so restore the + // concrete Drizzle insert shape at this boundary. // // ROW_B exists so the filter proofs below have a row they must EXCLUDE. On a // one-row table `gt(balance, 0n)` returning every row is indistinguishable // from it returning the right row. - const bigintRows = unwrapResult( - await client.bulkEncryptModels( - [ - { - rowKey: ROW_A, - testRunId: RUN, - balance: BIGINT_BALANCE, - ledger: BIGINT_LEDGER, - }, - { - rowKey: ROW_B, - testRunId: RUN, - balance: BIGINT_B_BALANCE, - ledger: BIGINT_B_LEDGER, - }, - ], - bigintSchema, + const bigintRows = assertScopeKeys( + unwrapResult( + await client.bulkEncryptModels( + [ + { + rowKey: ROW_A, + testRunId: RUN, + balance: BIGINT_BALANCE, + ledger: BIGINT_LEDGER, + }, + { + rowKey: ROW_B, + testRunId: RUN, + balance: BIGINT_B_BALANCE, + ledger: BIGINT_B_LEDGER, + }, + ], + bigintSchema, + ), ), ) await db.insert(bigintTable).values(bigintRows) diff --git a/packages/stack-drizzle/tsconfig.typecheck.json b/packages/stack-drizzle/tsconfig.typecheck.json index 16d312f2f..18f06548b 100644 --- a/packages/stack-drizzle/tsconfig.typecheck.json +++ b/packages/stack-drizzle/tsconfig.typecheck.json @@ -1,17 +1,13 @@ { // The typecheck gate CI runs (`test:types` -> vitest typecheck) covers the - // `.test-d.ts` files plus the two `integration/**` adapters that #772's - // review found were erroring in a file nothing compiled. + // `.test-d.ts` files plus every `integration/**` source that #772's review + // found was otherwise compiled by no CI job. // - // `src/`, the runtime `__tests__/*.test.ts` and the rest of `integration/**` - // are still ungated: 63 errors under the full `tsconfig.json`, most of them - // one root cause — `V3_MATRIX`'s `indexes` union in `@cipherstash/test-kit` - // (#778). Widen this `include` as that count comes down; do not widen it to - // the whole project until it is zero, or the gate is useless. + // `src/` and the runtime `__tests__/*.test.ts` are still ungated under the + // full `tsconfig.json`, most of it one root cause — `V3_MATRIX`'s `indexes` + // union in `@cipherstash/test-kit` (#778). Widen this `include` as that + // count comes down; do not widen it to the whole project until it is zero, + // or the gate is useless. "extends": "./tsconfig.json", - "include": [ - "__tests__/**/*.test-d.ts", - "integration/adapter.ts", - "integration/json-adapter.ts" - ] + "include": ["__tests__/**/*.test-d.ts", "integration/**/*.ts"] } diff --git a/packages/stack-supabase/__tests__/like-pattern.test.ts b/packages/stack-supabase/__tests__/like-pattern.test.ts new file mode 100644 index 000000000..113a3ed21 --- /dev/null +++ b/packages/stack-supabase/__tests__/like-pattern.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { parseLikeNeedle } from '../src/like-pattern' + +/** + * Direct unit coverage for `parseLikeNeedle`. + * + * The function is the whole of the like/ilike → matches() approximation: it + * decides which patterns are delegable and what literal term the encrypted + * fuzzy search actually receives. Its only other coverage is two end-to-end + * cases in `supabase-v3-builder.test.ts`, which exercise it through a builder + * and so pin the wire envelope rather than the parse. These tests pin the parse + * itself — in particular the escaping rules, which the naive predecessor + * (`replace(/^%+/, '').replace(/%+$/, '')` plus `pattern.includes('_')`) got + * wrong in both directions. + * + * Every pattern below is written as a JS string literal, so `\\` in the source + * is a single backslash in the pattern the parser sees. + */ + +type Case = { + /** What the row pins, and the test's name. */ + name: string + /** The SQL LIKE pattern, as a caller would pass it. */ + pattern: string + expected: { needle: string; hasUnsupportedWildcard: boolean } +} + +const CASES: Case[] = [ + { + name: 'strips the unescaped leading and trailing %', + pattern: '%abc%', + expected: { needle: 'abc', hasUnsupportedWildcard: false }, + }, + { + name: 'passes a wildcard-free pattern through untouched', + pattern: 'abc', + expected: { needle: 'abc', hasUnsupportedWildcard: false }, + }, + { + name: 'reports an interior % as unsupported, keeping it in the needle', + pattern: '%a%b%', + expected: { needle: 'a%b', hasUnsupportedWildcard: true }, + }, + { + name: 'reports an unescaped _ as unsupported', + pattern: '%a_b%', + expected: { needle: 'a_b', hasUnsupportedWildcard: true }, + }, + { + name: 'treats an escaped % as a literal percent in the needle', + // The 7-char pattern `%100\%%`: strippable wildcard, `100`, an escaped + // literal `%`, strippable wildcard. + pattern: '%100\\%%', + expected: { needle: '100%', hasUnsupportedWildcard: false }, + }, + { + name: 'treats an escaped _ as literal, without flagging it unsupported', + pattern: '%under\\_score%', + expected: { needle: 'under_score', hasUnsupportedWildcard: false }, + }, + { + name: 'reduces an all-wildcard pattern to an empty needle', + pattern: '%%', + expected: { needle: '', hasUnsupportedWildcard: false }, + }, + { + name: 'reduces an empty pattern to an empty needle', + pattern: '', + expected: { needle: '', hasUnsupportedWildcard: false }, + }, + { + name: 'keeps an escaped backslash as a literal backslash', + // `%a\\%` — the `\\` escapes a backslash, so the trailing `%` is still an + // unescaped, strippable wildcard. + pattern: '%a\\\\%', + expected: { needle: 'a\\', hasUnsupportedWildcard: false }, + }, +] + +describe('parseLikeNeedle', () => { + it.each(CASES)('$name', ({ pattern, expected }) => { + expect(parseLikeNeedle(pattern)).toEqual(expected) + }) + + /** + * Only LEADING and TRAILING `%` are strippable, and only UNESCAPED ones. + * + * Fuzzy matching is inherently unanchored, so a `%` at either end adds + * nothing and can be dropped. An ESCAPED `\%` in the same position is not a + * wildcard at all — it is a literal percent the user is searching for — so it + * must stop the strip rather than be eaten by it. The old + * `replace(/^%+/, '')` / `replace(/%+$/, '')` pair could not tell the two + * apart and silently deleted the literal. + */ + describe('strips only unescaped leading/trailing %', () => { + it('stops stripping at an escaped % on either end', () => { + expect(parseLikeNeedle('\\%abc\\%')).toEqual({ + needle: '%abc%', + hasUnsupportedWildcard: false, + }) + }) + + it('keeps an escaped % that sits between two strippable ones', () => { + // `%\%%` — the outer two are wildcards, the middle is the literal. + expect(parseLikeNeedle('%\\%%')).toEqual({ + needle: '%', + hasUnsupportedWildcard: false, + }) + }) + + it('does not strip an interior %, and reports it instead', () => { + expect(parseLikeNeedle('a%b')).toEqual({ + needle: 'a%b', + hasUnsupportedWildcard: true, + }) + }) + }) + + /** + * A trailing lone backslash is kept as a literal backslash, deliberately. + * + * Postgres rejects such a pattern outright ("LIKE pattern must not end with + * escape character"), so we could throw here too. We don't: this needle is + * only ever an approximation feeding encrypted fuzzy matching, and the + * plaintext `like` path never reaches this function, so refusing would turn a + * database-level error into an earlier, less informative client-side one for + * no gain. Treating the dangling escape as a literal keeps the needle + * well-defined. This is pinned, not incidental — don't "fix" it into a throw. + */ + it('keeps a trailing lone backslash as a literal backslash', () => { + expect(parseLikeNeedle('abc\\')).toEqual({ + needle: 'abc\\', + hasUnsupportedWildcard: false, + }) + }) + + // The interaction that actually decides whether a caller's query is accepted + // or thrown out: `hasUnsupportedWildcard` must track UNESCAPED wildcards + // only. The predecessor tested `pattern.includes('_')`, so every one of the + // escaped cases below was rejected even though it is perfectly matchable. + describe('hasUnsupportedWildcard tracks unescaped wildcards only', () => { + it.each([ + ['\\_', 'a bare escaped underscore'], + ['%under\\_score%', 'an escaped underscore between strippable wildcards'], + ['a\\_b\\_c', 'several escaped underscores'], + ['a\\%b', 'an escaped interior percent'], + ])('stays false for %s (%s)', (pattern) => { + expect(parseLikeNeedle(pattern).hasUnsupportedWildcard).toBe(false) + }) + + it.each([ + ['_abc', 'leading'], + ['a_bc', 'interior'], + ['abc_', 'trailing'], + ['%a_c%', 'interior, inside strippable wildcards'], + ['\\_a_b', 'interior, alongside an escaped underscore'], + ])('goes true for an unescaped _ (%s: %s)', (pattern) => { + expect(parseLikeNeedle(pattern).hasUnsupportedWildcard).toBe(true) + }) + }) +}) diff --git a/packages/stack-supabase/__tests__/supabase-helpers.test.ts b/packages/stack-supabase/__tests__/supabase-helpers.test.ts index cb3ef2f88..52aa1a73e 100644 --- a/packages/stack-supabase/__tests__/supabase-helpers.test.ts +++ b/packages/stack-supabase/__tests__/supabase-helpers.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { addJsonbCastsV3, parseOrString, rebuildOrString } from '../src/helpers' +import { + addJsonbCastsV3, + parseOrStringWithSpans, + rebuildOrString, + substituteOrStringLeaves, +} from '../src/helpers' import type { DbPendingOrCondition } from '../src/types' // `createdAt` is a renamed property (DB column `created_at`); `email` is a @@ -83,6 +88,21 @@ function cond(column: string, op: string, value: unknown, negate?: boolean) { return { column, op, value, negate } as unknown as DbPendingOrCondition } +/** + * The parsed leaves with `sourceSpan` dropped, for the assertions that are about + * column / op / negate / value. + * + * Spans are pinned exactly — and against the source text they must slice back + * to — in their own describe below, so removing them here costs no coverage and + * keeps these `toEqual`s as strict about the condition shape as they have + * always been. + */ +function parseConditions(orString: string) { + return parseOrStringWithSpans(orString).map( + ({ sourceSpan: _sourceSpan, ...condition }) => condition, + ) +} + describe('rebuildOrString quoting', () => { it('escapes the double quotes inside a quoted operand', () => { const out = rebuildOrString([cond('email', 'eq', ENVELOPE)]) @@ -188,13 +208,13 @@ describe('rebuildOrString containment', () => { }) }) -describe('parseOrString / rebuildOrString round-trip', () => { +describe('parseOrStringWithSpans / rebuildOrString round-trip', () => { it('round-trips an encrypted JSON envelope operand', () => { const conditions = [ { column: 'email', op: 'eq', negate: false, value: ENVELOPE }, ] expect( - parseOrString( + parseConditions( rebuildOrString(conditions.map((c) => cond(c.column, c.op, c.value))), ), ).toEqual(conditions) @@ -205,7 +225,7 @@ describe('parseOrString / rebuildOrString round-trip', () => { { column: 'a', op: 'eq', negate: false, value: 'x\\"y,z' }, ] expect( - parseOrString( + parseConditions( rebuildOrString(conditions.map((c) => cond(c.column, c.op, c.value))), ), ).toEqual(conditions) @@ -216,7 +236,7 @@ describe('parseOrString / rebuildOrString round-trip', () => { cond('email', 'eq', ENVELOPE), cond('id', 'eq', '7'), ]) - const parsed = parseOrString(s) + const parsed = parseConditions(s) expect(parsed).toHaveLength(2) expect(parsed[0].value).toBe(ENVELOPE) expect(parsed[1].value).toBe('7') @@ -238,7 +258,7 @@ describe('parseOrString / rebuildOrString round-trip', () => { const s = rebuildOrString( conditions.map((c) => cond(c.column, c.op, c.value)), ) - expect(parseOrString(s)).toEqual(conditions) + expect(parseConditions(s)).toEqual(conditions) }) }) @@ -259,23 +279,23 @@ describe('parseOrString / rebuildOrString round-trip', () => { // entirely — a filter that silently matches the wrong rows. Only or-strings // that also reference an encrypted column are rebuilt from the parse, so this // corrupts precisely the mixed encrypted/plaintext case. -describe('parseOrString containment literals', () => { +describe('parseOrStringWithSpans containment literals', () => { it('does not split on a comma inside an array literal', () => { - expect(parseOrString('note.eq.hello,tags.cs.{vip,admin}')).toEqual([ + expect(parseConditions('note.eq.hello,tags.cs.{vip,admin}')).toEqual([ { column: 'note', op: 'eq', negate: false, value: 'hello' }, { column: 'tags', op: 'cs', negate: false, value: '{vip,admin}' }, ]) }) it('does not split on a comma inside a jsonb literal', () => { - expect(parseOrString('meta.cs.{"a":1,"b":2},note.eq.x')).toEqual([ + expect(parseConditions('meta.cs.{"a":1,"b":2},note.eq.x')).toEqual([ { column: 'meta', op: 'cs', negate: false, value: '{"a":1,"b":2}' }, { column: 'note', op: 'eq', negate: false, value: 'x' }, ]) }) it('round-trips a plaintext containment literal through rebuild', () => { - const parsed = parseOrString('tags.cs.{vip,admin}') + const parsed = parseOrStringWithSpans('tags.cs.{vip,admin}') expect(rebuildOrString(parsed as DbPendingOrCondition[])).toBe( 'tags.cs."{vip,admin}"', ) @@ -290,16 +310,16 @@ describe('parseOrString containment literals', () => { // into the preceding operand — and only or-strings that also reference an // encrypted column are rebuilt from the parse, so it corrupts precisely the // mixed encrypted/plaintext case. -describe('parseOrString structural characters inside values', () => { +describe('parseOrStringWithSpans structural characters inside values', () => { it('does not close an array literal on a brace inside a quoted element', () => { - expect(parseOrString('tags.cs.{"a}b"},email.eq.secret')).toEqual([ + expect(parseConditions('tags.cs.{"a}b"},email.eq.secret')).toEqual([ { column: 'tags', op: 'cs', negate: false, value: '{"a}b"}' }, { column: 'email', op: 'eq', negate: false, value: 'secret' }, ]) }) it('does not close a jsonb literal on a brace inside a quoted value', () => { - expect(parseOrString('meta.cs.{"a":"v}"},id.eq.1')).toEqual([ + expect(parseConditions('meta.cs.{"a":"v}"},id.eq.1')).toEqual([ { column: 'meta', op: 'cs', negate: false, value: '{"a":"v}"}' }, { column: 'id', op: 'eq', negate: false, value: '1' }, ]) @@ -316,7 +336,7 @@ describe('parseOrString structural characters inside values', () => { '(', ])('keeps a quoted %s inside a jsonb literal out of the depth count', (char) => { expect( - parseOrString(`email.eq.x,meta.cs.{"a":"${char}"},note.eq.y`), + parseConditions(`email.eq.x,meta.cs.{"a":"${char}"},note.eq.y`), ).toEqual([ { column: 'email', op: 'eq', negate: false, value: 'x' }, { column: 'meta', op: 'cs', negate: false, value: `{"a":"${char}"}` }, @@ -326,20 +346,22 @@ describe('parseOrString structural characters inside values', () => { it('keeps an escaped quote inside a jsonb value opaque', () => { // `\"` must not close the element, or the `}` behind it decrements depth. - expect(parseOrString('a.eq.1,meta.cs.{"a":"\\"}"},b.eq.2')).toHaveLength(3) + expect(parseConditions('a.eq.1,meta.cs.{"a":"\\"}"},b.eq.2')).toHaveLength( + 3, + ) }) it('splits after an unmatched brace in an unquoted value', () => { // `}` is not a PostgREST reserved character, so `a}b` is a valid unquoted // scalar operand. - expect(parseOrString('nickname.eq.a}b,id.eq.1')).toEqual([ + expect(parseConditions('nickname.eq.a}b,id.eq.1')).toEqual([ { column: 'nickname', op: 'eq', negate: false, value: 'a}b' }, { column: 'id', op: 'eq', negate: false, value: '1' }, ]) }) it('splits after an unmatched paren in an unquoted value', () => { - expect(parseOrString('a.eq.x),b.eq.y')).toEqual([ + expect(parseConditions('a.eq.x),b.eq.y')).toEqual([ { column: 'a', op: 'eq', negate: false, value: 'x)' }, { column: 'b', op: 'eq', negate: false, value: 'y' }, ]) @@ -352,14 +374,14 @@ describe('parseOrString structural characters inside values', () => { // then forwarded VERBATIM (nothing looks encrypted), so PostgREST runs the // swallowed `email.eq.ada` with a plaintext operand against a ciphertext column. it('splits after an unmatched opening brace in an unquoted value', () => { - expect(parseOrString('note.eq.a{b,email.eq.ada')).toEqual([ + expect(parseConditions('note.eq.a{b,email.eq.ada')).toEqual([ { column: 'note', op: 'eq', negate: false, value: 'a{b' }, { column: 'email', op: 'eq', negate: false, value: 'ada' }, ]) }) it('splits after an unmatched opening paren in an unquoted value', () => { - expect(parseOrString('note.eq.a(b,email.eq.ada')).toEqual([ + expect(parseConditions('note.eq.a(b,email.eq.ada')).toEqual([ { column: 'note', op: 'eq', negate: false, value: 'a(b' }, { column: 'email', op: 'eq', negate: false, value: 'ada' }, ]) @@ -368,31 +390,32 @@ describe('parseOrString structural characters inside values', () => { // A stray opener must not cost the or-string its REAL containment literals. // Discarding the depth pass wholesale on an unbalanced count re-splits inside // `{vip,admin}`, and the dotless `admin}` fragment is then dropped by - // `parseOrString` — the same silent condition loss, moved one operand along. + // `parseOrStringWithSpans` — the same silent condition loss, moved one + // operand along. // A structural brace opens a group or an operand; anywhere else it is data. it('keeps a sibling array literal intact past a stray opening brace', () => { - expect(parseOrString('note.eq.a{b,tags.cs.{vip,admin}')).toEqual([ + expect(parseConditions('note.eq.a{b,tags.cs.{vip,admin}')).toEqual([ { column: 'note', op: 'eq', negate: false, value: 'a{b' }, { column: 'tags', op: 'cs', negate: false, value: '{vip,admin}' }, ]) }) it('keeps an array literal intact when the stray opener follows it', () => { - expect(parseOrString('tags.cs.{vip,admin},note.eq.a{b')).toEqual([ + expect(parseConditions('tags.cs.{vip,admin},note.eq.a{b')).toEqual([ { column: 'tags', op: 'cs', negate: false, value: '{vip,admin}' }, { column: 'note', op: 'eq', negate: false, value: 'a{b' }, ]) }) it('keeps a sibling jsonb literal intact past a stray opening brace', () => { - expect(parseOrString('note.eq.a{b,meta.cs.{"a":1,"b":2}')).toEqual([ + expect(parseConditions('note.eq.a{b,meta.cs.{"a":1,"b":2}')).toEqual([ { column: 'note', op: 'eq', negate: false, value: 'a{b' }, { column: 'meta', op: 'cs', negate: false, value: '{"a":1,"b":2}' }, ]) }) it('keeps a sibling array literal intact past a stray opening paren', () => { - expect(parseOrString('note.eq.a(b,tags.cs.{vip,admin}')).toEqual([ + expect(parseConditions('note.eq.a(b,tags.cs.{vip,admin}')).toEqual([ { column: 'note', op: 'eq', negate: false, value: 'a(b' }, { column: 'tags', op: 'cs', negate: false, value: '{vip,admin}' }, ]) @@ -402,19 +425,244 @@ describe('parseOrString structural characters inside values', () => { // scalar carrying an in-value dot still fools it. The unbalanced-depth // re-split is what recovers this one; both mechanisms are load-bearing. it('recovers a scalar whose brace follows an in-value dot', () => { - expect(parseOrString('x.eq.a.{b,y.eq.1')).toEqual([ + expect(parseConditions('x.eq.a.{b,y.eq.1')).toEqual([ { column: 'x', op: 'eq', negate: false, value: 'a.{b' }, { column: 'y', op: 'eq', negate: false, value: '1' }, ]) }) - it('treats and/or group parens as structure', () => { - expect(parseOrString('and(a.eq.1,b.eq.2),c.eq.3')).toHaveLength(2) - expect(parseOrString('not.and(a.eq.1,b.eq.2),c.eq.3')).toHaveLength(2) - expect(parseOrString('and(a.eq.1,or(b.eq.2,c.eq.3)),d.eq.4')).toHaveLength( - 2, + // A logic group is STRUCTURE, and its body is recursed into: every leaf comes + // back flat, in source order, each carrying the span it occupies in the + // caller's original string. + // + // The alternative — treating `and(a.eq.1,b.eq.2)` as one pseudo-condition on a + // column literally named `and(a` — is the disclosure this PR fixes. Such a + // condition matches no encrypted column, so nothing in the group looked + // encrypted, and the whole `.or()` was forwarded VERBATIM: an encrypted + // column inside the group was compared against a PLAINTEXT operand on the + // wire. Assert the leaves, not just the count, so a regression that flattens + // to the right number of wrong conditions cannot pass. + it('flattens an and() group to its leaf conditions', () => { + expect(parseConditions('and(a.eq.1,b.eq.2),c.eq.3')).toEqual([ + { column: 'a', op: 'eq', negate: false, value: '1' }, + { column: 'b', op: 'eq', negate: false, value: '2' }, + { column: 'c', op: 'eq', negate: false, value: '3' }, + ]) + }) + + it('flattens a not.and() group to its leaf conditions', () => { + // The `not.` belongs to the GROUP, not to any leaf: negation of the group is + // preserved by leaving the original text in place (see + // `substituteOrStringLeaves`), so no leaf comes back with `negate: true`. + expect(parseConditions('not.and(a.eq.1,b.eq.2),c.eq.3')).toEqual([ + { column: 'a', op: 'eq', negate: false, value: '1' }, + { column: 'b', op: 'eq', negate: false, value: '2' }, + { column: 'c', op: 'eq', negate: false, value: '3' }, + ]) + }) + + it('flattens an or() group nested inside an and() group', () => { + expect(parseConditions('and(a.eq.1,or(b.eq.2,c.eq.3)),d.eq.4')).toEqual([ + { column: 'a', op: 'eq', negate: false, value: '1' }, + { column: 'b', op: 'eq', negate: false, value: '2' }, + { column: 'c', op: 'eq', negate: false, value: '3' }, + { column: 'd', op: 'eq', negate: false, value: '4' }, + ]) + }) +}) + +// --------------------------------------------------------------------------- +// Source spans +// +// Flattening a group is only half the fix: the leaves come back detached from +// their nesting, so the ONLY thing that can put an encrypted operand back where +// it belongs is `sourceSpan`. Every span is an offset into the caller's +// ORIGINAL string, across group recursion, containment literals whose commas +// and braces are not delimiters, and whitespace the parser trims. +// +// A span that is off by even one character does not throw — it splices +// ciphertext into the middle of a neighbouring condition, producing an or-string +// PostgREST either rejects or, worse, silently reads as a different filter. +// --------------------------------------------------------------------------- + +/** + * Every leaf's span resolved back through `input.slice(start, end)` — the + * property that actually matters, since that is exactly the slice + * {@link substituteOrStringLeaves} overwrites. + */ +function spanTexts(orString: string): string[] { + return parseOrStringWithSpans(orString).map((condition) => { + const span = condition.sourceSpan + if (!span) { + throw new Error(`leaf ${condition.column}.${condition.op} has no span`) + } + return orString.slice(span.start, span.end) + }) +} + +/** The raw spans, for the assertions that pin exact offsets. */ +function spans(orString: string) { + return parseOrStringWithSpans(orString).map((c) => c.sourceSpan) +} + +describe('parseOrStringWithSpans source spans', () => { + it('spans each top-level leaf, and nothing of the delimiter', () => { + const input = 'email.eq.ada,note.eq.x' + expect(spans(input)).toEqual([ + { start: 0, end: 12 }, + { start: 13, end: 22 }, + ]) + expect(spanTexts(input)).toEqual(['email.eq.ada', 'note.eq.x']) + }) + + it('spans a leaf one group deep against the ORIGINAL string', () => { + // The recursion re-parses the group BODY, whose own offsets start at zero. + // The `open + 1` base offset is what translates them back into the original + // string; without it every inner span is short by the group's opener. + const input = 'and(a.eq.1,b.eq.2),c.eq.3' + expect(spans(input)).toEqual([ + { start: 4, end: 10 }, + { start: 11, end: 17 }, + { start: 19, end: 25 }, + ]) + expect(spanTexts(input)).toEqual(['a.eq.1', 'b.eq.2', 'c.eq.3']) + }) + + it('spans a leaf two groups deep, accumulating both base offsets', () => { + const input = 'and(a.eq.1,and(b.eq.2,or(c.eq.3,d.eq.4)))' + expect(spans(input)).toEqual([ + { start: 4, end: 10 }, + { start: 15, end: 21 }, + { start: 25, end: 31 }, + { start: 32, end: 38 }, + ]) + expect(spanTexts(input)).toEqual(['a.eq.1', 'b.eq.2', 'c.eq.3', 'd.eq.4']) + }) + + it('spans a leaf inside not.and(), counting the prefix', () => { + // The group regex matches `not.and(` as well as `and(`, so the opener is at + // index 7, not 3. Measuring from a hard-coded `and(` length would shift + // every leaf in a negated group by four characters. + const input = 'not.and(email.eq.ada,note.eq.x)' + expect(spans(input)).toEqual([ + { start: 8, end: 20 }, + { start: 21, end: 30 }, + ]) + expect(spanTexts(input)).toEqual(['email.eq.ada', 'note.eq.x']) + }) + + it('spans a leaf that follows an array containment literal', () => { + // `{vip,admin}` holds a comma that is NOT a delimiter. Splitting on it puts + // the following leaf's span inside the literal, so substitution would + // overwrite part of `{vip,admin}` rather than replace `email.eq.ada`. + const input = 'and(note.cs.{vip,admin},email.eq.ada)' + expect(spans(input)).toEqual([ + { start: 4, end: 23 }, + { start: 24, end: 36 }, + ]) + expect(spanTexts(input)).toEqual(['note.cs.{vip,admin}', 'email.eq.ada']) + }) + + it('spans a leaf that follows a jsonb containment literal', () => { + const input = 'and(meta.cs.{"a":1,"b":2},email.eq.ada)' + expect(spans(input)).toEqual([ + { start: 4, end: 25 }, + { start: 26, end: 38 }, + ]) + expect(spanTexts(input)).toEqual(['meta.cs.{"a":1,"b":2}', 'email.eq.ada']) + }) + + it('excludes the whitespace surrounding a leaf from its span', () => { + // The condition is parsed from the TRIMMED token, so the span must start + // after the leading spaces and stop before the trailing ones — otherwise + // substitution eats the caller's formatting, and a trailing-space span on + // the last leaf runs past a shorter replacement. + const input = ' email.eq.ada , note.eq.x ' + expect(spans(input)).toEqual([ + { start: 1, end: 13 }, + { start: 16, end: 25 }, + ]) + expect(spanTexts(input)).toEqual(['email.eq.ada', 'note.eq.x']) + }) + + it('gives two identical leaves distinct spans', () => { + // The token search runs from a moving cursor, not from index 0. Restarting + // it would give the second occurrence the first one's span, so both + // substitutions would rewrite the first leaf and the second would keep its + // plaintext operand. + const input = 'email.eq.ada,email.eq.ada' + expect(spans(input)).toEqual([ + { start: 0, end: 12 }, + { start: 13, end: 25 }, + ]) + expect(spanTexts(input)).toEqual(['email.eq.ada', 'email.eq.ada']) + }) +}) + +// --------------------------------------------------------------------------- +// substituteOrStringLeaves +// +// Replacements run RIGHT TO LEFT. Every span is an offset into the original +// string, so the moment one splice changes the string's length — and an +// encrypted operand is always far longer than the plaintext it replaces — +// every span to its right is stale. A left-to-right pass therefore splices the +// second replacement INSIDE the first one's ciphertext. +// --------------------------------------------------------------------------- + +describe('substituteOrStringLeaves', () => { + /** Re-value the parsed leaves, then splice. `map` keeps each leaf's span. */ + function substitute( + input: string, + revalue: (column: string, value: unknown) => unknown, + shouldReplace: (column: string) => boolean, + ) { + const conditions = parseOrStringWithSpans(input).map((c) => ({ + ...c, + value: revalue(c.column, c.value), + })) as DbPendingOrCondition[] + return substituteOrStringLeaves(input, conditions, (c) => + shouldReplace(c.column), + ) + } + + it('replaces leaves at two nesting depths, longest-first, in place', () => { + // Both replacements are LONGER than the operands they displace, and the + // top-level leaf sits at a higher offset than the grouped one — so a + // left-to-right pass, working from spans the first splice already + // invalidated, would write `CT-FOR-BOB…` into the middle of `CT-FOR-ADA…`. + // Distinct replacement values, so a splice landing on the wrong leaf shows + // up rather than cancelling out. + const input = 'and(email.eq.ada,note.eq.x),email.eq.bob' + expect( + substitute( + input, + (_column, value) => + value === 'ada' + ? 'CT-FOR-ADA-XXXXXXXXXXXXXXXXXXXX' + : value === 'bob' + ? 'CT-FOR-BOB-YYYYYYYYYYYYYYYYYYYY' + : value, + (column) => column === 'email', + ), + ).toBe( + 'and(email.eq.CT-FOR-ADA-XXXXXXXXXXXXXXXXXXXX,note.eq.x),email.eq.CT-FOR-BOB-YYYYYYYYYYYYYYYYYYYY', ) }) + + it('replaces leaves across three depths and leaves the rest byte-for-byte', () => { + // Depths 1, 2 and 0 in one expression, with the depth-2 `b` leaf skipped: + // the group syntax, the untouched leaf, and the delimiters must all survive + // verbatim — that byte-for-byte survival is the whole reason the adapter + // splices rather than rebuilding the expression from the flat leaves. + const input = 'and(a.eq.1,or(b.eq.2,c.eq.3)),d.eq.4' + expect( + substitute( + input, + (column, value) => (column === 'b' ? value : `${column}`.repeat(10)), + (column) => column !== 'b', + ), + ).toBe('and(a.eq.aaaaaaaaaa,or(b.eq.2,c.eq.cccccccccc)),d.eq.dddddddddd') + }) }) // An `in`-list element is quoted exactly like any other operand, so the list must @@ -422,26 +670,28 @@ describe('parseOrString structural characters inside values', () => { // string on every comma tore `("a,b",c)` into three fragments and left the quotes // embedded in them — on an encrypted column each fragment is encrypted as its own // term, so the intended element never matches. -describe('parseOrString in-list elements', () => { +describe('parseOrStringWithSpans in-list elements', () => { it('does not split on a comma inside a quoted element', () => { - expect(parseOrString('email.in.("a,b",c)')).toEqual([ + expect(parseConditions('email.in.("a,b",c)')).toEqual([ { column: 'email', op: 'in', negate: false, value: ['a,b', 'c'] }, ]) }) it('unescapes a quoted element', () => { - expect(parseOrString('a.in.("x\\"y",z)')).toEqual([ + expect(parseConditions('a.in.("x\\"y",z)')).toEqual([ { column: 'a', op: 'in', negate: false, value: ['x"y', 'z'] }, ]) }) it('round-trips a comma-bearing element through rebuild', () => { const s = 'name.in.("Doe, John",Smith)' - expect(rebuildOrString(parseOrString(s) as DbPendingOrCondition[])).toBe(s) + expect( + rebuildOrString(parseOrStringWithSpans(s) as DbPendingOrCondition[]), + ).toBe(s) }) it('round-trips an encrypted envelope element', () => { - const parsed = parseOrString( + const parsed = parseConditions( rebuildOrString([cond('email', 'in', [ENVELOPE, 'x'])]), ) expect(parsed).toEqual([ @@ -450,7 +700,7 @@ describe('parseOrString in-list elements', () => { }) it('splits a negated list on top-level commas only', () => { - expect(parseOrString('email.not.in.("a,b",c)')).toEqual([ + expect(parseConditions('email.not.in.("a,b",c)')).toEqual([ { column: 'email', op: 'in', negate: true, value: ['a,b', 'c'] }, ]) }) @@ -460,7 +710,7 @@ describe('parseOrString in-list elements', () => { // `(`: parsed as an array, an encrypted `eq` operand is encrypted as a JS array // rather than the intended string, and the filter matches nothing. it('does not read a parenthesized scalar as a list for a scalar operator', () => { - expect(parseOrString('email.eq.(foo)')).toEqual([ + expect(parseConditions('email.eq.(foo)')).toEqual([ { column: 'email', op: 'eq', negate: false, value: '(foo)' }, ]) }) @@ -477,10 +727,12 @@ describe('parseOrString in-list elements', () => { 'adj', ])('round-trips a paren-delimited %s operand', (op) => { const s = `period.${op}.(1,10)` - expect(parseOrString(s)).toEqual([ + expect(parseConditions(s)).toEqual([ { column: 'period', op, negate: false, value: ['1', '10'] }, ]) - expect(rebuildOrString(parseOrString(s) as DbPendingOrCondition[])).toBe(s) + expect( + rebuildOrString(parseOrStringWithSpans(s) as DbPendingOrCondition[]), + ).toBe(s) }) }) @@ -507,36 +759,36 @@ describe('rebuildOrString reserved words', () => { }) }) -describe('parseOrString negation', () => { +describe('parseOrStringWithSpans negation', () => { it('lifts a not. prefix off the operator', () => { - expect(parseOrString('nickname.not.eq.ada')).toEqual([ + expect(parseConditions('nickname.not.eq.ada')).toEqual([ { column: 'nickname', op: 'eq', negate: true, value: 'ada' }, ]) }) it('parses a negated in-list as a real list, not a literal string', () => { - expect(parseOrString('nickname.not.in.(ada,grace)')).toEqual([ + expect(parseConditions('nickname.not.in.(ada,grace)')).toEqual([ { column: 'nickname', op: 'in', negate: true, value: ['ada', 'grace'] }, ]) }) it('parses not.is.null', () => { - expect(parseOrString('email.not.is.null')).toEqual([ + expect(parseConditions('email.not.is.null')).toEqual([ { column: 'email', op: 'is', negate: true, value: null }, ]) }) it('leaves a non-negated condition unnegated', () => { - expect(parseOrString('nickname.in.(ada,grace)')).toEqual([ + expect(parseConditions('nickname.in.(ada,grace)')).toEqual([ { column: 'nickname', op: 'in', negate: false, value: ['ada', 'grace'] }, ]) }) it('does not mistake a column or value named "not" for the prefix', () => { - expect(parseOrString('not.eq.ada')).toEqual([ + expect(parseConditions('not.eq.ada')).toEqual([ { column: 'not', op: 'eq', negate: false, value: 'ada' }, ]) - expect(parseOrString('nickname.eq.not')).toEqual([ + expect(parseConditions('nickname.eq.not')).toEqual([ { column: 'nickname', op: 'eq', negate: false, value: 'not' }, ]) }) @@ -545,12 +797,12 @@ describe('parseOrString negation', () => { // `col.not.` is malformed PostgREST. Consuming the prefix would leave // no operator, and the condition would be silently DROPPED from the or-string // — quietly widening the result set. Pass it through so PostgREST rejects it. - expect(parseOrString('nickname.not.ada')).toEqual([ + expect(parseConditions('nickname.not.ada')).toEqual([ { column: 'nickname', op: 'not', negate: false, value: 'ada' }, ]) expect( rebuildOrString( - parseOrString('nickname.not.ada') as DbPendingOrCondition[], + parseOrStringWithSpans('nickname.not.ada') as DbPendingOrCondition[], ), ).toBe('nickname.not.ada') }) @@ -564,7 +816,7 @@ describe('rebuildOrString negation', () => { }) it('round-trips a negated in-list through parse → rebuild', () => { - const parsed = parseOrString('nickname.not.in.(ada,grace)') + const parsed = parseOrStringWithSpans('nickname.not.in.(ada,grace)') expect(rebuildOrString(parsed as DbPendingOrCondition[])).toBe( 'nickname.not.in.(ada,grace)', ) diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index ce9835a2f..0ee07e9d5 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -223,6 +223,18 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(supabase.callsFor('contains')).toHaveLength(0) }) + it('rejects nullish matches operands before they can reach the wire', () => { + const { es, supabase } = v3Instance() + + expect(() => + es.from('users', users).select('id').matches('email', undefined), + ).toThrow(/requires a non-null search term/) + expect(() => + es.from('users', users).select('id').matches('email', null), + ).toThrow(/requires a non-null search term/) + expect(supabase.callsFor('filter')).toHaveLength(0) + }) + it('refuses contains() on an encrypted column, naming matches', async () => { const { es } = v3Instance() @@ -263,6 +275,22 @@ describe('encryptedSupabaseV3 wire encoding', () => { ).toThrow(/cannot honor/) }) + it('treats escaped LIKE wildcards as literal search characters', async () => { + for (const [pattern, needle] of [ + ['%100\\%%', '100%'], + ['%under\\_score%', 'under_score'], + ] as const) { + const { es, supabase } = v3Instance() + + await es.from('users', users).select('id').like('email', pattern) + + const envelope = JSON.parse( + supabase.callsFor('filter')[0].args[2] as string, + ) + expect(envelope.pt).toBe(needle) + } + }) + it('passes contains through to native cs on a plaintext column', async () => { const { es, supabase } = v3Instance() @@ -512,11 +540,106 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(emitted.endsWith(',note.eq.x')).toBe(true) }) - // An all-plaintext or() string is forwarded verbatim, so its containment - // literal is never parsed. Naming an encrypted column forces the rebuild - // path — and there the literal's own commas must not be mistaken for - // condition separators, or `note` is filtered on the truncated `{vip`. - it('preserves a plaintext containment literal when rebuilding a mixed or() string', async () => { + it('preserves nested PostgREST logic while replacing encrypted leaves', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or('and(createdAt.gte.2026-01-01,note.eq.x),id.eq.1', { + referencedTable: 'profiles', + }) + + const [or] = supabase.callsFor('or') + const emitted = or.args[0] as string + expect(emitted).toMatch(/^and\(created_at\.gte\."/) + expect(emitted).toContain(',note.eq.x),id.eq.1') + expect(or.args[1]).toEqual({ referencedTable: 'profiles' }) + }) + + // `substituteOrStringLeaves` splices each encrypted leaf back into the + // caller's original string by source span, and sorts the replacements + // RIGHT-TO-LEFT so an earlier span stays valid after a later one grew — every + // v3 envelope is far longer than the plaintext operand it replaces. Every + // other string-form or() test carries exactly ONE encrypted leaf, where the + // sort direction cannot matter; with two, a left-to-right pass splices the + // second replacement at an offset that now lands INSIDE the first envelope. + it('substitutes every encrypted leaf of a multi-leaf or() string', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or('email.eq.ada,createdAt.gte.2026-01-01') + + const emitted = supabase.callsFor('or')[0].args[0] as string + + // Neither operand may reach PostgREST as plaintext. + expect(emitted).not.toContain('email.eq.ada') + expect(emitted).not.toContain('.gte.2026-01-01') + + // The two conditions are still separated by exactly one top-level comma, + // and the second still carries its DB column name. Split on the delimiter + // rather than on `,`: every quote inside an envelope is backslash-escaped, + // so this boundary occurs once. + const boundary = emitted.indexOf('",created_at.gte."') + expect(boundary).toBeGreaterThan(0) + const first = emitted.slice(0, boundary + 1) + const second = emitted.slice(boundary + 2) + + expect(first).toMatch(/^email\.eq\."/) + expect(second).toMatch(/^created_at\.gte\."/) + expect(JSON.parse(orOperand(first, 'email.eq.')).pt).toBe('ada') + expect(JSON.parse(orOperand(second, 'created_at.gte.')).pt).toBe( + '2026-01-01', + ) + }) + + // `parseOrStringAt`'s group regex admits a `not.` prefix + // (`/^(?:not\.)?(?:and|or)\(…\)$/`). Without it, `not.and(…)` is not a group: + // the leaf parser splits it at the first dot into the pseudo-column `not`, + // which matches no encrypted column — so the whole or-string takes the + // verbatim branch and the encrypted operand goes to the database in the clear. + it('encrypts a leaf nested inside a not.and() group', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or('not.and(email.eq.ada,note.eq.x),id.eq.1') + + const emitted = supabase.callsFor('or')[0].args[0] as string + + // The `not.and(` wrapper and both plaintext siblings survive byte-for-byte. + expect(emitted).toMatch(/^not\.and\(email\.eq\."/) + expect(emitted.endsWith(',note.eq.x),id.eq.1')).toBe(true) + expect(emitted).not.toContain('email.eq.ada') + + const operand = emitted.slice( + 'not.and('.length, + emitted.length - ',note.eq.x),id.eq.1'.length, + ) + expect(JSON.parse(orOperand(operand, 'email.eq.')).pt).toBe('ada') + }) + + it('preserves referencedTable for structured or() filters', async () => { + const { es, supabase } = v3Instance() + + await es + .from('users', users) + .select('id') + .or([{ column: 'nickname', op: 'eq', value: 'ada' }], { + foreignTable: 'profiles', + }) + + expect(supabase.callsFor('or')[0].args[1]).toEqual({ + referencedTable: 'profiles', + }) + }) + + // Encrypted leaves are substituted in place, so a plaintext containment + // sibling remains byte-for-byte identical even though its comma is nested. + it('preserves a plaintext containment literal in a mixed or() string', async () => { const { es, supabase } = v3Instance() await es @@ -526,7 +649,7 @@ describe('encryptedSupabaseV3 wire encoding', () => { const emitted = supabase.callsFor('or')[0].args[0] as string expect(emitted).toMatch(/^email\.eq\./) - expect(emitted.endsWith(',note.cs."{vip,admin}"')).toBe(true) + expect(emitted.endsWith(',note.cs.{vip,admin}')).toBe(true) }) it('keeps every filter array correlated in a combined query', async () => { @@ -852,6 +975,19 @@ describe('encryptedSupabaseV3 wire encoding', () => { ) }) + it('rejects a nullish encrypted search operand in structured or()', async () => { + const { es, supabase } = v3Instance() + + const { error, status } = await es + .from('users', users) + .select('id') + .or([{ column: 'email', op: 'matches', value: undefined }]) + + expect(status).toBe(500) + expect(error?.message).toMatch(/requires a non-null operand/) + expect(supabase.callsFor('or')).toHaveLength(0) + }) + // The operator token depends on the OPERATOR, never on whether the operand // was encrypted. `note` is a plaintext passthrough, so nothing encrypts and // the rewrite used to be skipped, emitting `note.contains.plain` — which @@ -897,6 +1033,15 @@ describe('encryptedSupabaseV3 wire encoding', () => { }) describe('update / delete / single / maybeSingle', () => { + it('rejects csv() before serialized ciphertext can enter model decryption', () => { + const { es, supabase } = v3Instance() + + expect(() => es.from('users', users).select('id, email').csv()).toThrow( + /serializes rows before the adapter can decrypt/, + ) + expect(supabase.callsFor('csv')).toHaveLength(0) + }) + it('updates with raw envelopes keyed by DB column name', async () => { const { es, supabase } = v3Instance() @@ -1000,6 +1145,87 @@ describe('encryptedSupabaseV3 wire encoding', () => { ) }) + it('leaves an invalid date value untouched', async () => { + const { es } = v3Instance([{ id: 1, createdAt: 'not-a-date' }]) + + const { data } = await es.from('users', users).select('id, createdAt') + + expect(data?.[0].createdAt).toBe('not-a-date') + }) + + it('reconstructs a nested dotted date path', async () => { + const nestedTable = encryptedTable('profiles', { + 'profile.createdAt': types.Timestamp('profile_created_at'), + }) + const supabase = createMockSupabase([ + { profile: { createdAt: '2026-01-02T03:04:05.000Z' } }, + ]) + const builder = new EncryptedQueryBuilderV3Impl( + 'profiles', + nestedTable, + createMockEncryptionClient(), + supabase.client, + ['profile_created_at'], + ) + + const { data } = await builder.select('profile.createdAt') + + if (!data) throw new Error('Expected a decrypted row') + const profile = (data[0] as Record).profile as Record< + string, + unknown + > + expect(profile.createdAt).toBeInstanceOf(Date) + }) + + // Two dotted paths under the SAME prefix. `postprocessDecryptedRow` now + // batches every dotted key into one `reconstructDatePaths` call instead of + // one call per key; within a single call the second path must clone the + // `profile` object the first already rebuilt, or one of the two Dates is + // silently thrown away along with any plaintext sibling. + it('reconstructs two dotted date paths sharing a prefix', async () => { + const nestedTable = encryptedTable('profiles', { + 'profile.createdAt': types.Timestamp('profile_created_at'), + 'profile.updatedAt': types.Timestamp('profile_updated_at'), + }) + const supabase = createMockSupabase([ + { + profile: { + createdAt: '2026-01-02T03:04:05.000Z', + updatedAt: '2026-03-04T05:06:07.000Z', + nickname: 'ada', + }, + }, + ]) + const builder = new EncryptedQueryBuilderV3Impl( + 'profiles', + nestedTable, + createMockEncryptionClient(), + supabase.client, + ['profile_created_at', 'profile_updated_at'], + ) + + const { data } = await builder.select( + 'profile.createdAt, profile.updatedAt', + ) + + if (!data) throw new Error('Expected a decrypted row') + const profile = (data[0] as Record).profile as Record< + string, + unknown + > + expect(profile.createdAt).toBeInstanceOf(Date) + expect((profile.createdAt as Date).toISOString()).toBe( + '2026-01-02T03:04:05.000Z', + ) + expect(profile.updatedAt).toBeInstanceOf(Date) + expect((profile.updatedAt as Date).toISOString()).toBe( + '2026-03-04T05:06:07.000Z', + ) + // The plaintext sibling rides along in the same nested object. + expect(profile.nickname).toBe('ada') + }) + // Selecting by raw DB name means the row comes back keyed `created_at`, // the only way to reach the `dbName` half of the two-key branch. It also // exercises the `value == null` skip on the absent `createdAt` key. diff --git a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts index 93bab05f9..966d5366e 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts @@ -79,6 +79,33 @@ describe('encryptedSupabaseV3 factory', () => { expect(createClientMock).not.toHaveBeenCalled() }) + it('diagnoses the removed v2 object call shape before introspection', async () => { + await expect( + encryptedSupabaseV3( + { + encryptionClient: {}, + supabaseClient: fakeClient, + } as unknown as SupabaseClientLike, + { databaseUrl: 'postgres://x' }, + ), + ).rejects.toThrow(/removed EQL v2 API.*Pass the Supabase client directly/) + expect(introspectMock).not.toHaveBeenCalled() + }) + + it('rejects an invalid supplied client before introspection', async () => { + await expect( + encryptedSupabaseV3({} as SupabaseClientLike, { + databaseUrl: 'postgres://x', + }), + ).rejects.toThrow(/Supabase client with a from\(\) method/) + await expect( + encryptedSupabaseV3(null as unknown as SupabaseClientLike, { + databaseUrl: 'postgres://x', + }), + ).rejects.toThrow(/Supabase client with a from\(\) method/) + expect(introspectMock).not.toHaveBeenCalled() + }) + it('falls back to process.env.DATABASE_URL', async () => { process.env.DATABASE_URL = 'postgres://env' await encryptedSupabaseV3(fakeClient) diff --git a/packages/stack-supabase/src/helpers.ts b/packages/stack-supabase/src/helpers.ts index 23168ffe0..8f3a55240 100644 --- a/packages/stack-supabase/src/helpers.ts +++ b/packages/stack-supabase/src/helpers.ts @@ -195,10 +195,11 @@ export function mapFilterOpToQueryType(op: FilterOp): QueryTypeName { } /** - * Parse a Supabase `.or()` filter string into structured conditions. + * Parse a Supabase `.or()` filter string into structured conditions, flattening + * nested `and(...)` / `or(...)` groups and recording every leaf's source span. * * Input: `'email.eq.john@example.com,name.ilike.%john%'` - * Output: `[{ column: 'email', op: 'eq', negate: false, value: 'john@example.com' }, …]` + * Output: `[{ column: 'email', op: 'eq', negate: false, value: 'john@example.com', sourceSpan: … }, …]` * * PostgREST spells negation `column.not..`. It is lifted onto its own * `negate` flag rather than left as the operator: the term collector keys the @@ -207,15 +208,40 @@ export function mapFilterOpToQueryType(op: FilterOp): QueryTypeName { * string `in.(a,b)` as a single plaintext — a filter that silently matched * nothing. Only a `not` in the OPERATOR position is a prefix; a column or value * of that name is untouched. + * + * The spans are what let {@link substituteOrStringLeaves} rewrite a leaf in + * place, so groups survive byte-for-byte instead of being rebuilt (and + * destroyed) from the flat condition array. */ -export function parseOrString(orString: string): PendingOrCondition[] { +export function parseOrStringWithSpans(orString: string): PendingOrCondition[] { + return parseOrStringAt(orString, 0) +} + +/** Recursively flatten PostgREST logic groups while retaining each leaf's + * exact location in the caller's original expression. `baseOffset` translates + * the group body's own coordinates back into that original string. */ +function parseOrStringAt( + orString: string, + baseOffset: number, +): PendingOrCondition[] { const conditions: PendingOrCondition[] = [] - // Split on commas that are not inside parentheses (nested or/and) const parts = splitTopLevel(orString) + let cursor = 0 for (const part of parts) { + const partStart = orString.indexOf(part, cursor) + cursor = partStart + part.length const trimmed = part.trim() if (!trimmed) continue + const leading = part.length - part.trimStart().length + const sourceStart = baseOffset + partStart + leading + + const group = /^(?:not\.)?(?:and|or)\(([\s\S]*)\)$/.exec(trimmed) + if (group) { + const open = trimmed.indexOf('(') + conditions.push(...parseOrStringAt(group[1], sourceStart + open + 1)) + continue + } // Format: column.op.value — or column.not.op.value const firstDot = trimmed.indexOf('.') @@ -245,7 +271,13 @@ export function parseOrString(orString: string): PendingOrCondition[] { // Handle special value formats const parsedValue = parseOrValue(value, op) - conditions.push({ column, op, negate, value: parsedValue }) + conditions.push({ + column, + op, + negate, + value: parsedValue, + sourceSpan: { start: sourceStart, end: sourceStart + trimmed.length }, + }) } return conditions @@ -297,6 +329,35 @@ export function rebuildOrString( .join(',') as DbFilterString } +/** + * Rebuild only encrypted leaves of a string-form `.or()` expression. + * + * The original delimiters, whitespace, nesting, and plaintext conditions are + * retained byte-for-byte. Replacements run from right to left so source spans + * remain stable when ciphertext is longer than the plaintext operand. + */ +export function substituteOrStringLeaves( + original: string, + conditions: DbPendingOrCondition[], + shouldReplace: (condition: DbPendingOrCondition) => boolean, +): DbFilterString { + let result = original + const replacements = conditions + .filter((condition) => condition.sourceSpan && shouldReplace(condition)) + .sort( + (left, right) => + (right.sourceSpan?.start ?? 0) - (left.sourceSpan?.start ?? 0), + ) + + for (const condition of replacements) { + const span = condition.sourceSpan + if (!span) continue + const replacement = rebuildOrString([condition]) + result = result.slice(0, span.start) + replacement + result.slice(span.end) + } + return result as DbFilterString +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -331,12 +392,12 @@ const OR_GROUP_TOKEN = /^(?:not\.)?(?:and|or)$/ * opener. `depth !== 0` at the end proves the counting was fooled, so the pass * is discarded and the input re-split honouring quotes alone — a backstop, not * the primary mechanism. It must stay narrow: applied to an input whose braces - * WERE structure, it re-splits inside `{vip,admin}` and `parseOrString` then + * WERE structure, it re-splits inside `{vip,admin}` and `parseOrStringAt` then * drops the dotless `admin}` fragment. * * `trackDepth` is the recursion's own flag, never passed by callers. NEVER - * throws — `query-builder.ts` relies on `parseOrString` being total so that - * capability errors surface in filter order. + * throws — `query-builder.ts` relies on `parseOrStringWithSpans` being total so + * that capability errors surface in filter order. */ function splitTopLevel(input: string, trackDepth = true): string[] { const parts: string[] = [] diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index a05c7b81f..aae094425 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -141,6 +141,21 @@ export async function encryptedSupabase( } supabaseClient = createClient(url, key) as unknown as SupabaseClientLike } else { + if (clientOrUrl === null || typeof clientOrUrl !== 'object') { + throw new Error( + '[supabase v3]: encryptedSupabase expected a Supabase client with a from() method. Pass encryptedSupabase(supabaseClient, options), or use encryptedSupabase(url, key, options).', + ) + } + if ('encryptionClient' in clientOrUrl && 'supabaseClient' in clientOrUrl) { + throw new Error( + '[supabase v3]: encryptedSupabase({ encryptionClient, supabaseClient }) was the removed EQL v2 API. Pass the Supabase client directly instead: encryptedSupabase(supabaseClient, { databaseUrl, schemas? }).', + ) + } + if (typeof clientOrUrl.from !== 'function') { + throw new Error( + '[supabase v3]: encryptedSupabase expected a Supabase client with a from() method. Pass encryptedSupabase(supabaseClient, options), or use encryptedSupabase(url, key, options).', + ) + } supabaseClient = clientOrUrl options = (keyOrOptions as EncryptedSupabaseOptions) ?? {} diff --git a/packages/stack-supabase/src/like-pattern.ts b/packages/stack-supabase/src/like-pattern.ts new file mode 100644 index 000000000..9853a71cf --- /dev/null +++ b/packages/stack-supabase/src/like-pattern.ts @@ -0,0 +1,44 @@ +type LikeToken = { value: string; wildcard: boolean } + +export type LikeNeedle = { + needle: string + hasUnsupportedWildcard: boolean +} + +/** + * Reduce a SQL LIKE pattern to the literal needle used by encrypted fuzzy + * matching. Only unescaped leading/trailing `%` tokens are approximable; + * escaped metacharacters remain literal and every other wildcard is reported. + * + * A trailing lone backslash (which Postgres itself rejects, "LIKE pattern must + * not end with escape character") is deliberately kept as a literal backslash + * rather than throwing: the needle is only an approximation feeding encrypted + * fuzzy matching, and the plaintext `like` path never reaches here. + */ +export function parseLikeNeedle(pattern: string): LikeNeedle { + const tokens: LikeToken[] = [] + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i] + if (char === '\\' && i + 1 < pattern.length) { + tokens.push({ value: pattern[++i], wildcard: false }) + } else { + tokens.push({ + value: char, + wildcard: char === '%' || char === '_', + }) + } + } + + while (tokens[0]?.wildcard && tokens[0].value === '%') tokens.shift() + while ( + tokens[tokens.length - 1]?.wildcard && + tokens[tokens.length - 1]?.value === '%' + ) { + tokens.pop() + } + + return { + needle: tokens.map((token) => token.value).join(''), + hasUnsupportedWildcard: tokens.some((token) => token.wildcard), + } +} diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index d7356a1a5..7bfbeaa57 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -10,6 +10,7 @@ import type { AnyV3Table } from '@cipherstash/stack/eql/v3' import type { LockContextInput } from '@cipherstash/stack/identity' import { ColumnMap } from './column-map' import { addJsonbCastsV3 } from './helpers' +import { parseLikeNeedle } from './like-pattern' import { toDbSpace } from './query-dbspace' import { assertJsonContainmentOperand, @@ -256,9 +257,10 @@ export class EncryptedQueryBuilderImpl< * delegated to `matches`. EQL v3 free-text search is fuzzy bloom token * matching, not SQL pattern matching, so the result is APPROXIMATE — matching * is case-insensitive and one-sided (may false-positive), and anchoring is - * lost. Leading/trailing `%` are stripped; an internal `%` or any `_` cannot be - * approximated by trigram matching and throws. A plaintext column keeps real - * SQL LIKE. + * lost. Unescaped leading/trailing `%` are stripped; an unescaped internal `%` + * or any unescaped `_` cannot be approximated by trigram matching and throws. + * Escaped metacharacters (`\%`, `\_`) are literal search characters. A + * plaintext column keeps real SQL LIKE. */ like(column: string, pattern: string): this { if (!this.columns.isEncryptedV3Column(column)) { @@ -313,6 +315,11 @@ export class EncryptedQueryBuilderImpl< * silently accept as containment of the raw string. */ matches(column: string, value: unknown): this { + if (value == null) { + throw new Error( + `[supabase v3]: matches("${column}", …) requires a non-null search term; null and undefined cannot be encrypted and would otherwise reach PostgREST as plaintext.`, + ) + } if (this.columns.isSearchableJsonColumn(column)) { throw new Error( `[supabase v3]: matches() is encrypted free-text search and does not apply to encrypted JSON column "${column}". Use contains("${column}", subDocument) or selectorEq("${column}", path, value).`, @@ -391,6 +398,7 @@ export class EncryptedQueryBuilderImpl< this.orFilters.push({ kind: 'structured', conditions: filtersOrConditions, + referencedTable: options?.referencedTable ?? options?.foreignTable, }) } return this @@ -486,8 +494,9 @@ export class EncryptedQueryBuilderImpl< } csv(): this { - this.transforms.push({ kind: 'csv' }) - return this + throw new Error( + '[supabase v3]: csv() is unavailable on encrypted queries because PostgREST serializes rows before the adapter can decrypt them. Select rows normally, then serialize the decrypted data to CSV.', + ) } abortSignal(signal: AbortSignal): this { @@ -719,9 +728,6 @@ export class EncryptedQueryBuilderImpl< case 'maybeSingle': query = query.maybeSingle() break - case 'csv': - query = query.csv() - break case 'abortSignal': query = query.abortSignal(t.signal) break @@ -857,14 +863,15 @@ export class EncryptedQueryBuilderImpl< /** * Reduce a SQL LIKE pattern to a fuzzy-match needle, or throw when it cannot be * approximated. Strips surrounding `%` (prefix/suffix wildcards, which fuzzy - * matching subsumes); an internal `%` or any `_` is unapproximable. Warns once - * per (op, column) that the delegation is approximate. + * matching subsumes); an unescaped internal `%` or any unescaped `_` is + * unapproximable. Escaped metacharacters (`\%`, `\_`) are literals and pass + * through. Warns once per (op, column) that the delegation is approximate. */ private likeNeedle(column: string, op: string, pattern: string): string { - const needle = pattern.replace(/^%+/, '').replace(/%+$/, '') - if (needle.includes('%') || pattern.includes('_')) { + const { needle, hasUnsupportedWildcard } = parseLikeNeedle(pattern) + if (hasUnsupportedWildcard) { throw new Error( - `[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an internal "%" or any "_"). Use matches("${column}", term) with a literal search term.`, + `[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an unescaped internal "%" or any unescaped "_"). Use matches("${column}", term) with a literal search term.`, ) } const key = `${op}:${column}` diff --git a/packages/stack-supabase/src/query-dbspace.ts b/packages/stack-supabase/src/query-dbspace.ts index ae68df562..194ba6055 100644 --- a/packages/stack-supabase/src/query-dbspace.ts +++ b/packages/stack-supabase/src/query-dbspace.ts @@ -1,5 +1,5 @@ import type { ColumnMap } from './column-map' -import { parseOrString } from './helpers' +import { parseOrStringWithSpans } from './helpers' import type { DbConflictList, DbMutationOp, @@ -51,11 +51,15 @@ function orFilterToDbSpace( return { kind: 'string', original: of_.value, - conditions: parseOrString(of_.value).map(toDbCondition), + conditions: parseOrStringWithSpans(of_.value).map(toDbCondition), referencedTable: of_.referencedTable, } } - return { kind: 'structured', conditions: of_.conditions.map(toDbCondition) } + return { + kind: 'structured', + conditions: of_.conditions.map(toDbCondition), + referencedTable: of_.referencedTable, + } } function transformToDbSpace(t: TransformOp, columns: ColumnMap): DbTransformOp { @@ -67,7 +71,6 @@ function transformToDbSpace(t: TransformOp, columns: ColumnMap): DbTransformOp { case 'range': case 'single': case 'maybeSingle': - case 'csv': case 'abortSignal': case 'throwOnError': case 'returns': @@ -100,7 +103,8 @@ function mutationToDbSpace(m: MutationOp, columns: ColumnMap): DbMutationOp { * `buildAndExecuteQuery`) consumes only the branded result, so a column can * no longer reach PostgREST untranslated — that is a compile error. * - * Total: `filterColumnName`, `parseOrString`, and `resolveMutationOptions` + * Total: `filterColumnName`, `parseOrStringWithSpans`, and + * `resolveMutationOptions` * never throw, so this introduces no new early-throw point and cannot perturb * the order in which capability errors surface. * diff --git a/packages/stack-supabase/src/query-encrypt.ts b/packages/stack-supabase/src/query-encrypt.ts index ae67e4bfe..6d617ae10 100644 --- a/packages/stack-supabase/src/query-encrypt.ts +++ b/packages/stack-supabase/src/query-encrypt.ts @@ -206,6 +206,24 @@ export function queryTypeForOrOp(op: FilterOp): QueryTypeName { return queryTypeForRawOp(op) } +/** A nullish encrypted search operand is never a SQL-NULL predicate. Skipping + * encryption would put the raw operand on the wire under `cs`, so fail closed + * for every spelling (`matches`, `contains`, raw `cs`, `not`, and `.or()`). */ +function assertEncryptedSearchOperand( + queryType: QueryTypeName, + value: unknown, + column: string, +): void { + if ( + value == null && + (queryType === 'freeTextSearch' || queryType === 'searchableJson') + ) { + throw new Error( + `[supabase v3]: encrypted search on column "${column}" requires a non-null operand; null and undefined cannot be sent through the plaintext PostgREST filter path.`, + ) + } +} + function encryptionFailure( tableName: string, message: string, @@ -287,19 +305,19 @@ export async function encryptFilterValues( const column = tableColumns[f.column] if (!column) continue + const queryType = mapFilterOpToQueryType(f.op) + assertEncryptedSearchOperand(queryType, f.value, f.column) if (f.op === 'in' && Array.isArray(f.value)) { - collectInListTerms( - f.op, - f.value, - column, - mapFilterOpToQueryType(f.op), - (inIndex) => ({ source: 'filter', filterIndex: i, inIndex }), - ) + collectInListTerms(f.op, f.value, column, queryType, (inIndex) => ({ + source: 'filter', + filterIndex: i, + inIndex, + })) } else if (!isEncryptableTerm(f.op, f.value)) { // `is` predicate or null operand — forwarded unencrypted. } else { - pushTerm(f.value as JsPlaintext, column, mapFilterOpToQueryType(f.op), { + pushTerm(f.value as JsPlaintext, column, queryType, { source: 'filter', filterIndex: i, }) @@ -328,9 +346,11 @@ export async function encryptFilterValues( for (let i = 0; i < dbSpace.notFilters.length; i++) { const nf = dbSpace.notFilters[i] if (!isEncryptedColumn(nf.column, encryptedColumnNames)) continue - if (!isEncryptableTerm(nf.op, nf.value)) continue const column = tableColumns[nf.column] if (!column) continue + const queryType = mapFilterOpToQueryType(nf.op) + assertEncryptedSearchOperand(queryType, nf.value, nf.column) + if (!isEncryptableTerm(nf.op, nf.value)) continue if (nf.op === 'in') { // A PostgREST list literal (`'(a,b)'`) cannot be encrypted element-wise, @@ -342,17 +362,15 @@ export async function encryptFilterValues( `not a PostgREST list literal — each element must be encrypted separately`, ) } - collectInListTerms( - nf.op, - nf.value, - column, - mapFilterOpToQueryType(nf.op), - (inIndex) => ({ source: 'not', notIndex: i, inIndex }), - ) + collectInListTerms(nf.op, nf.value, column, queryType, (inIndex) => ({ + source: 'not', + notIndex: i, + inIndex, + })) continue } - pushTerm(nf.value as JsPlaintext, column, mapFilterOpToQueryType(nf.op), { + pushTerm(nf.value as JsPlaintext, column, queryType, { source: 'not', notIndex: i, }) @@ -374,6 +392,7 @@ export async function encryptFilterValues( // `queryTypeForOrOp`, not `mapFilterOpToQueryType`: an or-condition may // carry a raw PostgREST operator (`cs`), which is not a `FilterOp`. const queryType = queryTypeForOrOp(cond.op) + assertEncryptedSearchOperand(queryType, cond.value, cond.column) const mappingFor = (inIndex?: number): TermMapping => ({ source, orIndex: i, @@ -397,6 +416,8 @@ export async function encryptFilterValues( if (!isEncryptedColumn(rf.column, encryptedColumnNames)) continue const column = tableColumns[rf.column] if (!column) continue + const queryType = queryTypeForRawOp(rf.operator) + assertEncryptedSearchOperand(queryType, rf.value, rf.column) if (rf.operator === 'in') { // Same contract as the `not(…, 'in', …)` path: a PostgREST list literal @@ -409,19 +430,17 @@ export async function encryptFilterValues( `not a PostgREST list literal — each element must be encrypted separately`, ) } - collectInListTerms( - 'in', - rf.value, - column, - queryTypeForRawOp(rf.operator), - (inIndex) => ({ source: 'raw', rawIndex: i, inIndex }), - ) + collectInListTerms('in', rf.value, column, queryType, (inIndex) => ({ + source: 'raw', + rawIndex: i, + inIndex, + })) continue } if (!isEncryptableTerm(rf.operator, rf.value)) continue - pushTerm(rf.value as JsPlaintext, column, queryTypeForRawOp(rf.operator), { + pushTerm(rf.value as JsPlaintext, column, queryType, { source: 'raw', rawIndex: i, }) diff --git a/packages/stack-supabase/src/query-filters.ts b/packages/stack-supabase/src/query-filters.ts index fdad1d10a..8eb545e0c 100644 --- a/packages/stack-supabase/src/query-filters.ts +++ b/packages/stack-supabase/src/query-filters.ts @@ -4,6 +4,7 @@ import { formatInListOperand, isEncryptedColumn, rebuildOrString, + substituteOrStringLeaves, } from './helpers' import { assertPostgrestCanQueryEncryptedOperator, @@ -338,14 +339,19 @@ export function applyFilters( ) if (referencesEncrypted) { - q = q.or(rebuildOrString(parsed), { - referencedTable: of_.referencedTable, - }) + q = q.or( + substituteOrStringLeaves(of_.original, parsed, (condition) => + isEncryptedColumn(condition.column, encryptedColumnNames), + ), + { + referencedTable: of_.referencedTable, + }, + ) } else { // Every condition names a plaintext column, whose property name IS // its DB name — nothing to map. Forward the caller's ORIGINAL string // byte-for-byte: relied on for nested `and()` and quoted values that - // `parseOrString`/`rebuildOrString` cannot round-trip. + // `parseOrStringWithSpans`/`rebuildOrString` cannot round-trip. q = q.or(of_.original as DbFilterString, { referencedTable: of_.referencedTable, }) @@ -357,7 +363,9 @@ export function applyFilters( return sub ? { ...cond, value: sub.value } : cond }) - q = q.or(rebuildOrString(conditions)) + q = q.or(rebuildOrString(conditions), { + referencedTable: of_.referencedTable, + }) } } diff --git a/packages/stack-supabase/src/query-results.ts b/packages/stack-supabase/src/query-results.ts index 128c06fca..b8daa6ccd 100644 --- a/packages/stack-supabase/src/query-results.ts +++ b/packages/stack-supabase/src/query-results.ts @@ -1,4 +1,9 @@ -import { DATE_LIKE_CASTS, logger } from '@cipherstash/stack/adapter-kit' +import { + DATE_LIKE_CASTS, + logger, + reconstructDatePaths, + reconstructDateValue, +} from '@cipherstash/stack/adapter-kit' import { selectKeyToDbV3 } from './helpers' import { type EncryptionContext, @@ -59,16 +64,23 @@ function postprocessDecryptedRow( } const out: Record = { ...row } + // Dotted paths are collected and reconstructed in ONE pass: + // `reconstructDatePaths` allocates a fresh shallow row copy per call, so + // calling it per key spent one clone on every nested date column. Batching + // is also what keeps two paths sharing a prefix (`profile.createdAt` / + // `profile.updatedAt`) correct — within a single call the second path clones + // the intermediate the first already rebuilt, rather than the raw source. + const dottedKeys: string[] = [] for (const [key, dbName] of Object.entries(keyToDb)) { const castAs = ctx.columns.schemaFor(dbName)?.cast_as if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue - const value = out[key] - if (value == null || value instanceof Date) continue - if (typeof value === 'string' || typeof value === 'number') { - out[key] = new Date(value) + if (key.includes('.')) { + dottedKeys.push(key) + } else if (Object.hasOwn(out, key)) { + out[key] = reconstructDateValue(out[key]) } } - return out + return dottedKeys.length > 0 ? reconstructDatePaths(out, dottedKeys) : out } /** diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index e19f685e9..e58cf74c9 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -539,7 +539,11 @@ export type PendingFilter = { } export type PendingOrFilter = - | { kind: 'structured'; conditions: PendingOrCondition[] } + | { + kind: 'structured' + conditions: PendingOrCondition[] + referencedTable?: string + } | { kind: 'string'; value: string; referencedTable?: string } export type PendingOrCondition = { @@ -549,6 +553,10 @@ export type PendingOrCondition = { * `in`-list split and the query-type mapping both key on the real operator. */ negate?: boolean value: unknown + /** Character range in the caller's original string-form `.or()` expression. + * Internal only: lets the encrypted adapter replace this leaf without + * rebuilding (and corrupting) surrounding `and(...)` / `or(...)` groups. */ + sourceSpan?: { start: number; end: number } } export type PendingMatchFilter = { @@ -591,7 +599,8 @@ export type TransformOp = } | { kind: 'single' } | { kind: 'maybeSingle' } - | { kind: 'csv' } + // No `csv` member: `csv()` throws rather than recording a transform (see + // {@link EncryptedQueryBuilderCore.csv}), so nothing can ever push one. | { kind: 'abortSignal'; signal: AbortSignal } | { kind: 'throwOnError' } | { kind: 'returns' } @@ -690,11 +699,15 @@ export type DbPendingMatchFilter = { } /** Retains the caller's ORIGINAL text for the verbatim fallback (which must be - * forwarded byte-for-byte — `parseOrString`/`rebuildOrString` do not round-trip - * nested `and()` or quoted values) alongside the parsed DB-space conditions - * used by the encrypt-and-rebuild path. Parsing happens once, here. */ + * forwarded byte-for-byte — `parseOrStringWithSpans`/`rebuildOrString` do not + * round-trip nested `and()` or quoted values) alongside the parsed DB-space + * conditions used by the encrypt-and-rebuild path. Parsing happens once, here. */ export type DbPendingOrFilter = - | { kind: 'structured'; conditions: DbPendingOrCondition[] } + | { + kind: 'structured' + conditions: DbPendingOrCondition[] + referencedTable?: string + } | { kind: 'string' original: string @@ -957,6 +970,16 @@ export interface EncryptedQueryBuilderCore< * error. Same `T | null` awaited shape — `single()` reports the missing row * through `error` instead. */ maybeSingle(): EncryptedSingleQueryBuilder + /** + * Always THROWS. PostgREST serializes rows server-side, so a CSV response + * carries ciphertext the adapter never gets to decrypt. Declared so the call + * is a loud runtime failure rather than a missing method, and typed `Self` + * only to keep the chain shape — it never returns. Select rows normally and + * serialize the decrypted data yourself. + * + * @deprecated Always throws on an encrypted query — select the rows normally, + * then serialize the decrypted data to CSV yourself. + */ csv(): Self abortSignal(signal: AbortSignal): Self throwOnError(): Self diff --git a/packages/stack/__tests__/typed-client-v3.test.ts b/packages/stack/__tests__/typed-client-v3.test.ts index c506d787f..3497021ec 100644 --- a/packages/stack/__tests__/typed-client-v3.test.ts +++ b/packages/stack/__tests__/typed-client-v3.test.ts @@ -78,6 +78,42 @@ describe('typedClient — decrypt reconstruction', () => { expect(data.note).toBeNull() }) + it('leaves invalid date values untouched', async () => { + const client = typedClient(fakeClient({ when: 'not-a-date' }), table) + + const result = await client.decryptModel({}, table) + if (result.failure) return + + expect((result.data as Record).when).toBe('not-a-date') + }) + + it('reconstructs a dotted date path without mutating the decrypted row', async () => { + const nested = encryptedTable('nested', { + 'profile.when': types.Timestamp('profile_when'), + 'profile.birthday': types.Date('profile_birthday'), + }) + const decrypted = { + profile: { + when: '2020-01-02T03:04:05.000Z', + birthday: '1990-04-05', + untouched: true, + }, + } + const client = typedClient(fakeClient(decrypted), nested) + + const result = await client.decryptModel({}, nested) + if (result.failure) return + + const data = result.data as Record + const profile = data.profile as Record + expect(profile.when).toBeInstanceOf(Date) + expect(profile.birthday).toBeInstanceOf(Date) + expect(profile.untouched).toBe(true) + expect(profile).not.toBe(decrypted.profile) + expect(decrypted.profile.when).toBe('2020-01-02T03:04:05.000Z') + expect(decrypted.profile.birthday).toBe('1990-04-05') + }) + it('reconstructs each row for bulkDecryptModels', async () => { const client = typedClient( fakeClient({ when: '2021-06-01T00:00:00.000Z', note: 'x' }), diff --git a/packages/stack/__tests__/wasm-inline-models.test.ts b/packages/stack/__tests__/wasm-inline-models.test.ts index acb2b9fea..d0d5a56bf 100644 --- a/packages/stack/__tests__/wasm-inline-models.test.ts +++ b/packages/stack/__tests__/wasm-inline-models.test.ts @@ -54,6 +54,7 @@ const users = encryptedTable('users', { // model carries `{ profile: { ssn } }`; the walk matches it via the path. const patients = encryptedTable('patients', { 'profile.ssn': types.TextEq('profile.ssn'), + 'profile.seenAt': types.Timestamp('profile.seen_at'), }) async function client() { @@ -217,6 +218,17 @@ describe('WasmEncryptionClient.decryptModel', () => { ) }) + it('rebuilds a date-like value at a dotted model path', async () => { + ffi.decryptBulkFallible.mockResolvedValueOnce([ + { data: '2026-07-22T01:02:03.000Z' }, + ] as never) + + const c = await client() + const out = await c.decryptModel({ profile: { seenAt: ct('d') } }, patients) + + expect(expectData(out).profile.seenAt).toBeInstanceOf(Date) + }) + it('names every failed field by its model path', async () => { ffi.decryptBulkFallible.mockResolvedValueOnce([ { error: 'boom', code: 'CT_ERROR' }, @@ -286,6 +298,26 @@ describe('WasmEncryptionClient.bulkDecryptModels', () => { ]) }) + it('reconstructs valid Dates in bulk and preserves invalid date values', async () => { + ffi.decryptBulkFallible.mockResolvedValueOnce([ + { data: '2026-07-22T01:02:03.000Z' }, + { data: 'not-a-date' }, + ] as never) + + const c = await client() + const out = await c.bulkDecryptModels( + [{ createdOn: ct('a') }, { createdOn: ct('b') }], + users, + ) + + const data = expectData(out) + expect(data[0].createdOn).toBeInstanceOf(Date) + expect((data[0].createdOn as Date).toISOString()).toBe( + '2026-07-22T01:02:03.000Z', + ) + expect(data[1].createdOn).toBe('not-a-date') + }) + it('labels failures with the model index and field path', async () => { ffi.decryptBulkFallible.mockResolvedValueOnce([ { data: 'ok' }, diff --git a/packages/stack/src/adapter-kit.ts b/packages/stack/src/adapter-kit.ts index a46706d8d..7376e9af7 100644 --- a/packages/stack/src/adapter-kit.ts +++ b/packages/stack/src/adapter-kit.ts @@ -33,6 +33,10 @@ export { DATE_LIKE_CASTS, EncryptedV3Column, } from './eql/v3/columns.js' +export { + reconstructDatePaths, + reconstructDateValue, +} from './eql/v3/date-reconstruction.js' // Domain registry: the Supabase adapter classifies introspected columns by their // Postgres domain and rebuilds each column's encryption config from it. export { diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index cc79cb306..6e25b29f0 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -10,6 +10,7 @@ import type { V3ModelInput, } from '@/eql/v3' import { DATE_LIKE_CASTS } from '@/eql/v3/columns' +import { reconstructDatePaths } from '@/eql/v3/date-reconstruction' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import type { LockContextInput } from '@/identity' import type { @@ -217,13 +218,7 @@ function rowReconstructor( .map(([property]) => property) return (row) => { - const out: Record = { ...row } - for (const property of dateProperties) { - const value = out[property] - if (value == null) continue - out[property] = new Date(value as string | number | Date) - } - return out + return reconstructDatePaths(row, dateProperties) } } diff --git a/packages/stack/src/eql/v3/date-reconstruction.ts b/packages/stack/src/eql/v3/date-reconstruction.ts new file mode 100644 index 000000000..9679fd1bf --- /dev/null +++ b/packages/stack/src/eql/v3/date-reconstruction.ts @@ -0,0 +1,66 @@ +/** + * Rebuild a decrypted plaintext for a date-like model field into a `Date`. + * Non-date values and already-constructed Dates pass through untouched. + * + * The `Number.isNaN` guard is the point of the function, not a formality: an + * unparseable stored value — a date column written in a non-ISO format, or + * corrupted — makes `new Date(...)` an Invalid Date. Returning the raw value + * instead means the caller sees what is actually stored, rather than an + * Invalid Date whose later `.toISOString()` throws far from the column that + * produced it (#742 review). + */ +export function reconstructDateValue(value: unknown): unknown { + if ( + value == null || + value instanceof Date || + (typeof value !== 'string' && typeof value !== 'number') + ) { + return value + } + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date +} + +/** Reconstruct date-like values at dotted model paths without mutating the + * decrypted input row. Missing paths and non-object intermediates are ignored. */ +export function reconstructDatePaths( + row: Record, + paths: readonly string[], +): Record { + const out = { ...row } + for (const path of paths) { + const segments = path.split('.') + let source: Record = row + let target: Record = out + let reachable = true + + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i] + const sourceChild = source[segment] + if ( + sourceChild === null || + typeof sourceChild !== 'object' || + Array.isArray(sourceChild) + ) { + reachable = false + break + } + const targetChild = target[segment] + const cloned = { + ...((targetChild !== null && + typeof targetChild === 'object' && + !Array.isArray(targetChild) + ? targetChild + : sourceChild) as Record), + } + target[segment] = cloned + source = sourceChild as Record + target = cloned + } + + const leaf = segments.at(-1) + if (!reachable || !leaf || !Object.hasOwn(source, leaf)) continue + target[leaf] = reconstructDateValue(source[leaf]) + } + return out +} diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 2b2d8dd6d..f3d31e3fe 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -117,6 +117,7 @@ import { type V3ModelInput, } from '@/eql/v3' import { DATE_LIKE_CASTS } from '@/eql/v3/columns' +import { reconstructDateValue } from '@/eql/v3/date-reconstruction' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { type CastAs, @@ -607,19 +608,6 @@ function datePropertyPaths(table: AnyV3Table): Set { return paths } -/** - * Rebuild a decrypted plaintext for a model field into a `Date` when the - * column is date-like. An unparseable stored value (a date column written in a - * non-ISO format, or corrupted) would make `new Date(...)` an Invalid Date; - * return the raw value in that case rather than silently handing back an - * Invalid Date whose later `.toISOString()` throws far from here (#742 review). - */ -function reconstructDate(value: WasmPlaintext, isDateColumn: boolean): unknown { - if (!isDateColumn || value == null) return value - const date = new Date(value as string | number) - return Number.isNaN(date.getTime()) ? value : date -} - /** * Internal token used to gate the {@link WasmEncryptionClient} * constructor. Symbols are unique by reference, so external code can't @@ -1368,7 +1356,9 @@ export class WasmEncryptionClient { setNestedValue( rebuilt, field.fieldKey.split('.'), - reconstructDate(item.data, dateFields.has(field.fieldKey)), + dateFields.has(field.fieldKey) + ? reconstructDateValue(item.data) + : item.data, ) }) return rebuilt diff --git a/packages/wizard/src/lib/install-skills.ts b/packages/wizard/src/lib/install-skills.ts index b3f5d2f96..53a4e06ae 100644 --- a/packages/wizard/src/lib/install-skills.ts +++ b/packages/wizard/src/lib/install-skills.ts @@ -11,7 +11,7 @@ import type { Integration } from './types.js' * skills into the user's project so Claude Code picks them up during * follow-up work. */ -const SKILL_MAP: Record = { +export const SKILL_MAP: Record = { drizzle: ['stash-encryption', 'stash-drizzle', 'stash-indexing', 'stash-cli'], // `stash-postgres` / `stash-edge` mirror the CLI's SKILL_MAP — see the comments // there for why Supabase and the generic (no-ORM) path get them (#754). @@ -23,7 +23,12 @@ const SKILL_MAP: Record = { 'stash-edge', 'stash-cli', ], - prisma: ['stash-encryption', 'stash-indexing', 'stash-cli'], + prisma: [ + 'stash-encryption', + 'stash-prisma-next', + 'stash-indexing', + 'stash-cli', + ], generic: [ 'stash-encryption', 'stash-indexing', @@ -62,7 +67,9 @@ export async function maybeInstallSkills( return { copied: [], failed: [] } } - const available = skills.filter((name) => existsSync(join(bundledRoot, name))) + const available = skills.filter((name) => + existsSync(join(bundledRoot, name, 'SKILL.md')), + ) if (available.length === 0) return { copied: [], failed: [] } const confirmed = await p.confirm({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a666f68e..cb4888fe8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: semver: specifier: ^7.8.0 version: 7.8.5 + typescript: + specifier: catalog:repo + version: 5.9.3 vitest: specifier: catalog:repo version: 3.2.7(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.0)(yaml@2.9.0) diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index d9d03b22e..2d540b68c 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -370,7 +370,7 @@ if (!decrypted.failure) { } ``` -`Date` columns are reconstructed to real `Date` instances on decrypt; `bigint` columns round-trip as native `bigint`. Non-schema fields pass through unchanged. +`Date` columns are reconstructed to real `Date` instances on decrypt; `bigint` columns round-trip as native `bigint`. Non-schema fields pass through unchanged. Drizzle decrypts through the same typed client as every other integration, so the one caveat applies here too: a stored value that does **not** parse as a date is handed back unchanged rather than as an `Invalid Date`, even though the declared type is `Date` — guard with `instanceof Date` before calling `Date` methods on a column whose stored values you don't control. ## Indexing Encrypted Columns diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index e8016b013..3fbb0bd3e 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -391,7 +391,7 @@ if (!enc.failure) { Typed-client model notes: - `decryptModel` / `bulkDecryptModels` take the **table as a second argument** and return a chainable `AuditableDecryptModelOperation` — await it for the `Result`, or chain `.audit({ metadata })` / `.withLockContext(lockContext)` first. A lock context may instead be passed as the optional third argument; use one form or the other, not both (chaining `.withLockContext()` onto a decrypt that already took a positional lock context throws). -- `Date` columns are reconstructed to real `Date` instances on decrypt; `bigint` columns round-trip as native `bigint`. +- `Date` columns are reconstructed to real `Date` instances on decrypt; `bigint` columns round-trip as native `bigint`. A stored value that does **not** parse as a date (a legacy non-ISO string, say) is handed back unchanged rather than as an `Invalid Date`, even though the declared type is `Date` — guard with `instanceof Date` before calling `Date` methods on a column whose stored values you don't control. - Nullable schema fields stay nullable through the round trip. ## Bulk Operations diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index deacdf19c..66eb932d6 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -372,12 +372,20 @@ These are passed through to Supabase directly: .order("email", { ascending: true }) // encrypted columns: see behaviour below .limit(10) .range(0, 9) -.csv() .abortSignal(signal) .throwOnError() .returns() ``` +`csv()` is the exception — it **throws**. PostgREST serializes rows +server-side, so a CSV response would carry ciphertext the wrapper never gets +to decrypt. Select rows normally and serialize the decrypted data yourself: + +```typescript +const { data } = await es.from("users").select("id, email") +const csv = data!.map((r) => `${r.id},${r.email}`).join("\n") +``` + `order()` works on plaintext columns and on OPE-backed encrypted ordering columns — see the `order()` bullet in the next section for exactly which domains qualify.