@cipherstash/stack 1.0.0-rc.1 → 1.0.0-rc.3

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.
@@ -11,10 +11,134 @@ import {
11
11
  import {
12
12
  decrypt as wasmDecrypt,
13
13
  encrypt as wasmEncrypt,
14
+ encryptQuery as wasmEncryptQuery,
15
+ encryptQueryBulk as wasmEncryptQueryBulk,
14
16
  isEncrypted as wasmIsEncrypted,
15
17
  newClient as wasmNewClient
16
18
  } from "@cipherstash/protect-ffi/wasm-inline";
17
19
 
20
+ // src/types.ts
21
+ var queryTypeToFfi = {
22
+ // v3 `_ord` domains carry `ope` instead — `resolveIndexType` swaps this
23
+ // static default for the ordering index the column actually configures.
24
+ orderAndRange: "ore",
25
+ freeTextSearch: "match",
26
+ equality: "unique",
27
+ steVecSelector: "ste_vec",
28
+ steVecTerm: "ste_vec",
29
+ searchableJson: "ste_vec"
30
+ };
31
+ var queryTypeToQueryOp = {
32
+ steVecSelector: "ste_vec_selector",
33
+ steVecTerm: "ste_vec_term"
34
+ };
35
+
36
+ // src/encryption/helpers/infer-index-type.ts
37
+ function inferIndexType(column) {
38
+ const config = column.build();
39
+ const indexes = config.indexes;
40
+ if (!indexes || Object.keys(indexes).length === 0) {
41
+ throw new Error(`Column "${column.getName()}" has no indexes configured`);
42
+ }
43
+ if (indexes.unique) return "unique";
44
+ if (indexes.match) return "match";
45
+ if (indexes.ore) return "ore";
46
+ if (indexes.ope) return "ope";
47
+ if (indexes.ste_vec) return "ste_vec";
48
+ throw new Error(
49
+ `Column "${column.getName()}" has no suitable index for queries`
50
+ );
51
+ }
52
+ function inferQueryOpFromPlaintext(plaintext) {
53
+ if (typeof plaintext === "string") {
54
+ return "ste_vec_selector";
55
+ }
56
+ if (typeof plaintext === "object" || typeof plaintext === "number" || typeof plaintext === "boolean") {
57
+ return "ste_vec_term";
58
+ }
59
+ return "ste_vec_term";
60
+ }
61
+ function validateIndexType(column, indexType) {
62
+ const config = column.build();
63
+ const indexes = config.indexes ?? {};
64
+ const indexMap = {
65
+ unique: !!indexes.unique,
66
+ match: !!indexes.match,
67
+ ore: !!indexes.ore,
68
+ ope: !!indexes.ope,
69
+ ste_vec: !!indexes.ste_vec
70
+ };
71
+ if (!indexMap[indexType]) {
72
+ throw new Error(
73
+ `Index type "${indexType}" is not configured on column "${column.getName()}"`
74
+ );
75
+ }
76
+ }
77
+ function equalityOrderingIndex(column) {
78
+ if (!("getQueryCapabilities" in column)) return null;
79
+ if (!column.getQueryCapabilities().equality) return null;
80
+ const indexes = column.build().indexes ?? {};
81
+ if (indexes.unique) return null;
82
+ if (indexes.ore) return "ore";
83
+ if (indexes.ope) return "ope";
84
+ return null;
85
+ }
86
+ function resolveIndexType(column, queryType, plaintext) {
87
+ let indexType = queryType ? queryTypeToFfi[queryType] : inferIndexType(column);
88
+ if (queryType) {
89
+ if (queryType === "equality") {
90
+ const ordering = equalityOrderingIndex(column);
91
+ if (ordering) return { indexType: ordering };
92
+ }
93
+ if (queryType === "orderAndRange" && indexType === "ore") {
94
+ const indexes = column.build().indexes ?? {};
95
+ if (!indexes.ore && indexes.ope) indexType = "ope";
96
+ }
97
+ validateIndexType(column, indexType);
98
+ if (queryType === "searchableJson") {
99
+ if (plaintext === void 0 || plaintext === null) {
100
+ return { indexType };
101
+ }
102
+ return { indexType, queryOp: inferQueryOpFromPlaintext(plaintext) };
103
+ }
104
+ return { indexType, queryOp: queryTypeToQueryOp[queryType] };
105
+ }
106
+ if (indexType === "ste_vec") {
107
+ if (plaintext === void 0 || plaintext === null) {
108
+ return { indexType };
109
+ }
110
+ return { indexType, queryOp: inferQueryOpFromPlaintext(plaintext) };
111
+ }
112
+ return { indexType };
113
+ }
114
+
115
+ // src/encryption/helpers/validation.ts
116
+ var INT64_MIN = -9223372036854775808n;
117
+ var INT64_MAX = 9223372036854775807n;
118
+ function isBigintOutOfInt64Range(value) {
119
+ return typeof value === "bigint" && (value < INT64_MIN || value > INT64_MAX);
120
+ }
121
+ function assertValidNumericValue(value) {
122
+ if (typeof value === "number" && Number.isNaN(value)) {
123
+ throw new Error("[encryption]: Cannot encrypt NaN value");
124
+ }
125
+ if (typeof value === "number" && !Number.isFinite(value)) {
126
+ throw new Error("[encryption]: Cannot encrypt Infinity value");
127
+ }
128
+ if (isBigintOutOfInt64Range(value)) {
129
+ throw new Error(
130
+ "[encryption]: Cannot encrypt bigint value out of int64 range"
131
+ );
132
+ }
133
+ }
134
+ function assertValueIndexCompatibility(value, indexType, columnName) {
135
+ if ((typeof value === "number" || typeof value === "bigint") && indexType === "match") {
136
+ throw new Error(
137
+ `[encryption]: Cannot use 'match' index with numeric value on column "${columnName}". The 'freeTextSearch' index only supports string values. Configure the column with 'orderAndRange()' or 'equality()' for numeric queries.`
138
+ );
139
+ }
140
+ }
141
+
18
142
  // src/schema/match-defaults.ts
19
143
  function defaultMatchOpts() {
20
144
  return {
@@ -4748,6 +4872,134 @@ var WasmEncryptionClient = class {
4748
4872
  isEncrypted(value) {
4749
4873
  return wasmIsEncrypted(value);
4750
4874
  }
4875
+ /**
4876
+ * Encrypt a QUERY TERM (search needle) for a queryable v3 column —
4877
+ * equality, free-text match, ORE range, or JSON containment/selector.
4878
+ *
4879
+ * The returned term is **ciphertext-free**: it is matched against stored
4880
+ * envelopes, never decrypted, so it is safe to log-scrub less aggressively
4881
+ * than storage payloads (though it still derives from the plaintext).
4882
+ * Interpolate it as a parameter and cast to the column's
4883
+ * `eql_v3.query_<domain>` type to reach the indexed operators — the domain
4884
+ * suffix mirrors the storage domain (`eql_v3_text_eq` →
4885
+ * `eql_v3.query_text_eq`; irregular: `eql_v3_json` → `eql_v3.query_jsonb`).
4886
+ *
4887
+ * @example Equality (unique index)
4888
+ * ```ts
4889
+ * const term = await client.encryptQuery("alice@example.com", {
4890
+ * table: users, column: users.email, queryType: "equality",
4891
+ * })
4892
+ * // postgres-js:
4893
+ * sql`SELECT * FROM users
4894
+ * WHERE email = ${term}::jsonb::eql_v3.query_text_eq`
4895
+ * ```
4896
+ *
4897
+ * @example Free-text match (bloom index — one-sided, fuzzy)
4898
+ * ```ts
4899
+ * const term = await client.encryptQuery("needle", {
4900
+ * table: users, column: users.bio, queryType: "freeTextSearch",
4901
+ * })
4902
+ * sql`SELECT * FROM users
4903
+ * WHERE eql_v3.contains(bio, ${term}::jsonb::eql_v3.query_text_search)`
4904
+ * ```
4905
+ *
4906
+ * @example Range / ORDER BY (ORE index)
4907
+ * ```ts
4908
+ * const term = await client.encryptQuery(42, {
4909
+ * table: users, column: users.age, queryType: "orderAndRange",
4910
+ * })
4911
+ * sql`SELECT * FROM users
4912
+ * WHERE eql_v3.gte(age, ${term}::jsonb::eql_v3.query_integer_ord)`
4913
+ * ```
4914
+ *
4915
+ * @example Encrypted JSON — containment and JSONPath selector
4916
+ * ```ts
4917
+ * // Object value → containment needle (a v3 envelope):
4918
+ * const contains = await client.encryptQuery({ role: "admin" }, {
4919
+ * table: users, column: users.prefs, queryType: "searchableJson",
4920
+ * })
4921
+ * sql`SELECT * FROM users
4922
+ * WHERE prefs @> ${contains}::jsonb::eql_v3.query_jsonb`
4923
+ *
4924
+ * // String value → JSONPath selector. NOTE: v3 has no encrypted-selector
4925
+ * // envelope — this returns the BARE selector-hash string, bound as the
4926
+ * // text argument of -> / ->>:
4927
+ * const selector = await client.encryptQuery("$.role", {
4928
+ * table: users, column: users.prefs, queryType: "searchableJson",
4929
+ * })
4930
+ * sql`SELECT prefs -> ${selector} FROM users`
4931
+ * ```
4932
+ *
4933
+ * @param plaintext - The search needle. `null`/`undefined` returns `null`
4934
+ * without contacting ZeroKMS (nothing to search for), mirroring the
4935
+ * native client.
4936
+ * @param opts - Table, column, and (optionally) which index to target —
4937
+ * see {@link WasmEncryptQueryOptions.queryType} for the inference rules.
4938
+ * @returns The v3 query term, or `null` for null plaintext.
4939
+ * @throws When the requested `queryType` isn't configured on the column,
4940
+ * the column has no indexes at all, the value fails the same pre-flight
4941
+ * validation the native client runs (NaN / Infinity / out-of-int64
4942
+ * bigint, or a numeric value against a `freeTextSearch` index), or
4943
+ * encryption fails. Errors THROW, consistent with this surface's
4944
+ * `encrypt`/`decrypt` (the native entry's `{ data } | { failure }`
4945
+ * envelope lives on the Node client only).
4946
+ */
4947
+ async encryptQuery(plaintext, opts) {
4948
+ if (plaintext === null || plaintext === void 0) return null;
4949
+ return await wasmEncryptQuery(
4950
+ // biome-ignore lint/plugin: the FFI handle is an opaque wasm-bindgen pointer with no JS-side type
4951
+ this.client,
4952
+ // biome-ignore lint/plugin: the term crosses the serde boundary, whose shape protect-ffi types as `any`
4953
+ toFfiQueryTerm(plaintext, opts)
4954
+ );
4955
+ }
4956
+ /**
4957
+ * Batch form of {@link encryptQuery} — one ZeroKMS round trip for many
4958
+ * terms, which is the shape query builders want (encrypt every needle in
4959
+ * a WHERE clause together).
4960
+ *
4961
+ * Position-stable: the result array is index-aligned with `terms`, and a
4962
+ * `null`/`undefined` value yields `null` at the same index (an all-null
4963
+ * batch short-circuits without calling ZeroKMS). Terms may mix query
4964
+ * types and columns freely.
4965
+ *
4966
+ * @example
4967
+ * ```ts
4968
+ * const [emailEq, bioMatch] = await client.encryptQueryBulk([
4969
+ * { value: "alice@example.com", table: users, column: users.email,
4970
+ * queryType: "equality" },
4971
+ * { value: "needle", table: users, column: users.bio,
4972
+ * queryType: "freeTextSearch" },
4973
+ * ])
4974
+ * ```
4975
+ *
4976
+ * @param terms - The needles to encrypt; see {@link WasmQueryTerm}.
4977
+ * @returns Index-aligned array of v3 query terms (or `null` per null value).
4978
+ * @throws As {@link encryptQuery} — the first invalid term aborts the batch.
4979
+ */
4980
+ async encryptQueryBulk(terms) {
4981
+ const live = [];
4982
+ terms.forEach((term, at) => {
4983
+ if (term.value !== null && term.value !== void 0)
4984
+ live.push({ term, at });
4985
+ });
4986
+ const out = terms.map(() => null);
4987
+ if (live.length === 0) return out;
4988
+ const ffiTerms = live.map(({ term }) => toFfiQueryTerm(term.value, term));
4989
+ const encrypted = await wasmEncryptQueryBulk(
4990
+ // biome-ignore lint/plugin: the FFI handle is an opaque wasm-bindgen pointer with no JS-side type
4991
+ this.client,
4992
+ // biome-ignore lint/plugin: the batch crosses the serde boundary, whose shape protect-ffi types as `any`
4993
+ {
4994
+ queries: ffiTerms
4995
+ }
4996
+ );
4997
+ encrypted.forEach((value, i) => {
4998
+ const slot = live[i];
4999
+ if (slot) out[slot.at] = value;
5000
+ });
5001
+ return out;
5002
+ }
4751
5003
  };
4752
5004
  async function Encryption(config) {
4753
5005
  const { schemas, config: clientConfig } = config;
@@ -4806,6 +5058,22 @@ function getColumnName(col) {
4806
5058
  "[encryption]: opts.column must be a column builder exposing getName()"
4807
5059
  );
4808
5060
  }
5061
+ function toFfiQueryTerm(value, opts) {
5062
+ assertValidNumericValue(value);
5063
+ const { indexType, queryOp } = resolveIndexType(
5064
+ opts.column,
5065
+ opts.queryType,
5066
+ value
5067
+ );
5068
+ assertValueIndexCompatibility(value, indexType, getColumnName(opts.column));
5069
+ return {
5070
+ plaintext: value,
5071
+ table: opts.table.tableName,
5072
+ column: getColumnName(opts.column),
5073
+ indexType,
5074
+ ...queryOp ? { queryOp } : {}
5075
+ };
5076
+ }
4809
5077
  var warnedStrategyDeprecated = false;
4810
5078
  function warnStrategyDeprecated() {
4811
5079
  if (warnedStrategyDeprecated) return;