diff --git a/.changeset/adapter-split-skills.md b/.changeset/adapter-split-skills.md index 772eeb21d..06806839d 100644 --- a/.changeset/adapter-split-skills.md +++ b/.changeset/adapter-split-skills.md @@ -5,7 +5,7 @@ Update the bundled `stash-drizzle`, `stash-supabase`, and `stash-encryption` agent skills (and the stack README / Supabase reference doc) for the adapter package split: the Drizzle and Supabase integrations import from `@cipherstash/stack-drizzle` -(+ `/v3`) and `@cipherstash/stack-supabase` respectively, installed alongside +and `@cipherstash/stack-supabase` respectively, installed alongside `@cipherstash/stack`, rather than from `@cipherstash/stack/{drizzle,supabase,eql/v3/drizzle}` subpaths. Skills ship inside the `stash` tarball, so the stale import paths would otherwise become wrong guidance in a user's project. diff --git a/.changeset/cli-eql-v3-single-bundle.md b/.changeset/cli-eql-v3-single-bundle.md index 25db56023..438b5e91c 100644 --- a/.changeset/cli-eql-v3-single-bundle.md +++ b/.changeset/cli-eql-v3-single-bundle.md @@ -2,7 +2,7 @@ 'stash': minor --- -`stash eql install --eql-version 3` now installs the eql-3.0.0 GA bundle, +`stash eql install` now installs the eql-3.0.0 GA bundle, vendored from the pinned `@cipherstash/eql` package (sha256-verified). Since eql-3.0.0 one artifact installs everywhere: the operator-class diff --git a/.changeset/cli-v2-cutover-prompt-correction.md b/.changeset/cli-v2-cutover-prompt-correction.md deleted file mode 100644 index 4a90e2f6b..000000000 --- a/.changeset/cli-v2-cutover-prompt-correction.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'stash': patch ---- - -`stash init`'s setup prompt no longer tells agents to declare an EQL v2 cutover -column with a `types.*` domain. - -The `types.*` factories are EQL v3 only — a v2 column is an `eql_v2_encrypted` -composite — so an agent following the old step-4 guidance would author a schema -that cannot describe the column it just cut over to. The prompt now says -explicitly not to use `types.*` for a v2 column, and points at the deprecated -`@cipherstash/stack/schema` builders with decryption through -`@cipherstash/stack`, which is the actual read path for legacy v2 rows. - -Superseded later in this release: `stash encrypt cutover` and all v2 mutation guidance are removed; legacy v2 remains read-only. diff --git a/.changeset/decrypt-chaining-docs.md b/.changeset/decrypt-chaining-docs.md index 36afc506b..2b7d6a230 100644 --- a/.changeset/decrypt-chaining-docs.md +++ b/.changeset/decrypt-chaining-docs.md @@ -49,15 +49,11 @@ other two to guess: object-shorthand that read as three required strings — `queryType` is inferred from the column's configured indexes. -The cutover and complete-rollout **plan templates** now split EQL v3 from v2. -Both described the v2 rename swap (`` → `_plaintext`, twin → ``) -as the only cutover path, so on the default v3 install `stash plan` drafted a -plan built around `stash encrypt cutover` — a command that refuses v3 columns -outright ("there is no rename step") and refuses entirely on a v3-only -database, where `eql_v2_configuration` does not exist. The implement prompt -already carried this split; the plan templates did not. The version is -per-column, so the templates tell the agent to establish it per column rather -than deciding once for the whole plan. +The cutover and complete-rollout **plan templates** now describe the EQL v3 +rollout. Both described a rename swap (`` → `_plaintext`, twin → +``) as the only cutover path, which EQL v3 does not have — the application +switches to the encrypted column by name. The implement prompt already carried +the v3 story; the plan templates did not. The "already encrypted" stop-and-ask now recognises `eql_v3_*` domains alongside the legacy `eql_v2_encrypted` udt, so it can fire on the default diff --git a/.changeset/encrypt-client-guard-parity.md b/.changeset/encrypt-client-guard-parity.md index 63c3a2664..68578a39b 100644 --- a/.changeset/encrypt-client-guard-parity.md +++ b/.changeset/encrypt-client-guard-parity.md @@ -6,9 +6,9 @@ initialized encrypt config, instead of reporting a missing table. The guard that refuses an unusable client file existed twice — once in -`loadEncryptConfig` (`stash db push` / `db validate`) and once, hand-copied, in +`loadEncryptConfig` (`stash db validate`) and once, hand-copied, in `loadEncryptionContext` (`stash encrypt backfill`). The copies had already -drifted: for a client whose `getEncryptConfig()` returns nothing, `db push` +drifted: for a client whose `getEncryptConfig()` returns nothing, `db validate` exited 1 with `Encryption client in has no initialized encrypt config`, while `encrypt backfill` fell through to `Table "users" was not found in the encryption client exports. Available: (none)` — naming the symptom rather than diff --git a/.changeset/encrypt-lifecycle-mixed-table.md b/.changeset/encrypt-lifecycle-mixed-table.md deleted file mode 100644 index f6b6273bd..000000000 --- a/.changeset/encrypt-lifecycle-mixed-table.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'stash': patch ---- - -`stash encrypt cutover` and `stash encrypt drop` no longer act on — or report -success for — an encrypted column they only guessed at. - -On a table holding both a legacy EQL v2 pair (`ssn` / `ssn_encrypted`) and one -unrelated EQL v3 column, the v2 ciphertext column is not classified as an EQL -column at all, so column resolution fell through to the "this is the table's -only EQL column" rule and claimed the unrelated v3 column. Three consequences, -all now fixed: - -- **`cutover` reported success for work it never did.** Its EQL v3 branch had no - guard on how the column was resolved, and returned without setting an exit - code — so it printed "point your application at `email_enc`" and exited 0 - while the v2 rename never ran. A scripted rollout read that as complete. It - now refuses, and exits 1, exactly as `drop` already did. - -- **The recorded pairing was discarded.** `encrypt backfill` writes the true - `encryptedColumn` to `.cipherstash/migrations.json`, so the answer was already - on disk — but a hint that failed to resolve was dropped entirely and the - re-resolution reached the guess. A hint naming a column that still exists but - is not an EQL v3 column is now reported as what it is (most often a legacy - `eql_v2_encrypted` counterpart) instead of being replaced by a guess. A - genuinely stale hint — one naming a column that is gone — still falls back to - the naming convention as before. - -- **`drop`'s refusal message prescribed the guess.** It told the user to re-run - `backfill --encrypted-column `. Following it recorded the - guess as fact, so the next run resolved "by hint", walked past the refusal, - and passed the coverage check vacuously — an unrelated but legitimately - backfilled column is non-NULL on every row — then generated a live - `DROP COLUMN` on the plaintext and exited 0. The message now asks for the - column that actually encrypts the named one, and says explicitly not to record - the guess. - -The `unresolvedHint` refusal is scoped to tables that actually hold EQL v3 -columns a guess could wrongly claim. A **pure-v2** table has none, so it still -falls through to the EQL v2 lifecycle exactly as before — including when -`encrypt backfill` recorded an `encryptedColumn` for it, which it does for v2 -columns too. - -Two cases DO newly exit 1, both deliberately: - -- Any table with at least one EQL v3 column where the manifest records an - `encryptedColumn` that exists but is not one of them — not only the - v2-pair-plus-one-v3 shape. The recorded pairing is authoritative and - disagrees with every candidate, so guessing past it is the bug. - -- A column whose encrypted counterpart could only be identified **by - elimination** — no recorded `encryptedColumn`, no `_encrypted` name - match, one EQL column left once the plaintext column itself is excluded. - `cutover` now refuses this as `drop` already did. Note `.cipherstash/` is - gitignored, so `migrations.json` is machine-local: a fresh clone or CI runner - can hit this on a **pure-v3** table whose encrypted column is named - unconventionally, where `cutover` previously exited 0 with "not applicable". - Re-run `stash encrypt backfill --table T --column C --encrypted-column ` - to record the pairing. - -Superseded later in this release: no v2 lifecycle can be driven by `stash encrypt`; mixed and pure-v2 state now fail with migration/recovery guidance. diff --git a/.changeset/eql-v3-adapter-type-robustness.md b/.changeset/eql-v3-adapter-type-robustness.md index f7cddd986..e2954a322 100644 --- a/.changeset/eql-v3-adapter-type-robustness.md +++ b/.changeset/eql-v3-adapter-type-robustness.md @@ -1,38 +1,35 @@ --- -'@cipherstash/stack': minor +'@cipherstash/stack-drizzle': minor +'@cipherstash/stack-supabase': minor --- Restore the EQL v3 envelope and `Result` types the adapters were erasing. -Both v3 adapters typed their operand-encryption paths as `unknown` and dropped -the `Result` wrapper, so the query-type encoding and the failure channel were +Both adapters typed their operand-encryption paths as `unknown` and dropped the +`Result` wrapper, so the query-type encoding and the failure channel were invisible to the type system: -- `eql/v3/drizzle/operators.ts` typed the client's `encrypt`/`bulkEncrypt` as +- The Drizzle operator module typed the client's `encrypt`/`bulkEncrypt` as returning `unknown`, collapsed the operation's `Result` to `{ data?: unknown; failure?: { message } }`, and cast the bulk response to `Array<{ data: unknown }>`. -- `supabase/query-builder-v3.ts` returned `Promise` from - `encryptCollectedTerms`, `bulkEncryptGroup` and `encryptGroupPerTerm`, and the - base `query-builder.ts` did the same. +- The Supabase query builder returned `Promise` from + `encryptCollectedTerms`, `bulkEncryptGroup` and `encryptGroupPerTerm`. These now carry the SDK's real types — `Encrypted` (the storage envelope union, which includes every v3 per-domain payload), `BulkEncryptedData`, and `EncryptedQueryResult` — threaded through a properly-typed operation surface that -resolves `Result`. The Supabase divergence the erasure hid is -now explicit: the v2 path yields `encryptQuery` composite literals and the v3 -path yields `JSON.stringify`'d envelope strings, and both are `EncryptedQueryResult`. +resolves `Result`. -Bumped `minor`, not `patch`: `createEncryptionOperatorsV3` is a public export -(`@cipherstash/stack/eql/v3/drizzle`), and tightening its client contract from -`unknown` to a typed operation surface is a compile-time breaking change — a -downstream consumer passing a loosely-typed (`unknown`-returning) client double -will now fail `tsc`. That tightening has teeth: `operators.test-d.ts` pins it -with a negative type-test asserting an `unknown`-returning `{ encrypt }` double -is rejected (a positive "correctly-typed double is accepted" assertion cannot -catch a re-erasure, since a correct value is assignable to `unknown`). +Tightening `createEncryptionOperators`' client contract from `unknown` to a typed +operation surface is a compile-time breaking change for a downstream consumer +passing a loosely-typed (`unknown`-returning) client double: it will now fail +`tsc`. That tightening has teeth — `operators.test-d.ts` pins it with a negative +type-test asserting an `unknown`-returning `{ encrypt }` double is rejected (a +positive "correctly-typed double is accepted" assertion cannot catch a +re-erasure, since a correct value is assignable to `unknown`). -Behaviour is otherwise unchanged, with one addition: the Supabase v3 bulk path -now rejects a `null` envelope returned by `bulkEncrypt` (the restored +Behaviour is otherwise unchanged, with one addition: the Supabase bulk path now +rejects a `null` envelope returned by `bulkEncrypt` (the restored `Encrypted | null` type makes that arm reachable, and a `null` would otherwise be `JSON.stringify`'d to the literal `"null"` and sent as a filter operand). diff --git a/.changeset/eql-v3-cli-install.md b/.changeset/eql-v3-cli-install.md deleted file mode 100644 index 264f449d9..000000000 --- a/.changeset/eql-v3-cli-install.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"stash": minor ---- - -Add an EQL v3 install path to `stash eql install` via a new `--eql-version <2|3>` -flag (default `2`). v3 installs the native concrete-domain schema (`public.*` -type domains, `eql_v3` operators, `eql_v3_internal` constructors) from bundles -vendored into `packages/cli/src/sql` by `scripts/build-eql-v3-sql.mjs` (full -bundle + a Supabase variant with the two superuser-only operator-class chunks -stripped). v3 currently supports the direct install path only — -`--drizzle`/`--migration`/`--migrations-dir`/`--latest` are rejected — and the -installer keys `isInstalled`/version checks and Supabase grants to the `eql_v3` -schema. - -Superseded later in this release: `--eql-version` and the v2 installer are removed; installs and upgrades are v3-only. diff --git a/.changeset/eql-v3-drizzle-encrypt-query.md b/.changeset/eql-v3-drizzle-encrypt-query.md index 9fb894b91..075e6d887 100644 --- a/.changeset/eql-v3-drizzle-encrypt-query.md +++ b/.changeset/eql-v3-drizzle-encrypt-query.md @@ -5,8 +5,8 @@ EQL v3 Drizzle: encrypt every query operand with `encryptQuery`, not `encrypt` (#622). -The v3 Drizzle operators (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`notBetween`/ -`inArray`/`notInArray`/`contains`) previously encrypted their operands with +The Drizzle operators (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`notBetween`/ +`inArray`/`notInArray`/`matches`/`contains`) previously encrypted their operands with `client.encrypt`, producing a full storage envelope (including the ciphertext `c`) cast to `::jsonb`. A WHERE-clause operand should be a query *term*, not a value to store. Every operator now uses `client.encryptQuery`, which yields a diff --git a/.changeset/eql-v3-drizzle-fail-open-guards.md b/.changeset/eql-v3-drizzle-fail-open-guards.md index 990508580..ee7fb142e 100644 --- a/.changeset/eql-v3-drizzle-fail-open-guards.md +++ b/.changeset/eql-v3-drizzle-fail-open-guards.md @@ -1,10 +1,10 @@ --- -'@cipherstash/stack': minor +'@cipherstash/stack-drizzle': minor --- -Close two fail-open paths in the EQL v3 Drizzle adapter. +Close two fail-open paths in the Drizzle adapter. -`ops.contains()` now throws `EncryptionOperatorError` for a search term that +`ops.matches()` now throws `EncryptionOperatorError` for a search term that tokenizes to nothing: the empty string, or a term shorter than the match index tokenizer's `token_length` (3 by default). Such a term produces an empty bloom filter, and `stored_bf @> '{}'` is true for every row — so a user searching @@ -16,8 +16,9 @@ The floor counts Unicode codepoints, matching the tokenizer. A UTF-16 length check would wave through an astral-plane term — `"👍👍"` is 4 code units but only 2 codepoints, yields no trigram, and matched every row. -**Breaking for callers passing short terms:** `contains()` calls that previously -returned every row now throw. Terms of 3+ codepoints are unaffected. +**Breaking for callers passing short terms:** free-text calls that previously +returned every row now throw. Terms at or above the configured `token_length` +are unaffected. `v3FromDriver()` now throws the new `EqlV3CodecError` on a payload that is not an EQL envelope, instead of surfacing a raw `SyntaxError` for malformed JSON and @@ -27,12 +28,7 @@ accepts both scalar envelopes (ciphertext at `c`) and SteVec documents (ciphertext at `sv[0].c`). A SteVec's `sv` must be a non-empty array: `sv[0]` is the decryption root, so `sv: []` carries a ciphertext key but no ciphertext, and is now rejected rather than passed to `decrypt`. `EqlV3CodecError` is exported -from `@cipherstash/stack/eql/v3/drizzle` so callers can catch it. +from the `@cipherstash/stack-drizzle` package root so callers can catch it. Also removes an unreachable branch in `inArray`/`notInArray`, whose empty-list guard already throws before it. - -Note: the v2 Drizzle adapter's `like`/`ilike` path builds the same bloom filters -and has the same short-term fail-open. It is **not** fixed here — v2 terms carry -SQL wildcards, so the floor must be measured against what its tokenizer actually -receives before the shared guard can be reused. Tracked separately. diff --git a/.changeset/eql-v3-drizzle.md b/.changeset/eql-v3-drizzle.md index 0ec9d099a..7b1c8a8f0 100644 --- a/.changeset/eql-v3-drizzle.md +++ b/.changeset/eql-v3-drizzle.md @@ -1,17 +1,18 @@ --- -"@cipherstash/stack": minor +'@cipherstash/stack-drizzle': minor --- -Add EQL v3 Drizzle support at `@cipherstash/stack/eql/v3/drizzle`. A Drizzle-native -`types` namespace (same PascalCase names as `@cipherstash/stack/eql/v3`) declares -encrypted columns whose Postgres type is the semantic `public.`; the concrete -type drives the legal query operators. `createEncryptionOperatorsV3` provides -capability-checked `eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`contains`/`inArray`/ -`asc`/`desc`/`and`/`or` that emit the latest two-argument `eql_v3` SQL functions with -full-envelope operands, and `extractEncryptionSchemaV3` rebuilds the schema for -`Encryption`. This surface was subsequently consolidated at the -`@cipherstash/stack-drizzle` package root. +EQL v3 Drizzle support on the `@cipherstash/stack-drizzle` package root. A +Drizzle-native `types` namespace (same PascalCase names as +`@cipherstash/stack/eql/v3`) declares encrypted columns whose Postgres type is +the semantic `public.eql_v3_`; the concrete type drives the legal query +operators. `createEncryptionOperators` provides capability-checked +`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`matches`/`contains`/`inArray`/ +`asc`/`desc`/`and`/`or` that emit the two-argument `eql_v3` SQL functions with +full-envelope operands, and `extractEncryptionSchema` rebuilds the schema for +`Encryption`. -The v3 text-search helper is `contains`; obsolete `like`/`ilike` helpers are not -exposed because v3 free-text search is token containment rather than SQL wildcard -matching. +The encrypted free-text helper is `matches`; obsolete `like`/`ilike` helpers are +not exposed, because encrypted free-text search is bloom-filter token matching +rather than SQL wildcard matching. `contains` is genuine encrypted-JSON +containment (`@>` against a `types.Json` column), not free-text. diff --git a/.changeset/eql-v3-ga-rebaseline.md b/.changeset/eql-v3-ga-rebaseline.md index b394d1741..68a6c1022 100644 --- a/.changeset/eql-v3-ga-rebaseline.md +++ b/.changeset/eql-v3-ga-rebaseline.md @@ -8,7 +8,7 @@ Re-baseline EQL v3 on the eql-3.0.0 GA release and protect-ffi 0.29. eql-3.0.0 naming convention — flat, prefixed names in `public` (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, …) instead of the alpha-era bare names. Databases installed from an alpha bundle must be - re-installed (`stash eql install --eql-version 3` replaces the schema). + re-installed (`stash eql install` replaces the schema). - `encryptQuery` on the EQL v3 client now returns EQL v3 query operands (protect-ffi 0.29): term-only scalar operands for the `eql_v3.query_` domains, the `eql_v3.query_jsonb` containment needle, and bare selector diff --git a/.changeset/eql-v3-rename-contains-to-matches.md b/.changeset/eql-v3-rename-contains-to-matches.md index 2e576b464..119a8214d 100644 --- a/.changeset/eql-v3-rename-contains-to-matches.md +++ b/.changeset/eql-v3-rename-contains-to-matches.md @@ -11,7 +11,7 @@ containment. The name `contains()` promised substring/containment semantics it never had. It is renamed to `matches()` on the encrypted surface; `contains()` is kept for genuine, exact containment: -- **Drizzle** (`@cipherstash/stack-drizzle/v3`): `matches()` = bloom free-text on +- **Drizzle** (`@cipherstash/stack-drizzle`): `matches()` = bloom free-text on `text_match`/`text_search` columns; `contains()` = exact encrypted-JSON `@>` on `types.Json` (ste_vec) columns. - **Supabase** (`@cipherstash/stack-supabase`): `.matches()` = encrypted free-text; diff --git a/.changeset/eql-v3-sole-docs.md b/.changeset/eql-v3-sole-docs.md index baa9edf10..4b3b14019 100644 --- a/.changeset/eql-v3-sole-docs.md +++ b/.changeset/eql-v3-sole-docs.md @@ -15,14 +15,13 @@ stored EQL v2 data is still reachable there: corresponding v3 table and `{ storedEqlVersion: 2 }`, so the `stash-dynamodb` skill documents that explicit compatibility path (#657). - **The encrypt rollout lifecycle.** `stash encrypt *` and `@cipherstash/migrate` - detect a column's generation from its Postgres domain type, with EQL v3 as the - recognised and default generation; a legacy `eql_v2_encrypted` column does not - classify and falls through to the v2 lifecycle, which ends in - `stash encrypt cutover` rather than the v3 `stash encrypt drop`. That - difference is kept under a version callout (#648). + classify a column from its Postgres domain type: a `public.eql_v3_*` domain is + recognised as v3, and anything else — including a legacy `eql_v2_encrypted` + column — does not classify. The documented lifecycle is the v3 one + (backfill → switch the application to the encrypted column → drop the + plaintext); legacy v2 columns are read-only, covered under a version callout + (#648). Also corrects the legacy `@cipherstash/drizzle` README's pointer to the removed `@cipherstash/stack/drizzle` subpath (now the separate `@cipherstash/stack-drizzle` package). - -Superseded later in this release: CLI/migrate v2 mutation guidance is removed; only legacy ciphertext reads and status diagnostics remain. diff --git a/.changeset/eql-v3-supabase-adapter.md b/.changeset/eql-v3-supabase-adapter.md index 418d410f0..f8d6a61fe 100644 --- a/.changeset/eql-v3-supabase-adapter.md +++ b/.changeset/eql-v3-supabase-adapter.md @@ -1,68 +1,46 @@ --- -'@cipherstash/stack': minor +'@cipherstash/stack-supabase': minor --- -Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter. It is -now a connect-time-async factory: `await encryptedSupabaseV3(url, key)` (or -`(client)`) introspects the database over `DATABASE_URL`, detects EQL v3 columns -by their Postgres domain (`information_schema.columns.domain_name`), and derives -each column's encryption config from its domain — callers no longer pass a -schema to `from()`. `select('*')` is supported (expanded from the introspected -column list, and aliased back to each declared column's JS property name so a -property→DB rename round-trips). A column using an EQL v3 domain this SDK version does not model -(e.g. `public.json`, `*_ord_ope`) throws at construction rather than silently -passing through. Supplying `schemas` remains optional and adds compile-time -types plus startup verification of the declared tables against the database. -Requires a Postgres connection for introspection (`pg` is a new optional peer), -so it cannot run in a Worker or the browser. +`encryptedSupabase` is a connect-time-async, introspecting EQL v3 factory: +`await encryptedSupabase(url, key)` (or `(client)`) introspects the database over +`DATABASE_URL`, detects EQL v3 columns by their Postgres domain +(`information_schema.columns.domain_name`), and derives each column's encryption +config from its domain — callers no longer pass a schema to `from()`. +`select('*')` is supported (expanded from the introspected column list, and +aliased back to each declared column's JS property name so a property→DB rename +round-trips). A column using a `public.eql_v3_*` domain this SDK version does not +model throws when its table is named via `from()` rather than silently passing +through. Supplying `schemas` remains optional and adds compile-time types plus +eager construction-time verification of the declared tables against the +database — including that same unmodelled-column check. Requires a Postgres connection for +introspection (`pg` is an optional peer), so it cannot run in a Worker or the +browser. Every column name a query carries — filters, `match`, `not`, raw `filter`, -`or()`, `order()`, and the `onConflict` option — is now resolved from its JS -property name to its DB column name in a single pass before the query is built, -so a declared rename round-trips everywhere rather than only on the paths that +`or()`, `order()`, and the `onConflict` option — is resolved from its JS property +name to its DB column name in a single pass before the query is built, so a +declared rename round-trips everywhere rather than only on the paths that remembered to translate. -`order()` on ANY encrypted v3 column is now rejected — at compile time when -`schemas` is supplied, and at runtime otherwise. The EQL v3 domains are -`DOMAIN … AS jsonb` and the bundle declares no btree operator class on them, so -`ORDER BY col` resolves through jsonb's default `jsonb_cmp` and sorts by the -envelope's byte structure: a stable, plausible-looking, meaningless row order, -with no error. Correct ordering is `ORDER BY eql_v3.ord_term(col)`, which -PostgREST's `order=` cannot express. Order by a plaintext column, expose -`eql_v3.ord_term()` as a generated column or view, or use the EQL v3 Drizzle -integration, which emits `ord_term` directly. Note `gte`/`lte` filters remain -correct: the comparison operators *are* declared on the ord domains, and only -sorting resolves through the missing operator class. - -`.or()` now understands PostgREST's `column.not..` negation. It was +`.or()` understands PostgREST's `column.not..` negation. It was previously parsed as `{ op: 'not', value: '.' }`, so on an encrypted column `or('nickname.not.in.(ada,grace)')` encrypted the literal string `in.(ada,grace)` as a single plaintext and produced a filter that silently matched nothing. -Free-text search on the v3 builder is `contains(column, value)`. `like`/`ilike` -are not exposed, because EQL v3 free-text search is token containment over a -bloom filter (`@>`, backed by `eql_v3.contains`) rather than SQL wildcard -matching — `%` is tokenized like any other character, so a `like` pattern is a -category error. This matches the v3 Drizzle integration, which omits them for -the same reason. On an encrypted column `like`/`ilike` now throw and name -`contains`; on a plaintext column they remain ordinary PostgREST filters. - -`contains` is narrowed at compile time to columns whose domain carries the -`freeTextSearch` capability (`public.text_match`, `public.text_search`), and -guarded at runtime for the untyped surface. A raw `filter(column, operator, …)` -on an encrypted v3 column now derives its query type from the operator instead -of always encrypting an equality term, so `filter('bio', 'cs', …)` on a -`public.text_match` column works rather than being rejected, and an unsupported -operator throws instead of silently encrypting the wrong term. - -Substring `contains` matches any needle whose trigrams are all present in the -stored value; needles shorter than the tokenizer's window (3 characters) bloom to -nothing and are rejected rather than silently matching every row. The v3 match -index now emits `include_original: false` — the flag is inert in protect-ffi (the -bloom is trigram-only either way), so this moves no ciphertext and only pins the -value a substring-search domain wants. - -This surface was subsequently consolidated as the unsuffixed -`encryptedSupabase` EQL v3 factory. The legacy v2 Supabase authoring wrapper has -been removed; core native decrypt remains compatible with stored v2 payloads. +Encrypted free-text search is `matches(column, value)`, narrowed at compile time +to columns whose domain carries the `freeTextSearch` capability +(`public.eql_v3_text_match`, `public.eql_v3_text_search`) and guarded at runtime +for the untyped surface. It matches any needle whose trigrams are all present in +the stored value; needles shorter than the tokenizer's window (3 characters) +bloom to nothing and are rejected rather than silently matching every row. A raw +`filter(column, operator, …)` on an encrypted column derives its query type from +the operator instead of always encrypting an equality term, so +`filter('bio', 'cs', …)` on a `public.eql_v3_text_match` column works rather than +being rejected, and an unsupported operator throws instead of silently encrypting +the wrong term. + +The v3 match index emits `include_original: false` — the flag is inert in +protect-ffi (the bloom is trigram-only either way), so this moves no ciphertext +and only pins the value a substring-search domain wants. diff --git a/.changeset/eql-v3-wasm-inline.md b/.changeset/eql-v3-wasm-inline.md index c67e659cc..9b1e2c88f 100644 --- a/.changeset/eql-v3-wasm-inline.md +++ b/.changeset/eql-v3-wasm-inline.md @@ -9,7 +9,7 @@ created a client pinned to the FFI's EQL v2 wire format, so a v3 schema (concrete `eql_v3_*` domains) failed every encrypt on the edge. It now targets EQL v3 exclusively: -- The factory constructs the WASM client with `eqlVersion: 3`, so v3 schemas +- The factory builds an EQL v3 WASM client, so v3 schemas encrypt/decrypt correctly on the edge. - The entry re-exports the **v3** authoring surface (`types`, `encryptedTable`, the column classes, `buildEncryptConfig`, and the inference helpers) — the diff --git a/.changeset/init-drizzle-eql-v3.md b/.changeset/init-drizzle-eql-v3.md index 5e54b75e2..184409944 100644 --- a/.changeset/init-drizzle-eql-v3.md +++ b/.changeset/init-drizzle-eql-v3.md @@ -2,16 +2,13 @@ 'stash': patch --- -`stash init --drizzle` now installs EQL v3 instead of v2. +`stash init --drizzle` installs EQL v3. -The Drizzle init flow pinned `--eql-version 2`, because `stash eql install ---drizzle` (the only migration-generating install path at the time) was -v2-only. That made `stash init --drizzle` the single flow that provisioned a v2 -database — a bare `stash eql install`, and init for every other integration, -already defaulted to v3. It also contradicted the `stash-drizzle` skill init -copies into the same project, which documents the v3 `@cipherstash/stack-drizzle` -surface (`types.*` domains, `Encryption`) and would have the user's agent -author v3 code against a v2 database. +The Drizzle init flow used to provision a v2 database — the only +migration-generating install path at the time was v2-only — while the +`stash-drizzle` skill init copies into the same project documents the v3 +`@cipherstash/stack-drizzle` surface (`types.*` domains, `Encryption`). The +user's agent would have authored v3 code against a v2 database. Init's Drizzle flow now routes through `stash eql migration --drizzle`, so it stays migration-first (the install lands in your Drizzle migration history and @@ -21,7 +18,6 @@ The generated migration also carries the `cs_migrations` tracking schema, so one isn't installed or configured, init now reports EQL as not installed and points at `stash eql migration --drizzle` rather than aborting the run. -The final CLI installation and mutation surface is v3-only: the explicit v2 -Drizzle install path is removed. Legacy v2 remains readable and visible in -diagnostics. Generate a checked-in install migration with `stash eql migration ---drizzle`. +The CLI installation and mutation surface is v3-only. Legacy v2 remains readable +and visible in diagnostics. Generate a checked-in install migration with +`stash eql migration --drizzle`. diff --git a/.changeset/init-placeholder-eql-v3.md b/.changeset/init-placeholder-eql-v3.md index 6c75a89c1..28e4bcf3d 100644 --- a/.changeset/init-placeholder-eql-v3.md +++ b/.changeset/init-placeholder-eql-v3.md @@ -13,9 +13,6 @@ customer's coding agent v2 guidance against a v3 schema (follow-up to #732 / Scaffolds now teach the v3 surface: `Encryption` from `@cipherstash/stack/v3`, the concrete-domain `types.*` factories (`types.TextSearch`, `types.IntegerOrd`, -`types.Text`, `types.Json`, …), and the `@cipherstash/stack-drizzle/v3` entry -(`extractEncryptionSchemaV3`) for Drizzle. The `encryptionClient` export shape -and the empty-schema "no schemas yet" error path are unchanged. - -The scaffolds emit `extractEncryptionSchema` from the collapsed -`@cipherstash/stack-drizzle` root. +`types.Text`, `types.Json`, …), and `extractEncryptionSchema` from the +`@cipherstash/stack-drizzle` package root for Drizzle. The `encryptionClient` +export shape and the empty-schema "no schemas yet" error path are unchanged. diff --git a/.changeset/init-scaffold-compiles.md b/.changeset/init-scaffold-compiles.md index 3de825aea..cf6b76338 100644 --- a/.changeset/init-scaffold-compiles.md +++ b/.changeset/init-scaffold-compiles.md @@ -13,16 +13,13 @@ the non-empty schema requirement. The scaffold now declares a single sentinel table, `__stash_placeholder__`, so the file typechecks as written. Every command that reads the encryption client -— `stash db push`, `stash db validate`, and `stash encrypt backfill` — refuses -to run while that table is still the only one declared, and names it, rather -than failing later with a confusing "table not found". (`stash encrypt cutover` -and `stash encrypt drop` do not read the client file at all; they resolve -against the database.) +— `stash db validate` and `stash encrypt backfill` — refuses to run while that +table is still the only one declared, and names it, rather than failing later +with a confusing "table not found". (`stash encrypt drop` does not read the +client file at all; it resolves against the database.) Nothing in the repo compiled this output before: `packages/cli` has no typecheck step, the codegen tests only string-match fragments of the template, and the step test stubs the generator out entirely. Both templates are now committed as fixtures that CI typechecks, pinned byte-for-byte to the generator so they cannot drift. - -Superseded later in this release: the generated guidance no longer references removed `db push` or `encrypt cutover` commands. diff --git a/.changeset/migrate-column-exists.md b/.changeset/migrate-column-exists.md index d88867430..33870eaae 100644 --- a/.changeset/migrate-column-exists.md +++ b/.changeset/migrate-column-exists.md @@ -11,11 +11,11 @@ Callers need that difference to tell a STALE column reference (it is gone) from a live one the domain classifier simply does not recognise — most often a legacy `eql_v2_encrypted` counterpart. -`stash encrypt cutover` / `drop` had a private copy of this probe built on a bare +`stash encrypt drop` had a private copy of this probe built on a bare `to_regclass($1)`. That form *parses* its argument and case-folds unquoted identifiers, so on a Prisma-style `"User"` table it resolved `user`, reported the column missing, and treated a valid recorded pairing as stale — silently skipping -the fail-closed that stops those commands acting on a guessed encrypted column. +the fail-closed that stops the command acting on a guessed encrypted column. The shared implementation quotes with `format('%I')` first, like every other catalog probe in this package, so the lookup is case-exact while still honouring `search_path` for unqualified names. diff --git a/.changeset/migrate-eql-v3.md b/.changeset/migrate-eql-v3.md index e43ea380e..97afadbba 100644 --- a/.changeset/migrate-eql-v3.md +++ b/.changeset/migrate-eql-v3.md @@ -16,17 +16,11 @@ right lifecycle, no new flags: database, including the domain CHECK and a decrypt round-trip). The manifest records the detected version, the encrypted column's name, and the v3 target phase, and the command prints v3-appropriate next steps. -- **`encrypt cutover`** on a backfilled v3 column reports "not applicable" - (exit 0) with guidance: v3 has no rename cut-over — the application - switches to the encrypted column by name. Before backfill completes it - exits 1 and says to finish the backfill instead of instructing the switch. - On a database with no `eql_v2_configuration` table (a v3-only install) the - v2 path now explains that instead of surfacing a raw Postgres error. - **`encrypt drop`** is version-aware: v3 runs from the `backfilled` phase, **verifies live coverage** (refuses to generate the migration while any row still has the plaintext set and the encrypted column NULL — the `countUnencrypted` check), and drops the ORIGINAL plaintext column (there - is no `_plaintext` under v3); v2 behaviour is unchanged. The generated + is no `_plaintext` under v3). The generated v3 migration **re-verifies coverage at apply time** — it locks the table, re-counts, and aborts without dropping if plaintext-only rows appeared after generation. And because dropping is the one irreversible step, it @@ -34,7 +28,7 @@ right lifecycle, no new flags: manifest's recorded `encryptedColumn` or the naming convention): a match found only by being the table's sole EQL column is refused with instructions, and an ambiguous table (several EQL columns, none - identifiable) fails closed listing the candidates — as does `cutover`. + identifiable) fails closed listing the candidates. - **`encrypt status`** classifies each column from the observed domain type (manifest as fallback), shows `v3` in the EQL column, and no longer raises the v2-only `not-registered` / `plaintext-col-missing` drift flags for v3 @@ -49,14 +43,11 @@ right lifecycle, no new flags: matching the manifest and the installer. Resolved columns carry `via: 'hint' | 'convention' | 'sole'` so callers can tell a positively asserted pairing from a by-elimination guess. -- Fixed: `encrypt cutover`/`encrypt drop` precondition failures now actually - exit 1 — the early-return guards previously skipped the exit-code path - entirely, so failed preconditions exited 0. (This also applies to v2 - preconditions: scripted pipelines that relied on the erroneous exit 0 will - now see the documented exit 1.) +- Fixed: `encrypt drop` precondition failures now actually exit 1 — the + early-return guards previously skipped the exit-code path entirely, so failed + preconditions exited 0. Scripted pipelines that relied on the erroneous exit 0 + will now see the documented exit 1. The `stash-cli` and `stash-encryption` skills and the `@cipherstash/migrate` -README document the two lifecycles (v2: backfill → cutover → drop; -v3: backfill → switch-by-name → drop). - -Superseded later in this release: `@cipherstash/migrate` and the CLI now author and mutate v3 only; legacy v2 manifest fields remain readable. +README document the v3 lifecycle: backfill → switch the application to the +encrypted column by name → drop the plaintext column. diff --git a/.changeset/pre.json b/.changeset/pre.json index 89dab5375..4bc0470ae 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,5 +1,5 @@ { - "mode": "pre", + "mode": "exit", "tag": "rc", "initialVersions": { "@cipherstash/e2e": "0.0.2", diff --git a/.changeset/remove-eql-v2-drizzle-root-consumers.md b/.changeset/remove-eql-v2-drizzle-root-consumers.md new file mode 100644 index 000000000..a9001d0a7 --- /dev/null +++ b/.changeset/remove-eql-v2-drizzle-root-consumers.md @@ -0,0 +1,13 @@ +--- +'stash': patch +'@cipherstash/stack': patch +--- + +Follow the `@cipherstash/stack-drizzle` package-root collapse in the packages that +document it. + +- **`stash`:** `stash init --drizzle` emits the package-root + `extractEncryptionSchema` import, and the bundled `stash-drizzle` and + `stash-encryption` skills match. +- **`@cipherstash/stack`:** README only — its Drizzle section documents the + package-root exports. diff --git a/.changeset/remove-eql-v2-drizzle-root.md b/.changeset/remove-eql-v2-drizzle-root.md index eb06bd693..c713ad68a 100644 --- a/.changeset/remove-eql-v2-drizzle-root.md +++ b/.changeset/remove-eql-v2-drizzle-root.md @@ -1,7 +1,5 @@ --- '@cipherstash/stack-drizzle': major -'@cipherstash/stack': patch -'stash': patch --- Remove the EQL v2 authoring surface from `@cipherstash/stack-drizzle` and collapse the EQL v3 `./v3` subpath into the package root. @@ -17,10 +15,3 @@ Remove the EQL v2 authoring surface from `@cipherstash/stack-drizzle` and collap 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` 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 now -documents the final package-root exports. diff --git a/.changeset/rewriter-never-drops-ciphertext.md b/.changeset/rewriter-never-drops-ciphertext.md index e32890e4c..25bbee069 100644 --- a/.changeset/rewriter-never-drops-ciphertext.md +++ b/.changeset/rewriter-never-drops-ciphertext.md @@ -36,11 +36,8 @@ rather than rewritten, so the ADD+DROP+RENAME no longer drops a column that the migration corpus itself shows already holds ciphertext. That is a guarantee about what the corpus says, not about the database: the sweep reasons entirely from migration files, and a database that has drifted from its migration -history is outside what it can see. `stash encrypt cutover` is the sharpest -example — it renames columns directly in the database and never writes drizzle -SQL, so the corpus can still describe a column as plaintext after cutover has -made it ciphertext; the same is true of any change made by hand via psql or the -Supabase dashboard. If your migration history is squashed, the column's +history is outside what it can see: a column encrypted by hand via psql or the +Supabase dashboard is still described as plaintext by the corpus. If your migration history is squashed, the column's `CREATE TABLE` lives outside the directory being swept, or the database has simply drifted from what the migrations describe, you will see the statement flagged instead of repaired: check the column's current type in the database diff --git a/.changeset/skills-eql-v3-accuracy.md b/.changeset/skills-eql-v3-accuracy.md index 75c9f55de..b892b60a0 100644 --- a/.changeset/skills-eql-v3-accuracy.md +++ b/.changeset/skills-eql-v3-accuracy.md @@ -10,16 +10,15 @@ rollout tooling was v2-only. Since these skills are copied into customer repos, stale text steered users away from v3 and toward workarounds they no longer need. - **`stash-drizzle`, `stash-supabase`** — replaced the "v3 not supported end-to-end" - callouts with an accurate EQL version note: the tooling auto-detects a column's - generation from its Postgres domain type, and the two lifecycles differ at the end. - v3 is `backfill → switch the app to the encrypted column by name → drop` with no - cut-over rename; v2 keeps the `stash encrypt cutover` rename plus config promotion. + callouts with an accurate EQL version note: the tooling classifies a column from + its Postgres domain type, and the documented lifecycle is + `backfill → switch the app to the encrypted column by name → drop` — there is no + cut-over rename. - **`stash-supabase`** — removed the "Interim path until #648: the v2 encrypted twin" section; a v2 twin is no longer needed to get CLI-managed backfill. - **`stash-drizzle`, `stash-supabase`** — the drop step now documents that - `stash encrypt drop` targets the *original* column under v3 (there is no - `_plaintext`, since nothing was renamed) and `_plaintext` under v2. -- **`stash-cli`** — corrected the documented `EQLInstaller` default: `eqlVersion` - defaults to `3`, not `2`, matching the `--eql-version` CLI default. Also reworded - the v2 cut-over known-gap note, which cited cipherstash/stack#585 as open tracking - when it was resolved by making v3 the default. + `stash encrypt drop` targets the *original* column (there is no + `_plaintext`, since nothing is renamed). +- **`stash-cli`** — corrected the documented `EQLInstaller` default (EQL v3) and + removed the v2 cut-over known-gap note, which cited cipherstash/stack#585 as open + tracking when it was resolved by making v3 the default. diff --git a/.changeset/skills-pin-stable-1-0.md b/.changeset/skills-pin-stable-1-0.md new file mode 100644 index 000000000..cfe7a9c6a --- /dev/null +++ b/.changeset/skills-pin-stable-1-0.md @@ -0,0 +1,21 @@ +--- +'stash': patch +--- + +The bundled skills pin `1.0.0`, not a release candidate (#791). + +`skills/stash-edge` hardcoded `@cipherstash/stack@1.0.0-rc.4` in its Deno `npm:` +import and its `deno.json` import map, and `skills/stash-cli` pinned the +bare-project `npx --package=stash@…` one-shot the same way. Nothing in the build +rewrites those literals — `tsup.config.ts` copies `skills/` verbatim, and the +`__STASH_RUNTIME_VERSIONS__` embed only reaches compiled CLI code — so the +published skill would have told Deno and Supabase Edge Function users to pin a +release candidate in production, in their own repo, indefinitely. + +The prerelease-semver paragraph that explained why `@^1.0.0` does not match +`1.0.0-rc.4` is gone with the rc pin; "pin exactly, Deno caches by specifier" +stands on its own. The `supabase-worker` example pins the same way. + +A test now guards every `skills/*/SKILL.md`: an exact pin of a release-train +package must name a stable version on the current major, so a stale rc pin fails +on the version-bump PR instead of shipping. diff --git a/.changeset/skills-v3-lifecycle-honesty.md b/.changeset/skills-v3-lifecycle-honesty.md deleted file mode 100644 index 2c29c1d0e..000000000 --- a/.changeset/skills-v3-lifecycle-honesty.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'stash': patch ---- - -Correct the EQL v2/v3 rollout lifecycle in the bundled `stash-encryption`, -`stash-supabase` and `stash-drizzle` agent skills. Each described the **v2** -lifecycle as the unqualified default even though v3 is the default generation, -so an agent following the prose would run steps that do not apply — and, in one -case, expect the wrong column to be dropped. - -- `stash encrypt drop` was documented as removing `_plaintext`. That is the - **v2** target. On a v3 column there is no `_plaintext`: the command drops - the **original ``**, guarded by a `DO` block that takes `ACCESS EXCLUSIVE` - and re-counts unencrypted rows at apply time, raising instead of dropping if - any remain. Each step in the cutover table is now marked v2-only or v3, and the - drop preconditions (`cut-over` for v2, `backfilled` for v3) are stated. -- "The pending row will be promoted to active by `stash encrypt cutover`" was - false for v3, where cutover short-circuits before touching any configuration. - `stash db activate` is the only promotion path there. -- The CipherStash Proxy call-outs told every reader to run `stash db push`. - `db push`/`db activate` manage `eql_v2_configuration`, which EQL v3 does not - ship — on a v3-only database `db push` reports "Nothing to do." and exits 0, - and `db activate` errors. The call-outs are now scoped to the EQL v2 + Proxy - path. - -Skills ship inside the `stash` tarball and are copied into user projects at -`stash init`, so this guidance was being installed into customer repos. - -Superseded later in this release: the bundled skills no longer document v2/Proxy mutation commands because those CLI paths are removed. diff --git a/.changeset/stack-1-0-0-rc.md b/.changeset/stack-1-0-0-rc.md index 555ae5c8e..aba65318a 100644 --- a/.changeset/stack-1-0-0-rc.md +++ b/.changeset/stack-1-0-0-rc.md @@ -5,7 +5,7 @@ 'stash': major --- -CipherStash Stack 1.0 (release candidate). +CipherStash Stack 1.0. This is the first 1.0-line release of `@cipherstash/stack`, the first published release of the split-out EQL v3 adapters `@cipherstash/stack-drizzle` and diff --git a/.changeset/stash-cli-eql-v3-default.md b/.changeset/stash-cli-eql-v3-default.md deleted file mode 100644 index b6eb6da19..000000000 --- a/.changeset/stash-cli-eql-v3-default.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"stash": minor ---- - -Default EQL to v3 and stop the CLI recommending `stash db push` (#585). - -- **EQL v3 is now the default.** `stash eql install` and `stash eql upgrade` target v3 (the native `eql_v3.*` domain schema) without `--eql-version 3`. The v2-only paths — `--drizzle`, `--migration`, `--migrations-dir`, and `--latest` — now require an explicit `--eql-version 2` and error with clear guidance otherwise (v3 installs via the direct path only). `stash init` pins v2 automatically when it drives the Drizzle migration flow. **Note:** for a Supabase project, `stash init` now runs a v3 direct install rather than offering the v2 migration-file flow; run `stash eql install --supabase --migration --eql-version 2` if you want a checked-in migration file. -- **`stash db push` is no longer recommended in CLI output.** `db push` writes the `public.eql_v2_configuration` table, which is a v2 + CipherStash Proxy artifact — EQL v3 has no configuration table (config lives in each column's `eql_v3.*` type) and nothing in the v3 stack reads it. The push recommendations are removed from `eql status`, the help banner, and the init/plan/cutover guidance. `db push` (and `db activate`) remain available for EQL v2 + Proxy users; they're now labelled as such. -- **`eql status` is v3-aware.** On a v3-only database it reports that encrypt config lives in the column types instead of hitting a "table not found" dead-end that told users to run `db push` (which neither creates that table nor applies to v3). -- **`stash db push` guards a v3-only database** with a clear "not needed under EQL v3" message instead of a raw `relation "public.eql_v2_configuration" does not exist` error. - -**Superseded later in this release:** the CLI no longer installs or mutates EQL -v2 state; `db push`, `db activate`, the Proxy choice, and the v2 install flags -described above are removed. Legacy state remains visible through status only. diff --git a/.changeset/stash-cli-skill-refresh.md b/.changeset/stash-cli-skill-refresh.md index a2e62330b..7d524448f 100644 --- a/.changeset/stash-cli-skill-refresh.md +++ b/.changeset/stash-cli-skill-refresh.md @@ -20,14 +20,9 @@ handoff time, so a stale skill becomes stale guidance in the user's project. entirely, plus the non-interactive interface (per-command escape hatches, exit codes, the `DATABASE_URL` resolution order, the `auth login --json` NDJSON event contract). - **Corrects the `db` → `eql` move.** `db install`, `db upgrade`, and `db status` are - deprecated aliases that warn and forward; `db push`, `db activate`, `db validate`, - `db test-connection`, and `db migrate` remain in the `db` group. -- **Scopes `db push` / `db activate` as EQL v2 + CipherStash Proxy only**, in both the skill - and the README's recommended flow. SDK users hold their encryption config in application - code and don't need them. -- Adds the missing `--database-url`, `--eql-version`, `--prisma-next`, `--proxy`/`--no-proxy`, - and `--region` flags; corrects six programmatic API signatures; fixes the README's claim + deprecated aliases that warn and forward; `db validate`, `db test-connection`, and + `db migrate` remain in the `db` group. +- Adds the missing `--database-url`, `--prisma-next`, and `--region` flags; corrects + six programmatic API signatures; fixes the README's claim that `stash init` ends in an agent-handoff menu (that belongs to `stash plan` / `stash impl`); and marks `stash env` as the non-functional stub it currently is. - -Superseded later in this release: the v2/Proxy commands and flags listed above are removed from both the CLI and bundled skill. diff --git a/.changeset/stash-supabase-contains-substrings.md b/.changeset/stash-supabase-contains-substrings.md index ca0df909a..c184442ca 100644 --- a/.changeset/stash-supabase-contains-substrings.md +++ b/.changeset/stash-supabase-contains-substrings.md @@ -2,8 +2,8 @@ "stash": patch --- -Correct the bundled `stash-supabase` agent skill: EQL v3 `contains()` matches -substrings. The skill previously carried the reverse — that `contains()` matched +Correct the bundled `stash-supabase` agent skill: encrypted free-text search +matches substrings. The skill previously carried the reverse — that it matched only exact values because the query's bloom filter appended the whole search term as an extra token. That was never true: `include_original` is inert in protect-ffi (the match bloom is trigram-only either way), so any substring of at diff --git a/.changeset/supabase-in-list-operands.md b/.changeset/supabase-in-list-operands.md index 360e23370..f4b5fa1b0 100644 --- a/.changeset/supabase-in-list-operands.md +++ b/.changeset/supabase-in-list-operands.md @@ -1,5 +1,5 @@ --- -'@cipherstash/stack': minor +'@cipherstash/stack-supabase': minor --- Fix encrypted `in`-list operands in the Supabase adapter, and widen the `is` / @@ -16,9 +16,7 @@ in.("{"v":1,"c":"…"}",…) ``` Encrypted lists are now emitted through `filter(col, 'in', …)` with each element -quoted and escaped, matching what the `.or()` path already did. This affects -**v2 as well as v3** — v2's `("a@b.com")` composite literal is itself -quote-bearing and was equally broken. +quoted and escaped, matching what the `.or()` path already did. **`not(col, 'in', […])` encrypted the whole list as a single ciphertext**, so the filter silently matched nothing, and emitted an unparenthesized @@ -30,11 +28,8 @@ array. **`filter(col, 'in', […])` encrypted the whole list as a single ciphertext.** The raw `.filter()` path reached `in` with none of the element-splitting the `in()`, `not(…, 'in', …)` and `.or()` paths perform, so the entire list operand -was encrypted as one equality term. The two wire formats then failed -differently, which is why this went unnoticed: **v2**'s `("json")` composite -literal is already parenthesized, so PostgREST parsed it as a one-element list -and answered `200 []` — a filter that silently matched nothing. **v3**'s bare -`{…}` envelope is not, so PostgREST rejected the request outright with +was encrypted as one equality term. A bare `{…}` envelope is not parenthesized, +so PostgREST rejected the request outright with `PGRST100 (failed to parse filter)`. Each element is now encrypted separately and the operand rendered as a quoted @@ -56,7 +51,8 @@ plaintext jsonb/array column falls through to PostgREST's native containment, so `contains('tags', ['vip'])` and `contains('meta', { plan: 'pro' })` now typecheck. A plaintext SCALAR column does not: `@>` is undefined on `text`, so the operand type follows the column's own shape and a scalar rejects every -containment operand. Encrypted match columns still take a `string` token. +containment operand. Encrypted free-text search is `matches()`, which takes a +`string` token; `contains()` on an encrypted non-JSON column throws and names it. Relatedly, `.or([{ op: 'contains' }])` now emits PostgREST's `cs` operator for plaintext columns too — previously only encrypted conditions were translated, so a plaintext containment reached the wire as `.contains.` and failed to parse. @@ -87,6 +83,6 @@ ciphertext. **In-list operands encrypt in one crossing per column.** The element-wise `in` / `not.in` encoding above spent one ZeroKMS round-trip per element; terms are now grouped by column and each group takes a single `bulkEncrypt` call, matching the -Drizzle v3 path. Falls back to per-term encryption for clients without +Drizzle path. Falls back to per-term encryption for clients without `bulkEncrypt`, and rejects a bulk response whose length does not match the list rather than silently truncating the predicate. diff --git a/.changeset/supabase-is-null-operands.md b/.changeset/supabase-is-null-operands.md index 46478aa24..c4a2e19b8 100644 --- a/.changeset/supabase-is-null-operands.md +++ b/.changeset/supabase-is-null-operands.md @@ -1,5 +1,5 @@ --- -'@cipherstash/stack': patch +'@cipherstash/stack-supabase': patch --- Fix the Supabase adapter encrypting `is` and `null` filter operands. @@ -14,10 +14,9 @@ NULL column rather than ciphertext, so it is found with an unencrypted `IS NULL`; encrypting the operand could never match. A single `isEncryptableTerm(operator, value)` predicate now guards every term -collector. Affects both `encryptedSupabase` (v2) and `encryptedSupabaseV3`. On -v3 this additionally removes a spurious `does not support equality queries` -error, which `is` raised because it maps to the `equality` query type and so hit -the column-capability guard — `or('active.is.null')` on a storage-only column +collector. This additionally removes a spurious `does not support equality +queries` error, which `is` raised because it maps to the `equality` query type +and so hit the column-capability guard — `or('active.is.null')` on a storage-only column threw rather than querying. Relatedly, an `or()` string is now rebuilt whenever a condition *references* an diff --git a/.changeset/supabase-or-string-parser.md b/.changeset/supabase-or-string-parser.md index 25db66e67..84fd89975 100644 --- a/.changeset/supabase-or-string-parser.md +++ b/.changeset/supabase-or-string-parser.md @@ -1,8 +1,8 @@ --- -'@cipherstash/stack': patch +'@cipherstash/stack-supabase': patch --- -Fix the Supabase adapter's `.or()` string parser mis-splitting conditions, and pin `contains()` on a mixed union column key to the encrypted operand. +Fix the Supabase adapter's `.or()` string parser mis-splitting conditions. An `.or()` string is only rebuilt from its parse when it references an encrypted column — otherwise the caller's string is forwarded verbatim — so each of these corrupts precisely the mixed encrypted/plaintext case. @@ -15,5 +15,3 @@ An `.or()` string is only rebuilt from its parse when it references an encrypted **A parenthesized operand was read as a list for every operator.** Only `in` and the range operators (`ov`, `sl`, `sr`, `nxr`, `nxl`, `adj`) take a paren-delimited operand; elsewhere `(` is an ordinary character. `email.eq.(foo)` parsed as `['foo']` and encrypted a JS array rather than the string, matching nothing. **A string operand spelling `null`, `true` or `false` is now quoted.** PostgREST reads a bare `null` as SQL NULL, so `.or([{ column: 'name', op: 'eq', value: 'null' }])` emitted `name.eq.null` and compared against NULL instead of the three-character string. - -**`contains(col, …)` where `col` is a union spanning an encrypted and a plaintext column** accepted an array or object operand. The union is now only as permissive as its strictest member: any declared encrypted column in the union pins the operand to `string`. A literal column argument was never affected. diff --git a/.changeset/supabase-v3-order-by-ope-term.md b/.changeset/supabase-v3-order-by-ope-term.md index 33d450239..ad7d612db 100644 --- a/.changeset/supabase-v3-order-by-ope-term.md +++ b/.changeset/supabase-v3-order-by-ope-term.md @@ -1,34 +1,33 @@ --- -'@cipherstash/stack': minor +'@cipherstash/stack-supabase': minor --- -**`order()` now works on EQL v3 encrypted ordering columns in the Supabase -adapter.** It was rejected outright on every encrypted column. +**`order()` works on EQL v3 encrypted ordering columns.** -A bare `ORDER BY col` on an EQL v3 domain really is wrong — the bundle declares -no btree operator class on any domain, so the sort falls through to jsonb's -default `jsonb_cmp` and compares the envelope's keys in storage order, starting -at the random ciphertext `c`. Measured over ten rows it returns -`r00,r04,r08,r01,…` where the plaintext order is `r00..r09`. No error, a stable -and plausible-looking meaningless order. +A bare `ORDER BY col` on an EQL v3 domain is wrong — the bundle declares no btree +operator class on any domain, so the sort falls through to jsonb's default +`jsonb_cmp` and compares the envelope's keys in storage order, starting at the +random ciphertext `c`. Measured over ten rows it returns `r00,r04,r08,r01,…` +where the plaintext order is `r00..r09`. No error, a stable and +plausible-looking meaningless order. But the correct sort key is reachable without a function call. `eql_v3.ord_term` returns the domain's `op` term, and OPE is order-preserving, so ordering by the term reproduces the plaintext order. PostgREST cannot emit -`ORDER BY eql_v3.ord_term(col)`, but it can emit a jsonb path. The builder now -emits `order=col->op` for an encrypted ordering column, verified against a live -PostgREST for `integer_ord` and `text_search` in both directions. +`ORDER BY eql_v3.ord_term(col)`, but it can emit a jsonb path. The builder emits +`order=col->op` for an encrypted ordering column, verified against a live +PostgREST for `eql_v3_integer_ord` and `eql_v3_text_search` in both directions. -The guard is now on the ordering FLAVOUR, not on encryption: +The guard is on the ordering FLAVOUR, not on encryption: -- **`ope` present → supported.** Every plain `*_ord` domain, plus `text_ord` and - `text_search`. +- **`ope` present → supported.** Every plain `eql_v3_*_ord` domain, plus + `eql_v3_text_ord` and `eql_v3_text_search`. - **`ore` present → rejected.** The `ob` term is an array of ORE blocks whose comparison needs the superuser-only operator class, which no jsonb path can reach. (Such a column cannot hold data on managed Postgres anyway: its domain - CHECK raises `ore_domain_unavailable`.) ORE columns are now excluded from - `order()` at COMPILE time too, not only at runtime — `.order(oreColumn)` is a - type error, matching the rejection. + CHECK raises `ore_domain_unavailable`.) ORE columns are excluded from `order()` + at COMPILE time as well as at runtime — `.order(oreColumn)` is a type error, + matching the rejection. - **neither → rejected.** Storage-only, equality-only and match-only columns carry no ordering term. @@ -40,8 +39,8 @@ numeric and date domains, and per-character (16 hex chars each) for text, so lexicographic order reproduces plaintext order including the prefix case (`ada` < `adam`). `ope-term.integration.test.ts` pins that shape. -`V3OrderableKeys` widens to admit OPE-backed ordering columns (`*_ord`, -`text_ord`, `text_search`) while still excluding ORE (`*_ord_ore`) columns, so -`order()` typechecks exactly where it works. `is(col, true)` is unaffected — it -stays plaintext-only, and now has its own `V3PlaintextKeys` rather than -borrowing the orderable set. +`OrderableKeys` admits OPE-backed ordering columns (`eql_v3_*_ord`, +`eql_v3_text_ord`, `eql_v3_text_search`) while excluding ORE +(`eql_v3_*_ord_ore`) columns, so `order()` typechecks exactly where it works. +`is(col, true)` is unaffected — it stays plaintext-only, with its own +`PlaintextKeys` rather than borrowing the orderable set. diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 04c61a87f..f3f433eb8 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -104,24 +104,37 @@ We use [**Changesets**](https://github.com/changesets/changesets) to manage vers - Follow the prompts to indicate the type of version bump (patch, minor, major). - The [GitHub Actions workflows](./.github/workflows/) handle the **publish** step to npm once your PR is merged and the changeset is committed to `main`. -## Pre release process +Releases are **stable** — Changesets is not in pre mode. Merging a changeset to +`main` opens (or updates) a "Version Packages" PR with plain semver versions, and +merging that PR publishes to npm under the `latest` dist-tag. -We currently use [changesets to manage pre-releasing](https://github.com/changesets/changesets/blob/main/docs/prereleases.md) the `next` version of the package, and the process is executed manually. +The `stash` / `@cipherstash/stack` / `@cipherstash/stack-drizzle` / +`@cipherstash/stack-supabase` / `@cipherstash/stack-prisma` / `@cipherstash/wizard` +packages are a `fixed` group in [`.changeset/config.json`](./.changeset/config.json): +they always version together, so a bump to any one of them bumps all six. -To do so, you need to: +## Pre-release process -1. Check out the `next` branch -2. Run `pnpm changeset pre enter next` -3. Run `pnpm changeset version` -4. Run `git add .` -5. Run `git commit -m "Enter prerelease mode and version packages"` -6. Run `pnpm changeset publish --tag next` -7. Run `git push --follow-tags` +The 1.0 line published its `1.0.0-rc.*` series through +[changesets pre mode](https://github.com/changesets/changesets/blob/main/docs/prereleases.md). +That mode is **exited** — do not re-enter it for ordinary work; a stable release +is the default. -When you are ready to release, you can run `pnpm changeset pre exit` to exit prerelease mode and commit the changes. -When you merge the PR, the `next` branch will be merged into `main`, and the package will be published to npm without the prerelease tag. +If a future line genuinely needs a prerelease series: + +1. Run `pnpm changeset pre enter rc` (this writes `.changeset/pre.json`) +2. Commit that file — from then on, every "Version Packages" PR produces + `x.y.z-rc.N` versions and publishes under the `rc` dist-tag +3. When the line is ready to go stable, run `pnpm changeset pre exit` and commit + the change. The next `changeset version` graduates every package to its final + version, consumes the accumulated changesets into `CHANGELOG.md`, and deletes + `.changeset/pre.json` > [!IMPORTANT] +> Pre mode retains every consumed changeset until exit, so its markdown is what +> lands in the stable changelog — an entry that a later change made wrong stays +> wrong until someone edits it. Review `.changeset/*.md` as a set before exiting. +> > This process can be dangerous, so please be careful when using it as it's difficult to undo mistakes. > If you are unfamiliar with the process, please reach out to the maintainers for help. diff --git a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts index ada5f2d07..db3a49f4d 100644 --- a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts +++ b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts @@ -18,7 +18,7 @@ import { encryptedTable, isEncrypted, types, -} from 'npm:@cipherstash/stack@^1.0.0-rc.3/wasm-inline' +} from 'npm:@cipherstash/stack@1.0.0/wasm-inline' // EQL v3: the WASM entry exports the v3 authoring surface only (`types.*`), // not the v2 chainable builders — `encryptedColumn('email').equality()` is diff --git a/packages/cli/src/__tests__/release-train.test.ts b/packages/cli/src/__tests__/release-train.test.ts index 64dc1da48..4a322eac1 100644 --- a/packages/cli/src/__tests__/release-train.test.ts +++ b/packages/cli/src/__tests__/release-train.test.ts @@ -1,12 +1,34 @@ -import { readFileSync } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { INTEGRATION_ADAPTER_PACKAGES } from '../commands/init/steps/install-deps.js' -import { RELEASE_TRAIN_MANIFESTS } from '../release-train.js' +import { + RELEASE_TRAIN_MANIFESTS, + type ReleaseTrainPackage, +} from '../release-train.js' const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') const REPO_ROOT = resolve(CLI_ROOT, '../..') +const SKILLS_ROOT = resolve(REPO_ROOT, 'skills') + +/** The stable version a workspace manifest belongs to: `1.0.0-rc.4` → `1.0.0`. */ +function stableVersion(version: string): string { + return version.split('-')[0] as string +} + +function isReleaseTrainPackage(pkg: string): pkg is ReleaseTrainPackage { + return Object.hasOwn(RELEASE_TRAIN_MANIFESTS, pkg) +} + +function manifestVersion(pkg: ReleaseTrainPackage): string { + const rel = RELEASE_TRAIN_MANIFESTS[pkg] + if (!rel) throw new Error(`${pkg} is not on the release train`) + const manifest = JSON.parse(readFileSync(resolve(CLI_ROOT, rel), 'utf8')) as { + version: string + } + return manifest.version +} // The growth guard for #661: `RELEASE_TRAIN_MANIFESTS` is the single source // the build embeds versions from, and `INTEGRATION_ADAPTER_PACKAGES` is what @@ -34,19 +56,71 @@ describe('release train coverage', () => { }) it('pins the bare-project stash invocation in the published CLI skill', () => { - const cliManifest = JSON.parse( - readFileSync(resolve(CLI_ROOT, 'package.json'), 'utf8'), - ) as { version: string } const skill = readFileSync( - resolve(REPO_ROOT, 'skills/stash-cli/SKILL.md'), + resolve(SKILLS_ROOT, 'stash-cli/SKILL.md'), 'utf8', ) + // The pin names the STABLE version of this release line, not the raw + // manifest version: the skill is read in a customer's repo against what is + // published, and a prerelease build's own `1.0.0-rc.4` would tell them to + // run a release candidate in production (#791). expect(skill).toContain( - `npx --package=stash@${cliManifest.version} stash eql install --database-url 'postgres://...'`, + `npx --package=stash@${stableVersion(manifestVersion('stash'))} stash eql install --database-url 'postgres://...'`, ) }) + // #791: `skills/` ships inside the `stash` tarball and is copied into + // customer repos by `stash init`, and NOTHING in the build rewrites the + // version literals inside it — `tsup.config.ts` copies the directory + // verbatim, and the `__STASH_RUNTIME_VERSIONS__` embed only reaches compiled + // CLI code. So a hardcoded `@cipherstash/stack@1.0.0-rc.4` would keep telling + // Deno and Supabase Edge users to pin a release candidate long after the + // stable release. This is the guard: it fires on the version-bump PR, when + // someone is in a position to update the surrounding prose too. + describe('skill version pins', () => { + const skillFiles = readdirSync(SKILLS_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + skill: entry.name, + body: readFileSync( + resolve(SKILLS_ROOT, entry.name, 'SKILL.md'), + 'utf8', + ), + })) + + it('finds skills to check (a moved directory must not silently pass)', () => { + expect(skillFiles.length).toBeGreaterThan(0) + }) + + it.each( + skillFiles, + )('$skill pins release-train packages at a stable version on the current major', ({ + body, + }) => { + // Exact pins only (`pkg@1.2.3`, optionally `npm:`-prefixed and with a + // trailing subpath). A range spec (`^1.0.0`) carries its own semantics + // and is not a pin to check. + const PIN = + /(?:npm:)?(stash|@cipherstash\/[a-z0-9-]+)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g + + for (const [, pkg, pinned] of body.matchAll(PIN)) { + if (!isReleaseTrainPackage(pkg)) continue + const current = manifestVersion(pkg) + + expect( + pinned, + `${pkg}@${pinned} pins a prerelease — skills ship to customers, so pin the stable release (${stableVersion(current)})`, + ).not.toContain('-') + + expect( + pinned.split('.')[0], + `${pkg}@${pinned} is off this release line (${current}) — update the pin and the prose around it`, + ).toBe(stableVersion(current).split('.')[0]) + } + }) + }) + it('every train manifest exists and carries a version (what tsup will embed)', () => { // Exercises the exact inputs tsup.config.ts reads at build time, so a // renamed/moved workspace package fails HERE in source-mode tests, not diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 0b8e3f567..cf354bd8c 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -354,7 +354,7 @@ Gets a project from zero to a direct EQL v3 install. It loads an existing `stash The removed `--eql-version`, `--latest`, `--drizzle`, `--migration`, `--direct`, `--migrations-dir`, and `--exclude-operator-family` options fail clearly instead of being ignored. A request for EQL v2 points dump-recovery users to the upstream EQL 2.3.1 SQL release. New installs are EQL v3 only; its pinned bundle self-adapts when a database role cannot create the optional operator family. -**`--database-url` is a one-shot.** It installs against that database and leaves the project untouched — no config is loaded, and none is scaffolded, nor is an encryption client. This lets `npx --package=stash@1.0.0-rc.4 stash eql install --database-url 'postgres://...'` run in a bare project with no CipherStash dependencies while pinning the CLI to this skill's release. It also means the flag always wins: loading a config could pick up a parent-directory `databaseUrl` literal and install against the wrong database. +**`--database-url` is a one-shot.** It installs against that database and leaves the project untouched — no config is loaded, and none is scaffolded, nor is an encryption client. This lets `npx --package=stash@1.0.0 stash eql install --database-url 'postgres://...'` run in a bare project with no CipherStash dependencies while pinning the CLI to this skill's release. It also means the flag always wins: loading a config could pick up a parent-directory `databaseUrl` literal and install against the wrong database. #### `eql migration` diff --git a/skills/stash-edge/SKILL.md b/skills/stash-edge/SKILL.md index ae4a11efb..ca68d3c7c 100644 --- a/skills/stash-edge/SKILL.md +++ b/skills/stash-edge/SKILL.md @@ -56,15 +56,12 @@ build step. ```ts import { Encryption, encryptedTable, types, isEncrypted, -} from 'npm:@cipherstash/stack@1.0.0-rc.4/wasm-inline' +} from 'npm:@cipherstash/stack@1.0.0/wasm-inline' ``` **Pin an exact version.** Deno caches by specifier, so an unpinned import -drifts between deploys. And while the package is on a prerelease line, a -caret range does not do what it looks like: `@^1.0.0` will **not** match -`1.0.0-rc.4`, because semver ranges exclude prereleases unless the range -itself names one. Use the exact version, or a prerelease-bearing range -(`@^1.0.0-rc.4`). Check what is current with `npm view @cipherstash/stack dist-tags`. +drifts between deploys — pin, and bump the pin deliberately. Check what is +current with `npm view @cipherstash/stack dist-tags`. ### Deno with an import map @@ -74,7 +71,7 @@ name everywhere: ```jsonc { "imports": { - "@cipherstash/stack/wasm-inline": "npm:@cipherstash/stack@1.0.0-rc.4/wasm-inline" + "@cipherstash/stack/wasm-inline": "npm:@cipherstash/stack@1.0.0/wasm-inline" } } ```