@cipherstash/stack 0.19.0 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +596 -0
  2. package/README.md +376 -276
  3. package/dist/adapter-kit.cjs +1002 -0
  4. package/dist/adapter-kit.cjs.map +1 -0
  5. package/dist/adapter-kit.d.cts +120 -0
  6. package/dist/adapter-kit.d.ts +120 -0
  7. package/dist/adapter-kit.js +122 -0
  8. package/dist/adapter-kit.js.map +1 -0
  9. package/dist/base-operation-AOAIvsSB.d.cts +32 -0
  10. package/dist/base-operation-FXEzUXIq.d.ts +32 -0
  11. package/dist/{chunk-4AVL4VZD.js → chunk-3B5ZX3IS.js} +3 -1
  12. package/dist/chunk-3B5ZX3IS.js.map +1 -0
  13. package/dist/{chunk-U66S7VIF.js → chunk-6SGN52W6.js} +166 -107
  14. package/dist/chunk-6SGN52W6.js.map +1 -0
  15. package/dist/chunk-7333ZC6L.js +48 -0
  16. package/dist/chunk-7333ZC6L.js.map +1 -0
  17. package/dist/{chunk-MP3SSDNN.js → chunk-CLM7E4I6.js} +15 -15
  18. package/dist/{chunk-MP3SSDNN.js.map → chunk-CLM7E4I6.js.map} +1 -1
  19. package/dist/{chunk-OFQ555AX.js → chunk-IDKP6ABU.js} +2 -2
  20. package/dist/{chunk-36AA7IBJ.js → chunk-L7ISHSG7.js} +53 -20
  21. package/dist/chunk-L7ISHSG7.js.map +1 -0
  22. package/dist/{chunk-LBMC4D6D.js → chunk-NVKK7UDN.js} +1 -1
  23. package/dist/chunk-NVKK7UDN.js.map +1 -0
  24. package/dist/chunk-X3JRXEIB.js +98 -0
  25. package/dist/chunk-X3JRXEIB.js.map +1 -0
  26. package/dist/client.cjs +29 -12
  27. package/dist/client.cjs.map +1 -1
  28. package/dist/client.d.cts +3 -2
  29. package/dist/client.d.ts +3 -2
  30. package/dist/client.js +2 -2
  31. package/dist/{table-CIH7jZ2h.d.ts → columns-0lbT9stl.d.ts} +157 -138
  32. package/dist/{table-DihEAlxG.d.cts → columns-Bxv7Oo9o.d.cts} +157 -138
  33. package/dist/dynamodb/index.d.cts +3 -2
  34. package/dist/dynamodb/index.d.ts +3 -2
  35. package/dist/encryption/index.cjs +54 -16
  36. package/dist/encryption/index.cjs.map +1 -1
  37. package/dist/encryption/index.d.cts +807 -6
  38. package/dist/encryption/index.d.ts +807 -6
  39. package/dist/encryption/index.js +6 -6
  40. package/dist/encryption/v3.cjs +1078 -973
  41. package/dist/encryption/v3.cjs.map +1 -1
  42. package/dist/encryption/v3.d.cts +15 -12
  43. package/dist/encryption/v3.d.ts +15 -12
  44. package/dist/encryption/v3.js +50 -22
  45. package/dist/encryption/v3.js.map +1 -1
  46. package/dist/eql/v3/index.cjs +130 -79
  47. package/dist/eql/v3/index.cjs.map +1 -1
  48. package/dist/eql/v3/index.d.cts +115 -13
  49. package/dist/eql/v3/index.d.ts +115 -13
  50. package/dist/eql/v3/index.js +16 -6
  51. package/dist/errors/index.cjs.map +1 -1
  52. package/dist/errors/index.d.cts +5 -5
  53. package/dist/errors/index.d.ts +5 -5
  54. package/dist/errors/index.js +1 -1
  55. package/dist/identity/index.cjs.map +1 -1
  56. package/dist/identity/index.js +2 -2
  57. package/dist/index-BquA71_Y.d.ts +24 -0
  58. package/dist/index-fhWTOV0K.d.cts +24 -0
  59. package/dist/index.cjs +79 -27
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.d.cts +5 -17
  62. package/dist/index.d.ts +5 -17
  63. package/dist/index.js +6 -6
  64. package/dist/schema/index.cjs +29 -12
  65. package/dist/schema/index.cjs.map +1 -1
  66. package/dist/schema/index.d.cts +1 -1
  67. package/dist/schema/index.d.ts +1 -1
  68. package/dist/schema/index.js +2 -2
  69. package/dist/{types-public-CpS5KjwX.d.ts → types-public-QMjYNfQO.d.cts} +765 -726
  70. package/dist/{types-public-CpS5KjwX.d.cts → types-public-QMjYNfQO.d.ts} +765 -726
  71. package/dist/types-public.cjs.map +1 -1
  72. package/dist/types-public.d.cts +1 -1
  73. package/dist/types-public.d.ts +1 -1
  74. package/dist/types-public.js +1 -1
  75. package/dist/wasm-inline.d.ts +779 -405
  76. package/dist/wasm-inline.js +606 -299
  77. package/dist/wasm-inline.js.map +1 -1
  78. package/package.json +25 -53
  79. package/dist/chunk-36AA7IBJ.js.map +0 -1
  80. package/dist/chunk-4AVL4VZD.js.map +0 -1
  81. package/dist/chunk-IADZCZEA.js +0 -23
  82. package/dist/chunk-IADZCZEA.js.map +0 -1
  83. package/dist/chunk-IBSK6P33.js +0 -209
  84. package/dist/chunk-IBSK6P33.js.map +0 -1
  85. package/dist/chunk-LBMC4D6D.js.map +0 -1
  86. package/dist/chunk-U66S7VIF.js.map +0 -1
  87. package/dist/client-DSGHBN-g.d.cts +0 -834
  88. package/dist/client-DfCrlHXh.d.ts +0 -834
  89. package/dist/drizzle/index.cjs +0 -5617
  90. package/dist/drizzle/index.cjs.map +0 -1
  91. package/dist/drizzle/index.d.cts +0 -358
  92. package/dist/drizzle/index.d.ts +0 -358
  93. package/dist/drizzle/index.js +0 -1220
  94. package/dist/drizzle/index.js.map +0 -1
  95. package/dist/supabase/index.cjs +0 -5951
  96. package/dist/supabase/index.cjs.map +0 -1
  97. package/dist/supabase/index.d.cts +0 -223
  98. package/dist/supabase/index.d.ts +0 -223
  99. package/dist/supabase/index.js +0 -1217
  100. package/dist/supabase/index.js.map +0 -1
  101. /package/dist/{chunk-OFQ555AX.js.map → chunk-IDKP6ABU.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,601 @@
1
1
  # @cipherstash/stack
2
2
 
3
+ ## 1.0.0-rc.1
4
+
5
+ ### Minor Changes
6
+
7
+ - 5fe9a2f: Encrypted-JSON querying on the v3 Supabase surface (#650). A `types.Json`
8
+ column now supports exact encrypted containment — `contains(col, subDocument)`
9
+ (ste_vec `@>` via PostgREST `cs`, with the sub-document storage-encrypted
10
+ against the column) — and JSONPath selector predicates: `selectorEq(col, path,
11
+ value)` and `selectorNe(col, path, value)` (dot-notation paths; `ne` includes
12
+ rows where the path is absent, mirroring the Drizzle selector's semantics).
13
+ Raw `.filter(col, 'cs', subDocument)` and `not(col, 'contains', …)` route
14
+ through the same encrypted path. Selector ordering is not expressible over
15
+ PostgREST yet (needs an EQL-bundle overload — see
16
+ cipherstash/encrypt-query-language#407); the Drizzle integration's
17
+ `ops.selector()` covers ordering today.
18
+
19
+ In core, `QueryTypesForColumn` gains the `searchableJson` arm (a `types.Json`
20
+ column no longer resolves to `never`, so typed adapter key sets can include
21
+ it), and the JSONPath selector-path helpers the Drizzle adapter introduced in
22
+ #651 moved to `@cipherstash/stack/adapter-kit` so both adapters share one
23
+ validation surface (`@cipherstash/stack-drizzle` re-exports them unchanged).
24
+
25
+ The bundled `stash-supabase` and `stash-encryption` skills are updated to
26
+ document the new querying surface (including the array-leaf and SQL-NULL
27
+ semantics, and the operand-exposure caveat) — skills ship inside the `stash`
28
+ tarball, hence the patch.
29
+
30
+ ### Patch Changes
31
+
32
+ - e297f64: Docs: EQL v3 is now the sole documented approach. The `stash-encryption`,
33
+ `stash-drizzle`, and `stash-supabase` skills and the `@cipherstash/stack`
34
+ README teach only the v3 typed surface (`EncryptionV3`, `types.*` concrete
35
+ domains, `@cipherstash/stack-drizzle/v3`, `encryptedSupabaseV3`); EQL v2
36
+ shrinks to one short Legacy section per document. Two explicit exceptions are
37
+ called out: DynamoDB still requires the v2 schema surface (#657), and the
38
+ encrypt rollout tooling (`stash encrypt backfill`/`cutover`,
39
+ `@cipherstash/migrate`) currently targets v2 columns (#648) — its guidance is
40
+ kept under a version callout. Also corrects the legacy `@cipherstash/drizzle`
41
+ README's pointer to the removed `@cipherstash/stack/drizzle` subpath (now the
42
+ separate `@cipherstash/stack-drizzle` package).
43
+ - 40ab142: Docs: stop teaching the deprecated `LockContext.identify()` as the primary
44
+ identity-aware-encryption path (#591). The `stash-encryption` and `stash-supabase`
45
+ skills and the `@cipherstash/stack` README now lead with the current pattern —
46
+ authenticate the client with `OidcFederationStrategy`, then bind the claim per
47
+ operation with `.withLockContext({ identityClaim })` — and demote
48
+ `LockContext.identify()` to a clearly-marked deprecated note (per-operation CTS
49
+ tokens were removed in protect-ffi 0.25). Skills ship in the `stash` tarball, so
50
+ this keeps the bundled guidance correct for the 1.0 surface.
51
+ - 7b53141: Three correctness fixes surfaced while documenting the v3 surface:
52
+
53
+ - **Supabase `matches()` now rejects a short free-text needle.** A needle
54
+ below the tokenizer's `token_length` blooms to zero tokens, so `bloom @> {}`
55
+ matched (and the caller decrypted) every row — a fail-open exposure. The
56
+ guard (`matchNeedleError`) was wired into the Drizzle adapter only; the
57
+ Supabase adapter now applies it at the same term-resolution choke point, so
58
+ both first-party surfaces reject identically. (Authoritative FFI-level backstop
59
+ for the `encryptQuery` paths tracked in cipherstash/protectjs-ffi#138.)
60
+ - **Supabase `.withLockContext()` accepts the plain `{ identityClaim }` form**,
61
+ not only a `LockContext` instance — matching the stack-level operations and
62
+ the documented identity-aware example (widened to `LockContextInput`).
63
+ - **`EncryptionErrorTypes` is now `as const`**, so the `StackError` union
64
+ actually discriminates: `switch (error.type)` narrows and `error.code` is
65
+ reachable on the relevant branches. Without it every `type` was `string` and
66
+ the documented exhaustive error handler did not compile.
67
+
68
+ ## 1.0.0-rc.0
69
+
70
+ ### Major Changes
71
+
72
+ - 7c7dbca: CipherStash Stack 1.0 (release candidate).
73
+
74
+ This is the first 1.0-line release of `@cipherstash/stack`, the first published
75
+ release of the split-out EQL v3 adapters `@cipherstash/stack-drizzle` and
76
+ `@cipherstash/stack-supabase`, and moves the `stash` CLI to 1.0 alongside them.
77
+ These four packages now version together as the Stack 1.0 family.
78
+
79
+ ### Minor Changes
80
+
81
+ - 31ca318: Split the Drizzle and Supabase integrations into their own packages.
82
+
83
+ The adapters now ship as first-party packages that depend on `@cipherstash/stack`,
84
+ following the `@cipherstash/prisma-next` precedent:
85
+
86
+ - **`@cipherstash/stack-drizzle`** — Drizzle ORM integration. EQL v2 on the package
87
+ root (`@cipherstash/stack-drizzle`: `encryptedType`, `extractEncryptionSchema`,
88
+ `createEncryptionOperators`) and EQL v3 on `@cipherstash/stack-drizzle/v3`
89
+ (`types` factories, `createEncryptionOperatorsV3`, `extractEncryptionSchemaV3`, …).
90
+ - **`@cipherstash/stack-supabase`** — Supabase integration: `encryptedSupabase` (v2)
91
+ and `encryptedSupabaseV3` (v3, connect-time introspection).
92
+
93
+ **Breaking (`@cipherstash/stack`):** the `./drizzle`, `./supabase`, and
94
+ `./eql/v3/drizzle` subpath exports are removed. Migrate imports:
95
+
96
+ - `@cipherstash/stack/drizzle` → `@cipherstash/stack-drizzle`
97
+ - `@cipherstash/stack/eql/v3/drizzle` → `@cipherstash/stack-drizzle/v3`
98
+ - `@cipherstash/stack/supabase` → `@cipherstash/stack-supabase`
99
+
100
+ Add the relevant package to your dependencies alongside `@cipherstash/stack`. A new
101
+ `@cipherstash/stack/adapter-kit` subpath exposes the narrow core internals the
102
+ first-party adapters consume; it is the core↔adapter seam, not general-purpose API.
103
+
104
+ - c4787c0: Restore the EQL v3 envelope and `Result` types the adapters were erasing.
105
+
106
+ Both v3 adapters typed their operand-encryption paths as `unknown` and dropped
107
+ the `Result` wrapper, so the query-type encoding and the failure channel were
108
+ invisible to the type system:
109
+
110
+ - `eql/v3/drizzle/operators.ts` typed the client's `encrypt`/`bulkEncrypt` as
111
+ returning `unknown`, collapsed the operation's `Result` to
112
+ `{ data?: unknown; failure?: { message } }`, and cast the bulk response to
113
+ `Array<{ data: unknown }>`.
114
+ - `supabase/query-builder-v3.ts` returned `Promise<unknown[]>` from
115
+ `encryptCollectedTerms`, `bulkEncryptGroup` and `encryptGroupPerTerm`, and the
116
+ base `query-builder.ts` did the same.
117
+
118
+ These now carry the SDK's real types — `Encrypted` (the storage envelope union,
119
+ which includes every v3 per-domain payload), `BulkEncryptedData`, and
120
+ `EncryptedQueryResult` — threaded through a properly-typed operation surface that
121
+ resolves `Result<T, EncryptionError>`. The Supabase divergence the erasure hid is
122
+ now explicit: the v2 path yields `encryptQuery` composite literals and the v3
123
+ path yields `JSON.stringify`'d envelope strings, and both are `EncryptedQueryResult`.
124
+
125
+ Bumped `minor`, not `patch`: `createEncryptionOperatorsV3` is a public export
126
+ (`@cipherstash/stack/eql/v3/drizzle`), and tightening its client contract from
127
+ `unknown` to a typed operation surface is a compile-time breaking change — a
128
+ downstream consumer passing a loosely-typed (`unknown`-returning) client double
129
+ will now fail `tsc`. That tightening has teeth: `operators.test-d.ts` pins it
130
+ with a negative type-test asserting an `unknown`-returning `{ encrypt }` double
131
+ is rejected (a positive "correctly-typed double is accepted" assertion cannot
132
+ catch a re-erasure, since a correct value is assignable to `unknown`).
133
+
134
+ Behaviour is otherwise unchanged, with one addition: the Supabase v3 bulk path
135
+ now rejects a `null` envelope returned by `bulkEncrypt` (the restored
136
+ `Encrypted | null` type makes that arm reachable, and a `null` would otherwise
137
+ be `JSON.stringify`'d to the literal `"null"` and sent as a filter operand).
138
+
139
+ - 66a0e02: Add the EQL v3 bigint domain family to the public DSL: `types.Bigint`,
140
+ `types.BigintEq`, `types.BigintOrdOre`, and `types.BigintOrd`, backed by the
141
+ `public.bigint*` concrete domains. Plaintext is a JS `bigint`, round-tripped
142
+ losslessly across the protect-ffi 0.28 boundary (i64 bounds enforced at the
143
+ FFI — out-of-range values surface as encryption errors). Index emission follows
144
+ the numeric rule: `bigint_eq` → unique (hm); `bigint_ord`/`bigint_ord_ore` →
145
+ ore (equality answered via ob).
146
+ - 7eba32d: EQL v3 Drizzle: encrypt every query operand with `encryptQuery`, not `encrypt` (#622).
147
+
148
+ The v3 Drizzle operators (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`notBetween`/
149
+ `inArray`/`notInArray`/`contains`) previously encrypted their operands with
150
+ `client.encrypt`, producing a full storage envelope (including the ciphertext `c`)
151
+ cast to `::jsonb`. A WHERE-clause operand should be a query _term_, not a value to
152
+ store. Every operator now uses `client.encryptQuery`, which yields a
153
+ ciphertext-free query term cast to the column's `eql_v3.query_<domain>` type — so
154
+ predicates carry no ciphertext and reach the bundle's `(domain, query_<domain>)`
155
+ operator overloads. This unifies the scalar/text operators with the JSON
156
+ containment path (already on `encryptQuery`) and removes the previously-optional
157
+ `encryptQuery` guard: it is now a required capability of the operand client.
158
+
159
+ `@cipherstash/stack` gains a batch `encryptQuery(terms)` overload on
160
+ `TypedEncryptionClient` (the type `EncryptionV3` returns), mirroring the nominal
161
+ `EncryptionClient`. This is additive — it lets `inArray`/`notInArray` encrypt a
162
+ whole list of query terms in one crossing.
163
+
164
+ - 0ebf57e: Close two fail-open paths in the EQL v3 Drizzle adapter.
165
+
166
+ `ops.contains()` now throws `EncryptionOperatorError` for a search term that
167
+ tokenizes to nothing: the empty string, or a term shorter than the match index
168
+ tokenizer's `token_length` (3 by default). Such a term produces an empty bloom
169
+ filter, and `stored_bf @> '{}'` is true for every row — so a user searching
170
+ `"ad"` silently received the entire table. Measured live, the terms `"ad"`,
171
+ `"a"` and `"x"` each returned every seeded row, including one in which `"x"`
172
+ did not appear.
173
+
174
+ The floor counts Unicode codepoints, matching the tokenizer. A UTF-16 length
175
+ check would wave through an astral-plane term — `"👍👍"` is 4 code units but
176
+ only 2 codepoints, yields no trigram, and matched every row.
177
+
178
+ **Breaking for callers passing short terms:** `contains()` calls that previously
179
+ returned every row now throw. Terms of 3+ codepoints are unaffected.
180
+
181
+ `v3FromDriver()` now throws the new `EqlV3CodecError` on a payload that is not
182
+ an EQL envelope, instead of surfacing a raw `SyntaxError` for malformed JSON and
183
+ passing a bare scalar through unchecked — `v3FromDriver('5')` previously returned
184
+ `5` typed as `Encrypted`, which then reached `decrypt` as garbage. The guard
185
+ accepts both scalar envelopes (ciphertext at `c`) and SteVec documents
186
+ (ciphertext at `sv[0].c`). A SteVec's `sv` must be a non-empty array: `sv[0]` is
187
+ the decryption root, so `sv: []` carries a ciphertext key but no ciphertext, and
188
+ is now rejected rather than passed to `decrypt`. `EqlV3CodecError` is exported
189
+ from `@cipherstash/stack/eql/v3/drizzle` so callers can catch it.
190
+
191
+ Also removes an unreachable branch in `inArray`/`notInArray`, whose empty-list
192
+ guard already throws before it.
193
+
194
+ Note: the v2 Drizzle adapter's `like`/`ilike` path builds the same bloom filters
195
+ and has the same short-term fail-open. It is **not** fixed here — v2 terms carry
196
+ SQL wildcards, so the floor must be measured against what its tokenizer actually
197
+ receives before the shared guard can be reused. Tracked separately.
198
+
199
+ - d73a03c: Add EQL v3 Drizzle support at `@cipherstash/stack/eql/v3/drizzle`. A Drizzle-native
200
+ `types` namespace (same PascalCase names as `@cipherstash/stack/eql/v3`) declares
201
+ encrypted columns whose Postgres type is the semantic `public.<domain>`; the concrete
202
+ type drives the legal query operators. `createEncryptionOperatorsV3` provides
203
+ capability-checked `eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`contains`/`inArray`/
204
+ `asc`/`desc`/`and`/`or` that emit the latest two-argument `eql_v3` SQL functions with
205
+ full-envelope operands, and
206
+ `extractEncryptionSchemaV3` rebuilds the schema for `EncryptionV3`. The existing v2
207
+ `@cipherstash/stack/drizzle` integration is unchanged.
208
+
209
+ The v3 text-search helper is `contains`; obsolete `like`/`ilike` helpers are not
210
+ exposed because v3 free-text search is token containment rather than SQL wildcard
211
+ matching.
212
+
213
+ - 89b903f: Upgrade `@cipherstash/protect-ffi` to 0.28.0 and update EQL v3 concrete Postgres domain names to match the SQL fixture (`integer*`, `smallint*`, `bool`, `real*`, and `double*`). The public factories remain semantic (`Integer`, `Smallint`, `Boolean`, `Real`, `Double`) while their concrete domains change, so this is a minor release.
214
+ - 229ce59: Re-baseline EQL v3 on the eql-3.0.0 GA release and protect-ffi 0.29.
215
+
216
+ - **Breaking (v3 preview surface):** the EQL v3 column domains follow the
217
+ eql-3.0.0 naming convention — flat, prefixed names in `public`
218
+ (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, …) instead of the
219
+ alpha-era bare names. Databases installed from an alpha bundle must be
220
+ re-installed (`stash eql install --eql-version 3` replaces the schema).
221
+ - `encryptQuery` under `eqlVersion: 3` now returns EQL v3 query operands
222
+ (protect-ffi 0.29): term-only scalar operands for the `eql_v3.query_<name>`
223
+ domains, the `eql_v3.query_jsonb` containment needle, and bare selector
224
+ hashes for JSON path queries — v3 scalar and selector queries no longer
225
+ throw `EQL_V3_QUERY_UNSUPPORTED` (the code is gone).
226
+ - v2 `searchableJson()` columns now pin the SteVec encoding to `standard`
227
+ explicitly. protect-ffi 0.29 flipped the library default to `compat`
228
+ (EQL v3's encoding); without the pin, v2 JSON containment queries would
229
+ silently match nothing and newly written rows would not be comparable with
230
+ existing ones.
231
+ - The EQL v3 test/install SQL is sourced from the pinned `@cipherstash/eql`
232
+ package (3.0.0) instead of a hand-vendored fixture.
233
+
234
+ - 50c0a9c: Add EQL v3 JSON columns. `types.Json('col')` declares a `public.eql_v3_json`
235
+ column that encrypts a JSON document to an ste_vec `SteVecDocument` and
236
+ round-trips it losslessly through `encrypt`/`decrypt` and the model path. A new
237
+ `searchableJson` query capability emits the ste_vec index; the index uses
238
+ `mode: 'compat'`, which eql-3.0.0's `eql_v3_json` requires (it orders ste_vec
239
+ entries by the CLLW-OPE `op` term, so v2's `'standard'`/CLLW-`oc` terms are
240
+ rejected).
241
+
242
+ The Drizzle integration's `contains(col, subObject)` now answers encrypted-JSONB
243
+ containment on a `types.Json` column, emitting the `@>` operator with a
244
+ `query_jsonb` needle (from `encryptQuery`). The ste_vec index indexes array
245
+ elements by identity but not position, so containment is a true subset test
246
+ (`{ roles: ['x'] }` matches any document whose `roles` array contains `x`,
247
+ regardless of index).
248
+
249
+ - 5d23e80: Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter. It is
250
+ now a connect-time-async factory: `await encryptedSupabaseV3(url, key)` (or
251
+ `(client)`) introspects the database over `DATABASE_URL`, detects EQL v3 columns
252
+ by their Postgres domain (`information_schema.columns.domain_name`), and derives
253
+ each column's encryption config from its domain — callers no longer pass a
254
+ schema to `from()`. `select('*')` is supported (expanded from the introspected
255
+ column list, and aliased back to each declared column's JS property name so a
256
+ property→DB rename round-trips). A column using an EQL v3 domain this SDK version does not model
257
+ (e.g. `public.json`, `*_ord_ope`) throws at construction rather than silently
258
+ passing through. Supplying `schemas` remains optional and adds compile-time
259
+ types plus startup verification of the declared tables against the database.
260
+ Requires a Postgres connection for introspection (`pg` is a new optional peer),
261
+ so it cannot run in a Worker or the browser.
262
+
263
+ Every column name a query carries — filters, `match`, `not`, raw `filter`,
264
+ `or()`, `order()`, and the `onConflict` option — is now resolved from its JS
265
+ property name to its DB column name in a single pass before the query is built,
266
+ so a declared rename round-trips everywhere rather than only on the paths that
267
+ remembered to translate.
268
+
269
+ `order()` on ANY encrypted v3 column is now rejected — at compile time when
270
+ `schemas` is supplied, and at runtime otherwise. The EQL v3 domains are
271
+ `DOMAIN … AS jsonb` and the bundle declares no btree operator class on them, so
272
+ `ORDER BY col` resolves through jsonb's default `jsonb_cmp` and sorts by the
273
+ envelope's byte structure: a stable, plausible-looking, meaningless row order,
274
+ with no error. Correct ordering is `ORDER BY eql_v3.ord_term(col)`, which
275
+ PostgREST's `order=` cannot express. Order by a plaintext column, expose
276
+ `eql_v3.ord_term()` as a generated column or view, or use the EQL v3 Drizzle
277
+ integration, which emits `ord_term` directly. Note `gte`/`lte` filters remain
278
+ correct: the comparison operators _are_ declared on the ord domains, and only
279
+ sorting resolves through the missing operator class.
280
+
281
+ `.or()` now understands PostgREST's `column.not.<op>.<value>` negation. It was
282
+ previously parsed as `{ op: 'not', value: '<op>.<value>' }`, so on an encrypted
283
+ column `or('nickname.not.in.(ada,grace)')` encrypted the literal string
284
+ `in.(ada,grace)` as a single plaintext and produced a filter that silently
285
+ matched nothing.
286
+
287
+ Free-text search on the v3 builder is `contains(column, value)`. `like`/`ilike`
288
+ are not exposed, because EQL v3 free-text search is token containment over a
289
+ bloom filter (`@>`, backed by `eql_v3.contains`) rather than SQL wildcard
290
+ matching — `%` is tokenized like any other character, so a `like` pattern is a
291
+ category error. This matches the v3 Drizzle integration, which omits them for
292
+ the same reason. On an encrypted column `like`/`ilike` now throw and name
293
+ `contains`; on a plaintext column they remain ordinary PostgREST filters.
294
+
295
+ `contains` is narrowed at compile time to columns whose domain carries the
296
+ `freeTextSearch` capability (`public.text_match`, `public.text_search`), and
297
+ guarded at runtime for the untyped surface. A raw `filter(column, operator, …)`
298
+ on an encrypted v3 column now derives its query type from the operator instead
299
+ of always encrypting an equality term, so `filter('bio', 'cs', …)` on a
300
+ `public.text_match` column works rather than being rejected, and an unsupported
301
+ operator throws instead of silently encrypting the wrong term.
302
+
303
+ Substring `contains` matches any needle whose trigrams are all present in the
304
+ stored value; needles shorter than the tokenizer's window (3 characters) bloom to
305
+ nothing and are rejected rather than silently matching every row. The v3 match
306
+ index now emits `include_original: false` — the flag is inert in protect-ffi (the
307
+ bloom is trigram-only either way), so this moves no ciphertext and only pins the
308
+ value a substring-search domain wants.
309
+
310
+ v2 (`encryptedSupabase`) is unchanged: it keeps `like`/`ilike` (`eql_v2.like`,
311
+ `~~`) and its raw-`filter` query-type mapping, so no v2 ciphertext moves.
312
+
313
+ - 1aa9a11: Add the EQL v3 `text_search` authoring DSL on a new `@cipherstash/stack/eql/v3`
314
+ subpath (`types.TextSearch`, v3 `encryptedTable` / `buildEncryptConfig`). The v3
315
+ builders emit the existing `EncryptConfig` shape, so encryption, payloads, and
316
+ query paths are unchanged at runtime.
317
+
318
+ Also widens the public client types (`EncryptionClientConfig.schemas`,
319
+ `EncryptOptions`, `SearchTerm`/`EncryptQueryOptions`) to a structural contract so
320
+ both v2 and v3 builders are accepted by `Encryption` / `encrypt` / `decrypt` /
321
+ `encryptQuery`. This is a backward-compatible widening — existing v2 usage is
322
+ unaffected. The structural contracts themselves (`BuildableColumn`,
323
+ `BuildableQueryColumn`, `BuildableV3QueryableColumn`, `BuildableTable`,
324
+ `BuildableTableColumns`) and the `encryptModel` return-type mapper
325
+ (`EncryptedFromBuildableTable`) are exported from `@cipherstash/stack/types` so
326
+ consumers can name them.
327
+
328
+ - af2d04e: Add a strongly-typed EQL v3 client surface on a new `@cipherstash/stack/v3`
329
+ subpath (`EncryptionV3`, `typedClient`, `TypedEncryptionClient`). It re-exports
330
+ the v3 `types` namespace and table API (from `@cipherstash/stack/eql/v3`), so a
331
+ single import provides everything needed to author and use a v3 schema.
332
+
333
+ Every method derives its types from the concrete `table` / `column` builder
334
+ arguments:
335
+
336
+ - `encrypt` / `encryptQuery` pin the plaintext to the column's domain type
337
+ (`text → string`, `timestamp → Date`, …).
338
+ - `encryptQuery` constrains `queryType` to the column's capabilities and rejects
339
+ storage-only columns at compile time.
340
+ - `encryptModel` / `bulkEncryptModels` validate schema-column fields against their
341
+ inferred plaintext type (passthrough fields are untouched) and return a precise
342
+ encrypted model.
343
+ - `decryptModel` / `bulkDecryptModels` return the precise plaintext model,
344
+ reconstructing `Date` values from the encrypt-config `cast_as`.
345
+
346
+ Because the typed methods bind to the concrete branded v3 classes, a hand-rolled
347
+ structural table/column is rejected — closing the soundness gap where a non-branded
348
+ table could be encrypted at runtime while typed as plaintext.
349
+
350
+ Runtime behaviour is unchanged: the encrypt/query paths return the same operations
351
+ as the base client; only the model-decrypt paths add a per-column `Date`
352
+ reconstruction step. The v2 client surface (`Encryption`) is untouched.
353
+
354
+ - b8a3d20: Add EQL v3 schema builders for supported generated SQL domains under `@cipherstash/stack/eql/v3`, exposed as the `types` namespace (one member per supported EQL v3 domain, e.g. `types.TextEq` / `types.IntegerOrd` / `types.Timestamp`), including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`).
355
+
356
+ Also widen the accepted plaintext input type for `encrypt` / `encryptQuery` to include `Date` (via the new `Plaintext` type), so v3 `date` / `timestamp` domains can be encrypted and queried with their natural JavaScript values.
357
+
358
+ - a0f3b2c: `@cipherstash/stack/wasm-inline` is now EQL v3 (#614).
359
+
360
+ The WASM entry (Deno / Bun / Cloudflare Workers / Supabase Edge) previously
361
+ created a client pinned to the FFI's EQL v2 wire format, so a v3 schema
362
+ (concrete `eql_v3_*` domains) failed every encrypt on the edge. It now targets
363
+ EQL v3 exclusively:
364
+
365
+ - The factory constructs the WASM client with `eqlVersion: 3`, so v3 schemas
366
+ encrypt/decrypt correctly on the edge.
367
+ - The entry re-exports the **v3** authoring surface (`types`, `encryptedTable`,
368
+ the column classes, `buildEncryptConfig`, and the inference helpers) — the
369
+ same API as `@cipherstash/stack/eql/v3` — so an Edge Function authors and runs
370
+ v3 from one import:
371
+
372
+ ```ts
373
+ import {
374
+ Encryption,
375
+ encryptedTable,
376
+ types,
377
+ } from "@cipherstash/stack/wasm-inline";
378
+
379
+ const patients = encryptedTable("patients", {
380
+ email: types.TextSearch("email"),
381
+ });
382
+ const client = await Encryption({ schemas: [patients], config });
383
+ ```
384
+
385
+ The v2 schema builders (`encryptedColumn` / `encryptedField` / the v2
386
+ `encryptedTable`) are no longer exported from this entry, and passing a v2 table
387
+ throws a clear error. The WASM path was never announced or documented for v2 and
388
+ had no known users; EQL v2 remains fully supported on the native
389
+ `@cipherstash/stack` entry.
390
+
391
+ - 5411a13: Add the `@cipherstash/stack/adapter-kit` subpath — a narrow support surface for
392
+ the first-party adapter packages (`@cipherstash/stack-supabase`,
393
+ `@cipherstash/stack-drizzle`) being split out of this package (#627). It
394
+ re-exports exactly the core internals the adapters consume (the logger,
395
+ `AuditConfig`, the v3 column model + `DATE_LIKE_CASTS`, the domain registry, the
396
+ match-index guard, and the model→composite helpers) so those imports resolve
397
+ across the package boundary without leaking six internal module paths. This is the
398
+ core↔adapter seam, not general-purpose public API.
399
+ - 99f8b0a: Fix encrypted `in`-list operands in the Supabase adapter, and widen the `is` /
400
+ `contains` type surfaces.
401
+
402
+ **`in()` on an encrypted column produced a request PostgREST rejects.** Every
403
+ encrypted operand is a serialized envelope, dense with `"` and `,`. postgrest-js
404
+ wraps a comma-bearing element as `"…"` but never escapes the quotes already
405
+ inside it, so `.in('email', […])` emitted
406
+
407
+ ```
408
+ in.("{"v":1,"c":"…"}",…)
409
+ ^ PostgREST ends the value here → PGRST100
410
+ ```
411
+
412
+ Encrypted lists are now emitted through `filter(col, 'in', …)` with each element
413
+ quoted and escaped, matching what the `.or()` path already did. This affects
414
+ **v2 as well as v3** — v2's `("a@b.com")` composite literal is itself
415
+ quote-bearing and was equally broken.
416
+
417
+ **`not(col, 'in', […])` encrypted the whole list as a single ciphertext**, so
418
+ the filter silently matched nothing, and emitted an unparenthesized
419
+ `not.in.a,b`. Each element is now encrypted separately and the operand is
420
+ rendered as `not.in.(…)`. Passing a PostgREST list literal (`'(a,b)'`) for an
421
+ encrypted column now throws instead of silently matching nothing — pass an
422
+ array.
423
+
424
+ **`filter(col, 'in', […])` encrypted the whole list as a single ciphertext.**
425
+ The raw `.filter()` path reached `in` with none of the element-splitting the
426
+ `in()`, `not(…, 'in', …)` and `.or()` paths perform, so the entire list operand
427
+ was encrypted as one equality term. The two wire formats then failed
428
+ differently, which is why this went unnoticed: **v2**'s `("json")` composite
429
+ literal is already parenthesized, so PostgREST parsed it as a one-element list
430
+ and answered `200 []` — a filter that silently matched nothing. **v3**'s bare
431
+ `{…}` envelope is not, so PostgREST rejected the request outright with
432
+ `PGRST100 (failed to parse filter)`.
433
+
434
+ Each element is now encrypted separately and the operand rendered as a quoted
435
+ PostgREST list literal. As on the `not` path, passing a list literal
436
+ (`'(a,b)'`) for an encrypted column now throws instead — pass an array.
437
+
438
+ Plaintext columns are unaffected, including the pre-existing quirk that
439
+ postgrest-js renders `.filter(col, 'in', [array])` as an unparenthesized
440
+ `in.a,b` that PostgREST rejects; pass a list literal there, or use `.in()`.
441
+
442
+ **`is(col, null)` is now allowed on every column**, including storage-only
443
+ encrypted ones (`types.Boolean`, `types.Integer`, …). `is` is never encrypted
444
+ and a NULL plaintext is stored as a SQL NULL, so `IS NULL` is not merely legal
445
+ there but the only predicate those columns support. `is(col, true)` remains a
446
+ compile error on encrypted columns.
447
+
448
+ **`contains()` accepts native operands on plaintext array and jsonb columns.** A
449
+ plaintext jsonb/array column falls through to PostgREST's native containment, so
450
+ `contains('tags', ['vip'])` and `contains('meta', { plan: 'pro' })` now
451
+ typecheck. A plaintext SCALAR column does not: `@>` is undefined on `text`, so
452
+ the operand type follows the column's own shape and a scalar rejects every
453
+ containment operand. Encrypted match columns still take a `string` token.
454
+ Relatedly, `.or([{ op: 'contains' }])` now emits PostgREST's `cs` operator for
455
+ plaintext columns too — previously only encrypted conditions were translated, so
456
+ a plaintext containment reached the wire as `.contains.` and failed to parse.
457
+
458
+ **Direct `contains()` / `not(col, 'contains', …)` now serialize their operand.**
459
+ postgrest-js builds an array operand as `cs.{a,b}` with no element quoting, so
460
+ `contains('tags', ['with,comma'])` reached Postgres as two elements; and its
461
+ `not()` stringifies the operand outright, emitting `not.contains.with,comma`
462
+ (no braces, and the wrong operator token) or `[object Object]` for a jsonb
463
+ operand. Both paths now build the containment literal the `.or()` path already
464
+ built, and emit the `cs` token.
465
+
466
+ **`.or()` no longer drops a condition after an unbalanced brace or paren.** A
467
+ scalar operand containing `{` left the parser's depth counter stranded above
468
+ zero, so no later comma separated a condition and everything behind it was
469
+ swallowed into that operand. With a plaintext column first, the group was then
470
+ forwarded verbatim — running the swallowed condition against a ciphertext column
471
+ with a plaintext operand. Braces are now quoted on emit (they are structural to
472
+ PostgREST inside `or=(…)`), and the parser falls back to quote-only splitting
473
+ when its depth tracking does not balance.
474
+
475
+ **`is(col, true)` is now rejected on every encrypted column, not just the
476
+ storage-only ones.** The boolean form was gated on the filterable keys, which
477
+ exclude storage-only columns but keep queryable encrypted ones — so
478
+ `is(emailTextSearchColumn, true)` compiled and emitted `IS TRUE` against a jsonb
479
+ ciphertext.
480
+
481
+ **In-list operands encrypt in one crossing per column.** The element-wise `in` /
482
+ `not.in` encoding above spent one ZeroKMS round-trip per element; terms are now
483
+ grouped by column and each group takes a single `bulkEncrypt` call, matching the
484
+ Drizzle v3 path. Falls back to per-term encryption for clients without
485
+ `bulkEncrypt`, and rejects a bulk response whose length does not match the list
486
+ rather than silently truncating the predicate.
487
+
488
+ - 9b65ae8: **`order()` now works on EQL v3 encrypted ordering columns in the Supabase
489
+ adapter.** It was rejected outright on every encrypted column.
490
+
491
+ A bare `ORDER BY col` on an EQL v3 domain really is wrong — the bundle declares
492
+ no btree operator class on any domain, so the sort falls through to jsonb's
493
+ default `jsonb_cmp` and compares the envelope's keys in storage order, starting
494
+ at the random ciphertext `c`. Measured over ten rows it returns
495
+ `r00,r04,r08,r01,…` where the plaintext order is `r00..r09`. No error, a stable
496
+ and plausible-looking meaningless order.
497
+
498
+ But the correct sort key is reachable without a function call. `eql_v3.ord_term`
499
+ returns the domain's `op` term, and OPE is order-preserving, so ordering by the
500
+ term reproduces the plaintext order. PostgREST cannot emit
501
+ `ORDER BY eql_v3.ord_term(col)`, but it can emit a jsonb path. The builder now
502
+ emits `order=col->op` for an encrypted ordering column, verified against a live
503
+ PostgREST for `integer_ord` and `text_search` in both directions.
504
+
505
+ The guard is now on the ordering FLAVOUR, not on encryption:
506
+
507
+ - **`ope` present → supported.** Every plain `*_ord` domain, plus `text_ord` and
508
+ `text_search`.
509
+ - **`ore` present → rejected.** The `ob` term is an array of ORE blocks whose
510
+ comparison needs the superuser-only operator class, which no jsonb path can
511
+ reach. (Such a column cannot hold data on managed Postgres anyway: its domain
512
+ CHECK raises `ore_domain_unavailable`.) ORE columns are now excluded from
513
+ `order()` at COMPILE time too, not only at runtime — `.order(oreColumn)` is a
514
+ type error, matching the rejection.
515
+ - **neither → rejected.** Storage-only, equality-only and match-only columns
516
+ carry no ordering term.
517
+
518
+ The path is `col->op` (jsonb), not `col->>op` (text). Neither avoids the
519
+ database collation — Postgres compares jsonb strings with `varstr_cmp` under the
520
+ default collation, exactly as it does text. What makes the ordering
521
+ collation-independent is the term's encoding: lowercase hex, fixed-width for
522
+ numeric and date domains, and per-character (16 hex chars each) for text, so
523
+ lexicographic order reproduces plaintext order including the prefix case
524
+ (`ada` < `adam`). `ope-term.integration.test.ts` pins that shape.
525
+
526
+ `V3OrderableKeys` widens to admit OPE-backed ordering columns (`*_ord`,
527
+ `text_ord`, `text_search`) while still excluding ORE (`*_ord_ore`) columns, so
528
+ `order()` typechecks exactly where it works. `is(col, true)` is unaffected — it
529
+ stays plaintext-only, and now has its own `V3PlaintextKeys` rather than
530
+ borrowing the orderable set.
531
+
532
+ ### Patch Changes
533
+
534
+ - cfd46ee: Source the EQL v3 install bundle from `@cipherstash/eql@3.0.0-alpha.3` instead of a hand-vendored 43k-line SQL fixture committed to the test tree. The package publishes its SQL and its TypeScript wire types from the same `eql-bindings` commit, so the bundle is now pinned to a released EQL version rather than tracked by convention.
535
+
536
+ Test-and-tooling only — `@cipherstash/eql` is a `devDependency` and no public API changes.
537
+
538
+ The staleness check in the v3 install helper now compares `eql_v3.version()` against the pinned release instead of probing for a hand-picked sentinel domain. The previous sentinel (`public.timestamp`) exists in both the old and new bundles, so it would have reported a stale install as current and left the suite silently running the wrong SQL.
539
+
540
+ - 63ca540: Re-vendor the EQL v3 SQL bundle and align the v3 DSL to it: encrypted type domains now live in the `public` schema (`public.text`, `public.integer`, …) rather than `eql_v3`, and the boolean domain is `public.boolean` (was `eql_v3.bool`). The `eql_v3` schema now holds only the operator-backing functions, and the index-term constructors (`hmac_256`, `ore_block_256`, `bloom_filter`) moved to `eql_v3_internal`. This keeps the SDK's emitted domain names byte-matched to the installed bundle so `CREATE TABLE`/cast resolution succeeds.
541
+ - f23f952: Remove the leftovers from the secrets removal (`1929c8fe`), which deleted
542
+ `packages/stack/src/secrets/` but left its export, build entry, skill, and docs
543
+ behind. Secrets tooling is not ready; nothing here was functional.
544
+
545
+ - **Drop the dead `@cipherstash/stack/secrets` subpath export.** It pointed at
546
+ `./dist/secrets/index.js`, which has no source and is not in the tarball, so
547
+ `import '@cipherstash/stack/secrets'` has been throwing `ERR_MODULE_NOT_FOUND`
548
+ for every consumer since the source was removed. Also drops the dangling
549
+ `src/secrets/index.ts` entry from `tsup.config.ts`. Removing an export that
550
+ cannot resolve breaks nothing.
551
+ - **Remove the `stash-secrets` agent skill** and its references in `AGENTS.md`
552
+ and the init setup-prompt skill index. It was never installed by `stash init`
553
+ (it is absent from `SKILL_MAP`), so no user project ever received it.
554
+ - **Remove the secrets documentation** from both published READMEs: the
555
+ `Secrets` class API and the `npx stash secrets` command reference in
556
+ `@cipherstash/stack`, and the `npx stash secrets` section in `stash`. The CLI
557
+ command does not exist — `stash secrets` returns `Unknown command`.
558
+
559
+ - fd33aad: Fix the Supabase adapter encrypting `is` and `null` filter operands.
560
+
561
+ `is` is a SQL predicate — PostgREST accepts only `null`/`true`/`false` after it
562
+ — and a `null` operand is SQL NULL, never a value to search for. Only the direct
563
+ `.is()` filter skipped encryption; `not()`, `or()`, `match()`, raw `filter()`,
564
+ and the `in()` element list all encrypted whatever they were handed. So
565
+ `or('age.is.null')` emitted `age.is."("null")"` and `eq('email', null)` emitted
566
+ `email=("null")` — operands PostgREST rejects. A null plaintext is stored as a
567
+ NULL column rather than ciphertext, so it is found with an unencrypted
568
+ `IS NULL`; encrypting the operand could never match.
569
+
570
+ A single `isEncryptableTerm(operator, value)` predicate now guards every term
571
+ collector. Affects both `encryptedSupabase` (v2) and `encryptedSupabaseV3`. On
572
+ v3 this additionally removes a spurious `does not support equality queries`
573
+ error, which `is` raised because it maps to the `equality` query type and so hit
574
+ the column-capability guard — `or('active.is.null')` on a storage-only column
575
+ threw rather than querying.
576
+
577
+ Relatedly, an `or()` string is now rebuilt whenever a condition _references_ an
578
+ encrypted column, not only when one of its values was encrypted. An `is` on an
579
+ encrypted column encrypts nothing, and the old condition sent it down the
580
+ verbatim path, forwarding the caller's JS property name to a database that only
581
+ knows the column's DB name.
582
+
583
+ - 8cd485d: Fix the Supabase adapter's `.or()` string parser mis-splitting conditions, and pin `contains()` on a mixed union column key to the encrypted operand.
584
+
585
+ 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.
586
+
587
+ **Quotes were tracked only at brace depth 0.** A `}` inside a quoted array element or jsonb string value closed the literal early, and the next `"` re-opened quoting, so the following top-level comma never split: `.or('tags.cs.{"a}b"},email.eq.secret')` parsed as a single condition and silently absorbed `email.eq.secret` into the operand. Quotes are now opaque at every depth.
588
+
589
+ **A stray `}` or `)` drove the depth counter negative**, after which no comma split again. `}` and `)` are not PostgREST reserved characters, so `a}b` is a valid unquoted operand and `.or('nickname.eq.a}b,id.eq.1')` dropped `id.eq.1`. Depth now floors at zero.
590
+
591
+ **`in`-list elements were split on every comma, ignoring quotes.** `.or('email.in.("a,b",c)')` parsed as three elements with the quotes still embedded; on an encrypted column each fragment was encrypted as its own term, so the intended element never matched. Elements are now split on top-level commas and unquoted, the inverse of what the rebuild emits.
592
+
593
+ **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.
594
+
595
+ **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.
596
+
597
+ **`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.
598
+
3
599
  ## 0.19.0
4
600
 
5
601
  ### Minor Changes