Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/adapter-release-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
'@cipherstash/stack': minor
'@cipherstash/stack-supabase': minor
'stash': patch
'@cipherstash/wizard': patch
---

Finish the EQL v2-removal release gates and adapter correctness pass.

- **Supabase encrypts leaves nested inside a PostgREST boolean group.** This
is a disclosure fix, not a formatting one. The `.or()` string parser had
no group recursion, so `.or('and(createdAt.gte.2026-01-01,note.eq.x)')`
came back from the top-level split as one part and the leaf parser cut it
at the first dot into the pseudo-column `and(createdAt`. That name matched
no encrypted column, so the whole expression took the verbatim branch: the
operand `2026-01-01` reached PostgREST **as plaintext, against an
encrypted column**, under the JS property name `createdAt` rather than the
DB column name `created_at`. Every encrypted leaf nested inside `and(...)`
/ `or(...)` / `not.and(...)` leaked its operand to the database and
returned wrong results. Nested groups and `referencedTable` are now
preserved while each encrypted leaf is substituted in place.
- Supabase never sends nullish encrypted search operands as plaintext, honours
escaped LIKE metacharacters, rejects CSV result mode before decryption, and
diagnoses the removed object-form factory call. The bundled `stash-supabase`
skill no longer lists `csv()` among the transforms passed through to
Supabase — it throws, and the skill now says so and shows serializing the
decrypted rows instead.
- Native, WASM, and Supabase model decryption reconstruct valid date and
timestamp values consistently, including nested paths, aliases, and bulk
results, while leaving invalid values unchanged. That last clause is a
behavioural change on the native typed client and the Supabase adapter,
which previously pushed every date-like column through `new Date(...)`
unconditionally: a stored value that does not parse used to come back as an
Invalid `Date` and now comes back as the raw string, matching what the WASM
entry already did. The declared column type is still `Date`, so code that
assumed `instanceof Date` held for every date column — or called a `Date`
method on it unguarded, so that `.getTime()` used to yield `NaN` and now
throws a `TypeError` — has to handle the raw value.
- `stash init` names the concrete `public.eql_v3_*` domain family and gives
`public.eql_v3_text_search` as a valid Supabase example.
- CLI and wizard skill selection stay in parity for every integration,
including the Prisma Next skill, and verify that each selected skill has a
`SKILL.md`.

The final 1.0 integration surface is `Encryption` from
`@cipherstash/stack/v3`, the `@cipherstash/stack-drizzle` package root, and
`encryptedSupabase` from `@cipherstash/stack-supabase`. DynamoDB decrypt
operations retain `.audit()` on the typed `Encryption` client. Existing EQL v2
ciphertext remains readable through the core client; authoring and adapter
writes use EQL v3.
5 changes: 3 additions & 2 deletions .changeset/decrypt-chaining-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions .changeset/dynamodb-eql-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions .changeset/nextjs-stack-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 7 additions & 8 deletions .changeset/remove-eql-v2-drizzle-root.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion .changeset/remove-eql-v2-migrate-classifier.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
'@cipherstash/migrate': patch
'@cipherstash/migrate': minor
---

Drop EQL v2 from the domain-type classifier. `classifyEqlDomain` (and the
Expand Down
1 change: 0 additions & 1 deletion .changeset/remove-eql-v2-packages.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
'@cipherstash/stack': patch
'@cipherstash/nextjs': patch
---

Remove the EQL v2-only published packages `@cipherstash/protect`,
Expand Down
25 changes: 9 additions & 16 deletions .changeset/remove-eql-v2-supabase-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,18 @@ Remove the EQL v2 authoring surface and de-suffix the v3 API to the canonical
unsuffixed names (part of the EQL v2 removal, #707).

- **`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory**
(formerly `encryptedSupabaseV3`). `encryptedSupabaseV3` remains as a
(formerly `encryptedSupabaseV3`). `encryptedSupabaseV3` remains a
type-identical `@deprecated` alias, so existing imports keep working.
- **The legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })`
wrapper is removed** — with it the two-argument `from(tableName, schema)` form
and the hand-written client-side v2 schema. Its `EncryptedSupabaseConfig` and
the v2 `EncryptedSupabaseInstance`/`EncryptedQueryBuilder` type shapes are gone;
the unsuffixed type names now denote the v3 surface.
- **The `*V3` type exports are de-suffixed** to their canonical names —
`EncryptedSupabaseV3Options` → `EncryptedSupabaseOptions`,
`EncryptedSupabaseV3Instance` → `EncryptedSupabaseInstance`,
`TypedEncryptedSupabaseV3Instance` → `TypedEncryptedSupabaseInstance`,
`EncryptedQueryBuilderV3` → `EncryptedQueryBuilder`,
`EncryptedQueryBuilderV3Untyped` → `EncryptedQueryBuilderUntyped`,
`V3FilterableKeys` → `FilterableKeys`, `V3OrderableKeys` → `OrderableKeys`, and
the rest of the `*V3` key-helper types. Each keeps a type-identical
`@deprecated` `*V3` alias.
- **The public types use canonical unsuffixed names:**
`EncryptedSupabaseOptions`, `EncryptedSupabaseInstance`,
`TypedEncryptedSupabaseInstance`, `EncryptedQueryBuilder`,
`EncryptedQueryBuilderUntyped`, `FilterableKeys`, and `OrderableKeys`. Each
keeps a type-identical `@deprecated` `*V3` alias.

**Reading existing v2 data.** Only the v2 *authoring/emission* surface is removed
— no v2 ciphertext is stranded. Decryption in `@cipherstash/stack` is
Expand All @@ -37,9 +33,6 @@ Internally the v3 query builder (`query-builder-v3.ts`) was folded into the base
`EncryptedQueryBuilderImpl`, which is now natively EQL v3; no runtime behaviour or
wire encoding changed.

**Migration:** rename `encryptedSupabaseV3` → `encryptedSupabase` (or keep using
the alias). If you still use the v2 `encryptedSupabase({ encryptionClient,
supabaseClient }).from(table, schema)` wrapper, migrate the table to an
`eql_v3_*` column domain and switch to the introspecting factory —
`await encryptedSupabase(supabaseUrl, supabaseKey)` — see the `stash-supabase`
skill and https://cipherstash.com/docs.
**Migration:** use `await encryptedSupabase(supabaseUrl, supabaseKey)` with
`eql_v3_*` column domains. See the `stash-supabase` skill and
https://cipherstash.com/docs.
6 changes: 3 additions & 3 deletions .changeset/remove-eql-v2-supabase-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

Update the bundled `stash-supabase` agent skill for the EQL v2 removal (#707):
`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory (with
`encryptedSupabaseV3` kept as a `@deprecated` alias), and the legacy v2
`encryptedSupabase({ encryptionClient, supabaseClient })` authoring wrapper has
been removed. The skill's examples, exported-type list, and migration/cutover
`encryptedSupabaseV3` kept as a type-identical `@deprecated` alias), and the
legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })` authoring
wrapper has been removed. The skill's examples, exported-type list, and migration/cutover
guidance are corrected accordingly. Skills ship inside the `stash` tarball, so
the stale v2 guidance would otherwise land in a user's project.
15 changes: 8 additions & 7 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,14 @@ jobs:
# workspace dependencies through `dist/*.d.ts`, hence the turbo filters
# (`^build` builds the dependencies first).
#
# NOT gated yet, and deliberately: `@cipherstash/stack` (147 errors under
# its own tsconfig), `@cipherstash/stack-drizzle` (63), `stash` (21) and
# `@cipherstash/stack-supabase` (11). Their `test:types` scripts only
# cover `__tests__/**/*.test-d.ts`, so `src`, the runtime test suites and
# `integration/**` compile nowhere. Most of the drizzle and supabase count
# is one root cause — `V3_MATRIX`'s `indexes` union in
# `@cipherstash/test-kit` — see #778.
# NOT fully gated yet, and deliberately: `@cipherstash/stack` (147 errors
# under its own tsconfig), `stash` (21) and `@cipherstash/stack-supabase`
# (11). Their `test:types` scripts only cover `__tests__/**/*.test-d.ts`,
# so `src` and the runtime test suites compile nowhere.
# `@cipherstash/stack-drizzle` now type-tests every `integration/**`
# source as well; its remaining `src` / runtime-test errors share one
# root cause — `V3_MATRIX`'s `indexes` union in `@cipherstash/test-kit`
# — see #778.
- name: Typecheck (migrate)
run: pnpm exec turbo run typecheck --filter @cipherstash/migrate

Expand Down
4 changes: 3 additions & 1 deletion docs/reference/supabase-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"devDependencies": {
"@types/semver": "7.7.1",
"semver": "^7.8.0",
"typescript": "catalog:repo",
"vitest": "catalog:repo",
"yaml": "^2.9.0"
}
Expand Down
60 changes: 60 additions & 0 deletions e2e/tests/skill-map-parity.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<WizardIntegration, CliIntegration> = {
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(),
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,23 @@ const ALL_INTEGRATIONS: Integration[] = [
'postgresql',
]

// Parity with the wizard's own SKILL_MAP is asserted in
// `e2e/tests/skill-map-parity.e2e.test.ts` — the only workspace that declares
// both `stash` and `@cipherstash/wizard`, and whose `typecheck` script compiles
// the cross-package import.
describe('SKILL_MAP', () => {
it('selects only skills that contain a bundled SKILL.md', () => {
for (const integration of ALL_INTEGRATIONS) {
const available = new Set(availableSkills(integration))
const skills = SKILL_MAP[integration]
for (const skill of skills) {
expect(available.has(skill), `${integration}: ${skill}/SKILL.md`).toBe(
true,
)
}
}
})

it('has a non-empty entry for every integration (no undefined → crash)', () => {
for (const integration of ALL_INTEGRATIONS) {
const skills = SKILL_MAP[integration]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/init/lib/setup-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading