From e155956d71a04a8b6aee6db343deeb3b279a7c6a Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 12:26:43 +1000 Subject: [PATCH 01/13] fix: finish v2 removal release gates --- .changeset/adapter-release-readiness.md | 28 ++++ .changeset/decrypt-chaining-docs.md | 5 +- .changeset/dynamodb-eql-v3.md | 11 +- .changeset/remove-eql-v2-drizzle-root.md | 15 +-- .../remove-eql-v2-migrate-classifier.md | 2 +- .changeset/remove-eql-v2-packages.md | 1 - .../remove-eql-v2-supabase-authoring.md | 26 ++-- .changeset/remove-eql-v2-supabase-skill.md | 3 +- e2e/package.json | 1 + .../init/lib/__tests__/install-skills.test.ts | 8 ++ .../init/lib/__tests__/setup-prompt.test.ts | 3 +- .../cli/src/commands/init/lib/setup-prompt.ts | 2 +- .../lock-context.integration.test.ts | 24 ++-- .../null-persistence.integration.test.ts | 10 +- .../relational.integration.test.ts | 107 +++++++-------- .../stack-drizzle/tsconfig.typecheck.json | 6 +- .../__tests__/supabase-v3-builder.test.ts | 125 +++++++++++++++++- .../__tests__/supabase-v3-factory.test.ts | 27 ++++ packages/stack-supabase/src/helpers.ts | 72 +++++++++- packages/stack-supabase/src/index.ts | 15 +++ packages/stack-supabase/src/query-builder.ts | 35 ++++- packages/stack-supabase/src/query-dbspace.ts | 10 +- packages/stack-supabase/src/query-encrypt.ts | 69 ++++++---- packages/stack-supabase/src/query-filters.ts | 16 ++- packages/stack-supabase/src/query-results.ts | 17 ++- packages/stack-supabase/src/types.ts | 16 ++- .../stack/__tests__/typed-client-v3.test.ts | 36 +++++ .../__tests__/wasm-inline-models.test.ts | 12 ++ packages/stack/src/adapter-kit.ts | 4 + packages/stack/src/encryption/v3.ts | 9 +- .../stack/src/eql/v3/date-reconstruction.ts | 57 ++++++++ packages/stack/src/wasm-inline.ts | 11 +- packages/wizard/src/lib/install-skills.ts | 13 +- pnpm-lock.yaml | 3 + 34 files changed, 609 insertions(+), 190 deletions(-) create mode 100644 .changeset/adapter-release-readiness.md create mode 100644 packages/stack/src/eql/v3/date-reconstruction.ts diff --git a/.changeset/adapter-release-readiness.md b/.changeset/adapter-release-readiness.md new file mode 100644 index 000000000..f0ced1557 --- /dev/null +++ b/.changeset/adapter-release-readiness.md @@ -0,0 +1,28 @@ +--- +'@cipherstash/stack': patch +'@cipherstash/stack-supabase': patch +'stash': patch +'@cipherstash/wizard': patch +--- + +Finish the EQL v2-removal release gates and adapter correctness pass. + +- Supabase preserves nested PostgREST boolean expressions and + `referencedTable`, 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. +- 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. +- `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/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..59c6e036d 100644 --- a/.changeset/remove-eql-v2-supabase-authoring.md +++ b/.changeset/remove-eql-v2-supabase-authoring.md @@ -5,23 +5,16 @@ 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 - type-identical `@deprecated` alias, so existing imports keep working. +- **`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory.** - **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`. **Reading existing v2 data.** Only the v2 *authoring/emission* surface is removed — no v2 ciphertext is stranded. Decryption in `@cipherstash/stack` is @@ -37,9 +30,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..29d4b5b89 100644 --- a/.changeset/remove-eql-v2-supabase-skill.md +++ b/.changeset/remove-eql-v2-supabase-skill.md @@ -3,8 +3,7 @@ --- 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` is now the connect-time-introspecting EQL v3 factory, 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 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/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..5e315fd48 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 @@ -17,6 +17,7 @@ import type { Integration } from '../../types.js' vi.mock('@clack/prompts', () => ({ log: { warn: vi.fn() } })) import * as p from '@clack/prompts' +import { SKILL_MAP as WIZARD_SKILL_MAP } from '../../../../../../wizard/src/lib/install-skills.js' import { availableSkills, installSkills, @@ -40,6 +41,13 @@ const ALL_INTEGRATIONS: Integration[] = [ ] describe('SKILL_MAP', () => { + it('stays in parity with every wizard integration', () => { + expect(WIZARD_SKILL_MAP.drizzle).toEqual(SKILL_MAP.drizzle) + expect(WIZARD_SKILL_MAP.supabase).toEqual(SKILL_MAP.supabase) + expect(WIZARD_SKILL_MAP.prisma).toEqual(SKILL_MAP['prisma-next']) + expect(WIZARD_SKILL_MAP.generic).toEqual(SKILL_MAP.postgresql) + }) + 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..7173a7fb5 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,24 @@ function encryptedInsertRows(): MatrixPlainRow[] { }) } +function checkedInsertRows( + 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 +253,8 @@ beforeAll(async () => { ) `) - const encryptedRows = unwrapResult( - await client.bulkEncryptModels(encryptedInsertRows(), schema), + const encryptedRows = checkedInsertRows( + unwrapResult(await client.bulkEncryptModels(encryptedInsertRows(), schema)), ) await db.insert(matrixTable).values(encryptedRows) await db.insert(accountsTable).values([ @@ -276,30 +262,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 = checkedInsertRows( + 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..051ef8107 100644 --- a/packages/stack-drizzle/tsconfig.typecheck.json +++ b/packages/stack-drizzle/tsconfig.typecheck.json @@ -9,9 +9,5 @@ // (#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__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index ce9835a2f..5163918b5 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,41 @@ 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' }) + }) + + 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 +584,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 +910,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 +968,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 +1080,39 @@ 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) + }) + // 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..3eb6e27ef 100644 --- a/packages/stack-supabase/src/helpers.ts +++ b/packages/stack-supabase/src/helpers.ts @@ -209,13 +209,46 @@ export function mapFilterOpToQueryType(op: FilterOp): QueryTypeName { * of that name is untouched. */ export function parseOrString(orString: string): PendingOrCondition[] { + return parseOrStringAt(orString, 0, false).map( + ({ sourceSpan: _, ...condition }) => condition, + ) +} + +/** Adapter-only parser that flattens nested groups and records every leaf's + * source span, allowing encrypted substitution without rebuilding the group. */ +export function parseOrStringWithSpans(orString: string): PendingOrCondition[] { + return parseOrStringAt(orString, 0, true) +} + +/** Recursively flatten PostgREST logic groups while retaining each leaf's + * exact location in the caller's original expression. The adapter must + * encrypt leaves inside `and(...)` / `or(...)`, but rebuilding the whole + * expression from a flat condition array destroys those groups. */ +function parseOrStringAt( + orString: string, + baseOffset: number, + recurseGroups: boolean, +): 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 (recurseGroups && group) { + const open = trimmed.indexOf('(') + conditions.push( + ...parseOrStringAt(group[1], sourceStart + open + 1, true), + ) + continue + } // Format: column.op.value — or column.not.op.value const firstDot = trimmed.indexOf('.') @@ -245,7 +278,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 +336,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 // --------------------------------------------------------------------------- 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/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index d7356a1a5..6516af1cf 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -313,6 +313,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 +396,7 @@ export class EncryptedQueryBuilderImpl< this.orFilters.push({ kind: 'structured', conditions: filtersOrConditions, + referencedTable: options?.referencedTable ?? options?.foreignTable, }) } return this @@ -486,8 +492,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 { @@ -861,12 +868,32 @@ export class EncryptedQueryBuilderImpl< * 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 tokens: Array<{ value: string; wildcard: boolean }> = [] + 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() + + if (tokens.some((token) => token.wildcard)) { 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.`, ) } + const needle = tokens.map((token) => token.value).join('') const key = `${op}:${column}` if (!warnedLikeDelegation.has(key)) { warnedLikeDelegation.add(key) diff --git a/packages/stack-supabase/src/query-dbspace.ts b/packages/stack-supabase/src/query-dbspace.ts index ae68df562..fed373a64 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 { 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..12d8ba14a 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,9 +339,14 @@ 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 @@ -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..425ca8c75 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, @@ -58,14 +63,14 @@ function postprocessDecryptedRow( keyToDb[dbName] ??= dbName } - const out: Record = { ...row } + let out: Record = { ...row } 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('.')) { + out = reconstructDatePaths(out, [key]) + } else if (Object.hasOwn(out, key)) { + out[key] = reconstructDateValue(out[key]) } } return out diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index e19f685e9..f6a53ba61 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 = { @@ -694,7 +702,11 @@ export type DbPendingMatchFilter = { * 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 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..eef1e0a33 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' }, 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..fcefb081e --- /dev/null +++ b/packages/stack/src/eql/v3/date-reconstruction.ts @@ -0,0 +1,57 @@ +/** Reconstruct a decrypted date-like plaintext without manufacturing an + * `Invalid Date`. Non-date values and already-constructed Dates pass through. */ +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..af9d467b8 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, @@ -614,12 +615,6 @@ function datePropertyPaths(table: AnyV3Table): Set { * 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 +1363,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) From 738741461cc7d313d7387d68d289785ea7f505e8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 12:50:08 +1000 Subject: [PATCH 02/13] test: close issue 816 release gaps --- .changeset/nextjs-stack-metadata.md | 8 ++++ .github/workflows/tests.yml | 12 +++--- .../init/lib/__tests__/install-skills.test.ts | 12 ++++++ .../stack-drizzle/tsconfig.typecheck.json | 12 +++--- packages/stack-supabase/src/like-pattern.ts | 39 +++++++++++++++++++ packages/stack-supabase/src/query-builder.ts | 25 ++---------- .../__tests__/wasm-inline-models.test.ts | 20 ++++++++++ 7 files changed, 92 insertions(+), 36 deletions(-) create mode 100644 .changeset/nextjs-stack-metadata.md create mode 100644 packages/stack-supabase/src/like-pattern.ts 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/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ece7ea51..a6de8d262 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -166,13 +166,11 @@ 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`, `stash` + # and `@cipherstash/stack-supabase` still have source/runtime-test files + # outside their type-test configs. stack-drizzle's type-test config now + # includes every `integration/**` source; its remaining runtime source + # sets are tracked separately. - name: Typecheck (migrate) run: pnpm exec turbo run typecheck --filter @cipherstash/migrate 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 5e315fd48..07fb875f8 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 @@ -48,6 +48,18 @@ describe('SKILL_MAP', () => { expect(WIZARD_SKILL_MAP.generic).toEqual(SKILL_MAP.postgresql) }) + 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/stack-drizzle/tsconfig.typecheck.json b/packages/stack-drizzle/tsconfig.typecheck.json index 051ef8107..1dad75198 100644 --- a/packages/stack-drizzle/tsconfig.typecheck.json +++ b/packages/stack-drizzle/tsconfig.typecheck.json @@ -1,13 +1,11 @@ { // 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 runtime `__tests__/*.test.ts` are still outside this explicit + // gate. Keep this include focused until those remaining source sets are + // clean under the full project config. "extends": "./tsconfig.json", "include": ["__tests__/**/*.test-d.ts", "integration/**/*.ts"] } diff --git a/packages/stack-supabase/src/like-pattern.ts b/packages/stack-supabase/src/like-pattern.ts new file mode 100644 index 000000000..51874fc2a --- /dev/null +++ b/packages/stack-supabase/src/like-pattern.ts @@ -0,0 +1,39 @@ +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. + */ +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 6516af1cf..eb9c2ee80 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, @@ -868,32 +869,12 @@ export class EncryptedQueryBuilderImpl< * per (op, column) that the delegation is approximate. */ private likeNeedle(column: string, op: string, pattern: string): string { - const tokens: Array<{ value: string; wildcard: boolean }> = [] - 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() - - if (tokens.some((token) => token.wildcard)) { + 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.`, ) } - const needle = tokens.map((token) => token.value).join('') const key = `${op}:${column}` if (!warnedLikeDelegation.has(key)) { warnedLikeDelegation.add(key) diff --git a/packages/stack/__tests__/wasm-inline-models.test.ts b/packages/stack/__tests__/wasm-inline-models.test.ts index eef1e0a33..d0d5a56bf 100644 --- a/packages/stack/__tests__/wasm-inline-models.test.ts +++ b/packages/stack/__tests__/wasm-inline-models.test.ts @@ -298,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' }, From 4dab29abe1e087e2e21ffce483ad04331d5f415d Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 12:58:09 +1000 Subject: [PATCH 03/13] test(stack-supabase): cover multi-leaf or() substitution and not.and() recursion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two guards in the string-form `.or()` path had no test that could see them fail. `substituteOrStringLeaves` sorts its replacements right-to-left so an earlier source span stays valid after a later one grew — and every v3 envelope is far longer than the plaintext operand it replaces. Sorting ascending instead splices the second envelope INSIDE the first and leaks the trailing condition verbatim: `email.eq."{\"created_at.gte."{…}"…}",createdAt.gte.2026-01-01`. Only one existing test replaced two leaves at all (the `is`-on-an-encrypted- column case, where the second replacement is `created_at.is.null` — shorter than the span it fills), so the growth case that motivates the sort was untested. `parseOrStringAt`'s group regex admits a `not.` prefix. Drop it and `not.and(email.eq.ada,…)` stops being 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 `ada` goes to PostgREST in the clear against an encrypted column. Both were verified red against those exact mutations and green on restore. --- .../__tests__/supabase-v3-builder.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index 5163918b5..ebd8eb202 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -557,6 +557,71 @@ describe('encryptedSupabaseV3 wire encoding', () => { 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() From 34c48eb84568ff1514c1c471ac77fdd9df6cd663 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 12:59:21 +1000 Subject: [PATCH 04/13] docs(stack): move the date-reconstruction rationale onto the function it explains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reconstructDate` was deleted from `wasm-inline.ts` when date reconstruction moved to `eql/v3/date-reconstruction.ts`, but its doc comment survived and came to rest above `INTERNAL_CONSTRUCT` — two JSDoc blocks stacked over one constant, the first documenting nothing. The explanation it carried is still the reason `reconstructDateValue` guards on `Number.isNaN` at all, so it lands there: an unparseable stored value must come back as the raw value, not as an Invalid Date whose later `.toISOString()` throws far from the column that produced it (#742 review). --- packages/stack/src/eql/v3/date-reconstruction.ts | 13 +++++++++++-- packages/stack/src/wasm-inline.ts | 7 ------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/stack/src/eql/v3/date-reconstruction.ts b/packages/stack/src/eql/v3/date-reconstruction.ts index fcefb081e..9679fd1bf 100644 --- a/packages/stack/src/eql/v3/date-reconstruction.ts +++ b/packages/stack/src/eql/v3/date-reconstruction.ts @@ -1,5 +1,14 @@ -/** Reconstruct a decrypted date-like plaintext without manufacturing an - * `Invalid Date`. Non-date values and already-constructed Dates pass through. */ +/** + * 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 || diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index af9d467b8..f3d31e3fe 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -608,13 +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). - */ /** * Internal token used to gate the {@link WasmEncryptionClient} * constructor. Symbols are unique by reference, so external code can't From d6ded7ff58f14952a946d01da9886d2d466f17ca Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 13:01:07 +1000 Subject: [PATCH 05/13] refactor(stack-supabase,skills): drop the unreachable csv transform, and correct the skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `csv()` throws unconditionally — PostgREST serializes rows server-side, so a CSV response carries ciphertext the adapter never gets to decrypt. Nothing can therefore push `{ kind: 'csv' }`, yet the transform survived in three places: the `TransformOp` union, the `toDbSpace` pass-through switch, and the `buildAndExecuteQuery` apply switch. All three go; the fail-closed throw stays exactly as it is. `skills/stash-supabase/SKILL.md` still listed `.csv()` among the transforms "passed through to Supabase directly". That ships inside the `stash` tarball and gets copied into customer repos, so it was about to become wrong guidance in someone else's codebase. It now documents the throw and shows serializing the decrypted rows instead. --- packages/stack-supabase/src/query-builder.ts | 3 --- packages/stack-supabase/src/query-dbspace.ts | 1 - packages/stack-supabase/src/types.ts | 10 +++++++++- skills/stash-supabase/SKILL.md | 10 +++++++++- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index eb9c2ee80..e583aa055 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -727,9 +727,6 @@ export class EncryptedQueryBuilderImpl< case 'maybeSingle': query = query.maybeSingle() break - case 'csv': - query = query.csv() - break case 'abortSignal': query = query.abortSignal(t.signal) break diff --git a/packages/stack-supabase/src/query-dbspace.ts b/packages/stack-supabase/src/query-dbspace.ts index fed373a64..42ae9cb9e 100644 --- a/packages/stack-supabase/src/query-dbspace.ts +++ b/packages/stack-supabase/src/query-dbspace.ts @@ -71,7 +71,6 @@ function transformToDbSpace(t: TransformOp, columns: ColumnMap): DbTransformOp { case 'range': case 'single': case 'maybeSingle': - case 'csv': case 'abortSignal': case 'throwOnError': case 'returns': diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index f6a53ba61..a2500a2e6 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -599,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' } @@ -969,6 +970,13 @@ 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. + */ csv(): Self abortSignal(signal: AbortSignal): Self throwOnError(): Self 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. From fcb627b16c2b4d1ffa6d4f3c6ce2eb4b5eaabe4d Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 13:02:32 +1000 Subject: [PATCH 06/13] perf(stack-supabase): reconstruct nested date paths in one pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `postprocessDecryptedRow` called `reconstructDatePaths(out, [key])` once per dotted date key, and that helper allocates a fresh shallow row copy on every call — so a row with N nested date columns paid N clones to produce one result. Collect the dotted keys and make a single call. The batching is also the correctness-preserving form for two paths under a shared prefix: inside one call the second path clones the intermediate the first already rebuilt (`targetChild` wins over `sourceChild`), so both Dates and their plaintext siblings survive. Covered by a new `profile.createdAt` / `profile.updatedAt` test, which goes red if that preference is dropped. The flat-key branch is unchanged. --- .../__tests__/supabase-v3-builder.test.ts | 48 +++++++++++++++++++ packages/stack-supabase/src/query-results.ts | 13 +++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index ebd8eb202..0ee07e9d5 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -1178,6 +1178,54 @@ describe('encryptedSupabaseV3 wire encoding', () => { 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/src/query-results.ts b/packages/stack-supabase/src/query-results.ts index 425ca8c75..b8daa6ccd 100644 --- a/packages/stack-supabase/src/query-results.ts +++ b/packages/stack-supabase/src/query-results.ts @@ -63,17 +63,24 @@ function postprocessDecryptedRow( keyToDb[dbName] ??= dbName } - let out: Record = { ...row } + 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 if (key.includes('.')) { - out = reconstructDatePaths(out, [key]) + dottedKeys.push(key) } else if (Object.hasOwn(out, key)) { out[key] = reconstructDateValue(out[key]) } } - return out + return dottedKeys.length > 0 ? reconstructDatePaths(out, dottedKeys) : out } /** From 673cc071f18dc0b160e58dc8f62cd2d349fd2de1 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 13:03:20 +1000 Subject: [PATCH 07/13] test(stack-drizzle): name the insert-row helper for what it actually checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkedInsertRows` reads as though it validates the rows it returns. It validates `rowKey` and `testRunId` are strings and then does `return rows as T[]` — the encrypted columns, the ones that could plausibly be malformed, are never looked at. Renamed to `assertScopeKeys`, with a doc comment saying plainly that it re-establishes the Drizzle insert type after asserting only that the two plaintext scope keys survived encryption. Verified through `pnpm --filter @cipherstash/stack-drizzle run test:types`, which is the script driving `tsconfig.typecheck.json` — now widened to `integration/**/*.ts`. Confirmed the gate really compiles this file by planting a deliberate type error in it and watching the run exit 1. --- .../integration/relational.integration.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/stack-drizzle/integration/relational.integration.test.ts b/packages/stack-drizzle/integration/relational.integration.test.ts index 7173a7fb5..892e73077 100644 --- a/packages/stack-drizzle/integration/relational.integration.test.ts +++ b/packages/stack-drizzle/integration/relational.integration.test.ts @@ -183,7 +183,19 @@ function encryptedInsertRows(): MatrixPlainRow[] { }) } -function checkedInsertRows( +/** + * 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) { @@ -253,7 +265,7 @@ beforeAll(async () => { ) `) - const encryptedRows = checkedInsertRows( + const encryptedRows = assertScopeKeys( unwrapResult(await client.bulkEncryptModels(encryptedInsertRows(), schema)), ) await db.insert(matrixTable).values(encryptedRows) @@ -270,7 +282,7 @@ beforeAll(async () => { // 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 = checkedInsertRows( + const bigintRows = assertScopeKeys( unwrapResult( await client.bulkEncryptModels( [ From 5b03135dbacefa36d462ed460950f2a651604bdb Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 13:05:03 +1000 Subject: [PATCH 08/13] test(e2e): assert CLI/wizard SKILL_MAP parity where both packages are declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity assertion lived in the CLI's unit suite and reached the wizard through `../../../../../../wizard/src/lib/install-skills.js` — a deep relative path into a package `packages/cli` declares no dependency on, targeting a non-published internal module. If the wizard relocated that file the only signal was a module-not-found in a different package's test run. Chose option (a), moving it to `e2e/`, over adding a devDependency and a comment (option (b)), for three reasons: - `e2e/` already declares BOTH `stash` and `@cipherstash/wizard` as workspace dependencies, so the layering is honest without widening the CLI's dep graph for a single assertion. - `e2e` has a `typecheck` script over `tests/**` and CI runs it ("Typecheck (e2e)"). `packages/cli` has no typecheck script at all, so this is the only placement where a relocated module is a COMPILE error. Verified: pointing the import at a non-existent path fails `pnpm --filter @cipherstash/e2e run typecheck` with TS2307. - Cross-package behaviour is what that workspace is for, per its own description — and reading source rather than a built binary is an existing idiom there (`package-managers.e2e.test.ts` Suite A), so no build is needed. `SKILL_MAP` stays unexported from the wizard's package entry — the published surface should not grow for a test. The assertion also got stronger in the move: the wizard→CLI name mapping is now a `Record`, so a new integration on either side fails to compile or fails the key-coverage test, instead of being silently omitted from a hand-listed set of four expectations. --- e2e/README.md | 1 + e2e/tests/skill-map-parity.e2e.test.ts | 60 +++++++++++++++++++ .../init/lib/__tests__/install-skills.test.ts | 12 ++-- 3 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 e2e/tests/skill-map-parity.e2e.test.ts 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/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 07fb875f8..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 @@ -17,7 +17,6 @@ import type { Integration } from '../../types.js' vi.mock('@clack/prompts', () => ({ log: { warn: vi.fn() } })) import * as p from '@clack/prompts' -import { SKILL_MAP as WIZARD_SKILL_MAP } from '../../../../../../wizard/src/lib/install-skills.js' import { availableSkills, installSkills, @@ -40,14 +39,11 @@ 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('stays in parity with every wizard integration', () => { - expect(WIZARD_SKILL_MAP.drizzle).toEqual(SKILL_MAP.drizzle) - expect(WIZARD_SKILL_MAP.supabase).toEqual(SKILL_MAP.supabase) - expect(WIZARD_SKILL_MAP.prisma).toEqual(SKILL_MAP['prisma-next']) - expect(WIZARD_SKILL_MAP.generic).toEqual(SKILL_MAP.postgresql) - }) - it('selects only skills that contain a bundled SKILL.md', () => { for (const integration of ALL_INTEGRATIONS) { const available = new Set(availableSkills(integration)) From 7f708791e7b1b44b5972d2d537fcf0cd5d275027 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 13:06:00 +1000 Subject: [PATCH 09/13] docs(changeset): restore the encryptedSupabaseV3 alias, and say what the nested-or() fix was MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changesets had been edited to delete the statement that `encryptedSupabaseV3` survives as a type-identical `@deprecated` alias. It does — `packages/stack-supabase/src/index.ts:275`, alongside the `*V3` type aliases at 282-297 — the repo's own tests import it, and `AGENTS.md` documents it. A changeset that contradicts the shipped code is worse than a terse one, so both get a tight sentence back. `adapter-release-readiness` compressed the nested-group fix into "preserves nested PostgREST boolean expressions", which reads as a formatting nicety. It was a disclosure fix: with no group recursion, `and(createdAt.gte.…,…)` was cut at the first dot into the pseudo-column `and(createdAt`, matched no encrypted column, and took the verbatim branch — so the operand reached PostgREST as plaintext against an encrypted column, under the JS property name rather than the DB column name. Users need that stated outright. Also records the `stash-supabase` skill correction (csv() throws, it is not a pass-through transform) on the changeset that already bumps `stash`. --- .changeset/adapter-release-readiness.md | 22 +++++++++++++++---- .../remove-eql-v2-supabase-authoring.md | 7 ++++-- .changeset/remove-eql-v2-supabase-skill.md | 7 +++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/.changeset/adapter-release-readiness.md b/.changeset/adapter-release-readiness.md index f0ced1557..dc3540e00 100644 --- a/.changeset/adapter-release-readiness.md +++ b/.changeset/adapter-release-readiness.md @@ -7,10 +7,24 @@ Finish the EQL v2-removal release gates and adapter correctness pass. -- Supabase preserves nested PostgREST boolean expressions and - `referencedTable`, 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. +- **Supabase encrypts leaves nested inside a PostgREST boolean group.** This + is a disclosure fix, not a formatting one. `parseOrString` 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. diff --git a/.changeset/remove-eql-v2-supabase-authoring.md b/.changeset/remove-eql-v2-supabase-authoring.md index 59c6e036d..0bf7fb5f5 100644 --- a/.changeset/remove-eql-v2-supabase-authoring.md +++ b/.changeset/remove-eql-v2-supabase-authoring.md @@ -5,7 +5,9 @@ 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.** +- **`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory** + (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 @@ -14,7 +16,8 @@ unsuffixed names (part of the EQL v2 removal, #707). - **The public types use canonical unsuffixed names:** `EncryptedSupabaseOptions`, `EncryptedSupabaseInstance`, `TypedEncryptedSupabaseInstance`, `EncryptedQueryBuilder`, - `EncryptedQueryBuilderUntyped`, `FilterableKeys`, and `OrderableKeys`. + `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 diff --git a/.changeset/remove-eql-v2-supabase-skill.md b/.changeset/remove-eql-v2-supabase-skill.md index 29d4b5b89..c632acc7f 100644 --- a/.changeset/remove-eql-v2-supabase-skill.md +++ b/.changeset/remove-eql-v2-supabase-skill.md @@ -3,8 +3,9 @@ --- Update the bundled `stash-supabase` agent skill for the EQL v2 removal (#707): -`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory, and the legacy v2 -`encryptedSupabase({ encryptionClient, supabaseClient })` authoring wrapper has -been removed. The skill's examples, exported-type list, and migration/cutover +`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory (with +`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. From 2db092d57ee5a31d4f41aa4ee836097dc7446c54 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 13:43:56 +1000 Subject: [PATCH 10/13] docs(ci,stack-drizzle): keep the error counts and #778 in the gate comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening `stack-drizzle`'s typecheck `include` to `integration/**/*.ts` made the surrounding comments stale, and rewriting them dropped the two things that made them actionable: the per-package error counts, and the `#778` root cause (`V3_MATRIX`'s `indexes` union in `@cipherstash/test-kit`). Those counts are the mechanism the original comment describes — "widen this include as that count comes down" is not a usable instruction once the count is gone, and "tracked separately" does not say where. Restore both, keeping the corrected scope: drizzle now type-tests every `integration/**` source, so only its `src` and runtime tests remain ungated. Comment-only; no build or gate behaviour changes. --- .github/workflows/tests.yml | 13 ++++++++----- packages/stack-drizzle/tsconfig.typecheck.json | 8 +++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a6de8d262..4a656554d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -166,11 +166,14 @@ jobs: # workspace dependencies through `dist/*.d.ts`, hence the turbo filters # (`^build` builds the dependencies first). # - # NOT fully gated yet, and deliberately: `@cipherstash/stack`, `stash` - # and `@cipherstash/stack-supabase` still have source/runtime-test files - # outside their type-test configs. stack-drizzle's type-test config now - # includes every `integration/**` source; its remaining runtime source - # sets are tracked separately. + # 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/packages/stack-drizzle/tsconfig.typecheck.json b/packages/stack-drizzle/tsconfig.typecheck.json index 1dad75198..18f06548b 100644 --- a/packages/stack-drizzle/tsconfig.typecheck.json +++ b/packages/stack-drizzle/tsconfig.typecheck.json @@ -3,9 +3,11 @@ // `.test-d.ts` files plus every `integration/**` source that #772's review // found was otherwise compiled by no CI job. // - // `src/` and runtime `__tests__/*.test.ts` are still outside this explicit - // gate. Keep this include focused until those remaining source sets are - // clean under the full project config. + // `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/**/*.ts"] } From 0f54c0e181572588096ee18c49a3de23b9a2cd47 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 14:11:16 +1000 Subject: [PATCH 11/13] refactor(stack-supabase): collapse the or-string parser to the one production runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseOrStringWithSpans` became the only parser any production path calls when the nested-group fix landed, but `parseOrString` survived alongside it and kept the entire helpers suite — including assertions that pinned the NON-recursing behaviour, e.g. `and(a.eq.1,b.eq.2),c.eq.3` yielding two conditions, one of them a junk leaf on a column literally named `and(a`. The parser with the coverage was the dead one; the live one's group recursion and source spans were exercised only indirectly, through the builder. Delete the wrapper, repoint the suite at `parseOrStringWithSpans`, and drop the now-constant `recurseGroups` parameter. A local `parseConditions` strips `sourceSpan` so every existing `toEqual` stays exactly as strict about the condition shape as it was. Adds the span-level coverage that existed nowhere: a leaf one and two groups deep, inside `not.and(`, after array and jsonb containment literals whose commas are not delimiters, with surrounding whitespace excluded, and two identical leaves getting distinct spans — plus `substituteOrStringLeaves` replacing leaves at two nesting depths. Both are real regressions tests: flipping the splice to left-to-right fails two of them, and shifting the group base offset by one fails seven. Nothing in the suite caught either mutation before. --- .../__tests__/supabase-helpers.test.ts | 350 +++++++++++++++--- packages/stack-supabase/src/helpers.ts | 37 +- packages/stack-supabase/src/query-dbspace.ts | 3 +- packages/stack-supabase/src/query-filters.ts | 2 +- 4 files changed, 319 insertions(+), 73 deletions(-) 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/src/helpers.ts b/packages/stack-supabase/src/helpers.ts index 3eb6e27ef..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,27 +208,21 @@ 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[] { - return parseOrStringAt(orString, 0, false).map( - ({ sourceSpan: _, ...condition }) => condition, - ) -} - -/** Adapter-only parser that flattens nested groups and records every leaf's - * source span, allowing encrypted substitution without rebuilding the group. */ export function parseOrStringWithSpans(orString: string): PendingOrCondition[] { - return parseOrStringAt(orString, 0, true) + return parseOrStringAt(orString, 0) } /** Recursively flatten PostgREST logic groups while retaining each leaf's - * exact location in the caller's original expression. The adapter must - * encrypt leaves inside `and(...)` / `or(...)`, but rebuilding the whole - * expression from a flat condition array destroys those groups. */ + * 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, - recurseGroups: boolean, ): PendingOrCondition[] { const conditions: PendingOrCondition[] = [] const parts = splitTopLevel(orString) @@ -242,11 +237,9 @@ function parseOrStringAt( const sourceStart = baseOffset + partStart + leading const group = /^(?:not\.)?(?:and|or)\(([\s\S]*)\)$/.exec(trimmed) - if (recurseGroups && group) { + if (group) { const open = trimmed.indexOf('(') - conditions.push( - ...parseOrStringAt(group[1], sourceStart + open + 1, true), - ) + conditions.push(...parseOrStringAt(group[1], sourceStart + open + 1)) continue } @@ -399,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/query-dbspace.ts b/packages/stack-supabase/src/query-dbspace.ts index 42ae9cb9e..194ba6055 100644 --- a/packages/stack-supabase/src/query-dbspace.ts +++ b/packages/stack-supabase/src/query-dbspace.ts @@ -103,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-filters.ts b/packages/stack-supabase/src/query-filters.ts index 12d8ba14a..8eb545e0c 100644 --- a/packages/stack-supabase/src/query-filters.ts +++ b/packages/stack-supabase/src/query-filters.ts @@ -351,7 +351,7 @@ export function applyFilters( // 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, }) From 2b0eeb2f7b68ad536fde5439e72d351df02627fe Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 14:11:16 +1000 Subject: [PATCH 12/13] test(stack-supabase): pin parseLikeNeedle's escape handling directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseLikeNeedle` decides which `like`/`ilike` patterns can be delegated to encrypted fuzzy matching and what literal term the search receives, but its only coverage was two end-to-end cases in the builder suite, which pin the wire envelope rather than the parse. Its predecessor (`replace(/^%+/, '')` plus `pattern.includes('_')`) got the escaping wrong in both directions, so the rules are worth holding directly: 9 of the 22 new tests fail against that predecessor. Pins in particular that `hasUnsupportedWildcard` tracks UNESCAPED wildcards only — an escaped `\_` is a literal and must not reject the query — and that only unescaped leading/trailing `%` are strippable, so a literal `\%` at either end stops the strip instead of being eaten by it. A trailing lone backslash stays a literal backslash rather than throwing; Postgres rejects such a pattern outright, but this needle is an approximation and the plaintext `like` path never reaches here. Documented as deliberate in the JSDoc so it does not get "fixed" into a throw. Also corrects the throw's parenthetical, which still read `an internal "%" or any "_"` — escaped metacharacters are honoured now, so it names unescaped ones. --- .../__tests__/like-pattern.test.ts | 161 ++++++++++++++++++ packages/stack-supabase/src/like-pattern.ts | 5 + packages/stack-supabase/src/query-builder.ts | 14 +- 3 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 packages/stack-supabase/__tests__/like-pattern.test.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/src/like-pattern.ts b/packages/stack-supabase/src/like-pattern.ts index 51874fc2a..9853a71cf 100644 --- a/packages/stack-supabase/src/like-pattern.ts +++ b/packages/stack-supabase/src/like-pattern.ts @@ -9,6 +9,11 @@ export type LikeNeedle = { * 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[] = [] diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index e583aa055..7bfbeaa57 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -257,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)) { @@ -862,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, 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}` From c62d23c45b19d92ccea4db36a2d5411a21c116dd Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 14:11:30 +1000 Subject: [PATCH 13/13] docs(stack-supabase,changeset): mark csv() deprecated and state the date-shape break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things this release changes that a reader could only find at runtime. `csv()` throws on every encrypted query, but the interface member is still typed `Self` with no `@deprecated` tag, so an existing call site got no editor or compile signal and would discover the removal on its first request. Tag it; the `Self` return type stays, since it is a deliberate choice to keep the chain shape. `docs/reference/supabase-sdk.md` still advertised `.csv` among the transforms passed through to Supabase — the bundled skill was corrected in this PR, the reference was not. Date reconstruction now returns the raw value when a stored value does not parse. Before this branch the native typed client pushed every date-like column through `new Date(...)` unconditionally, so an unparseable value came back as an Invalid `Date`: `instanceof Date` held and `.getTime()` yielded `NaN`. It now comes back as the raw string and that call throws a `TypeError`, while the declared column type is still `Date`. The changeset said only "leaving invalid values unchanged", which undersells it. Both `stash-encryption` and `stash-drizzle` asserted flatly that `Date` columns are reconstructed to real `Date` instances — Drizzle decrypts through the same typed client, so both now carry the caveat. Bumps `@cipherstash/stack-supabase` and `@cipherstash/stack` from patch to minor. `csv()` and `matches(column, null)` throwing where they previously did something are behavioural breaks, and the date-shape change originates in `stack`; the migrate classifier changeset was raised for the same reason. The emitted version is unchanged under rc prerelease mode with a fixed group — this is signal, not version math. Also names the parser that actually runs in the nested-group bullet, since `parseOrString` no longer exists. --- .changeset/adapter-release-readiness.md | 36 +++++++++++++++---------- docs/reference/supabase-sdk.md | 4 ++- packages/stack-supabase/src/types.ts | 9 ++++--- skills/stash-drizzle/SKILL.md | 2 +- skills/stash-encryption/SKILL.md | 2 +- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/.changeset/adapter-release-readiness.md b/.changeset/adapter-release-readiness.md index dc3540e00..f633bd8df 100644 --- a/.changeset/adapter-release-readiness.md +++ b/.changeset/adapter-release-readiness.md @@ -1,6 +1,6 @@ --- -'@cipherstash/stack': patch -'@cipherstash/stack-supabase': patch +'@cipherstash/stack': minor +'@cipherstash/stack-supabase': minor 'stash': patch '@cipherstash/wizard': patch --- @@ -8,17 +8,17 @@ 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. `parseOrString` 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. + 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` @@ -27,7 +27,15 @@ Finish the EQL v2-removal release gates and adapter correctness pass. 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. + 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, 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/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index a2500a2e6..e58cf74c9 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -699,9 +699,9 @@ 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' @@ -976,6 +976,9 @@ export interface EncryptedQueryBuilderCore< * 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 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