diff --git a/.changeset/young-waves-worry.md b/.changeset/young-waves-worry.md
new file mode 100644
index 00000000..988c36db
--- /dev/null
+++ b/.changeset/young-waves-worry.md
@@ -0,0 +1,7 @@
+---
+"@cipherstash/protect": minor
+---
+
+* Added support for searching encrypted data
+* Added a schema strategy for defining your schema
+* Required schema to initialize the protect client
diff --git a/.gitignore b/.gitignore
index cc3a9e76..fc7ee438 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,3 +65,4 @@ mise.local.toml
# cipherstash
cipherstash.toml
cipherstash.secret.toml
+sql/cipherstash-*.sql
diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md
index 4b48eaa6..9363d06a 100644
--- a/CONTRIBUTE.md
+++ b/CONTRIBUTE.md
@@ -98,6 +98,27 @@ 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](./.github/workflows/) (or other CI pipeline) will handle the **publish** step to npm once your PR is merged and the changeset is committed to `main`.
+## Pre release process
+
+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.
+
+To do so, you need to:
+
+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`
+
+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.
+
+> [!IMPORTANT]
+> 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.
+
## Additional Resources
- [Turborepo Documentation](https://turbo.build/repo/docs)
diff --git a/README.md b/README.md
index bf7769be..db4b250b 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,29 @@
-# Protect.js
+
+
+
-[](https://github.com/cipherstash/protectjs/actions/workflows/tests.yml)
-[](https://cipherstash.com)
+ Protect.js
+
+ Implement robust data security without sacrificing performance or usability
+
+ Built by CipherStash
+
+
-Protect.js is a JavaScript/TypeScript package for encrypting and decrypting data in PostgreSQL databases.
-Encryption operations happen directly in your app, and the ciphertext is stored in your PostgreSQL database.
+
-Every value you encrypt with Protect.js has a unique key, made possible by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms)'s blazing fast bulk key operations.
-Under the hood Protect.js uses CipherStash [Encrypt Query Language (EQL)](https://github.com/cipherstash/encrypt-query-language), and all ZeroKMS data keys are backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html).
+## What's Protect.js?
+
+Protect.js is a TypeScript package for encrypting and decrypting data.
+Encryption operations happen directly in your app, and the ciphertext is stored in your database.
+
+Every value you encrypt with Protect.js has a unique key, made possible by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms)'s blazing fast bulk key operations, and backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html).
+
+The encrypted data is structured as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload, and can be stored in any database that supports JSONB.
+
+> [!IMPORTANT]
+> Searching, sorting, and filtering on encrypted data is only supported in PostgreSQL at the moment.
+> Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md).
## Table of contents
@@ -29,7 +45,7 @@ For more specific documentation, please refer to the [docs](https://github.com/c
## Features
-Protect.js protects data in PostgreSQL databases using industry-standard AES encryption.
+Protect.js protects data in using industry-standard AES encryption.
Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for bulk encryption and decryption operations.
This enables every encrypted value, in every column, in every row in your database to have a unique key β without sacrificing performance.
@@ -38,6 +54,8 @@ This enables every encrypted value, in every column, in every row in your databa
- **Single item encryption and decryption**: Just looking for a way to encrypt and decrypt single values? Protect.js has you covered.
- **Really fast:** ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Protect.js.
- **Identity-aware encryption**: Lock down access to sensitive data by requiring a valid JWT to perform a decryption.
+- **Audit trail**: Every decryption event will be logged in ZeroKMS to help you prove compliance.
+- **Searchable encryption**: Protect.js supports searching encrypted data in PostgreSQL.
- **TypeScript support**: Strongly typed with TypeScript interfaces and types.
**Use cases:**
@@ -54,7 +72,7 @@ Check out the example applications:
- [Drizzle example](/apps/drizzle) demonstrates how to use Protect.js with an ORM
- [Next.js and lock contexts example using Clerk](/apps/nextjs-clerk) demonstrates how to protect data with identity-aware encryption
-`@cipherstash/protect` can be used with most ORMs that support PostgreSQL.
+`@cipherstash/protect` can be used with most ORMs.
If you're interested in using `@cipherstash/protect` with a specific ORM, please [create an issue](https://github.com/cipherstash/protectjs/issues/new).
## Installing Protect.js
@@ -115,33 +133,86 @@ At the end of `stash setup`, you will have two files in your project:
> `cipherstash.secret.toml` should not be committed to git, because it contains sensitive credentials.
> The `stash setup` command will attempt to append to your `.gitignore` file with the `cipherstash.secret.toml` file.
-You can read more about [configuration via toml file or environment variables here](./docs/configuration.md).
+You can read more about [configuration via toml file or environment variables here](./docs/reference/configuration.md).
-### Initializing the EQL client
+### Basic file structure
-In your application, import the `protect` function from the `@cipherstash/protect` package, and initialize a client with your CipherStash credentials.
+This is the basic file structure of the project. In the `src/protect` directory, we have table definition in `schema.ts` and the protect client in `index.ts`.
-```typescript
-const { protect } = require('@cipherstash/protect')
-const protectClient = await protect()
+```
+π¦
+ β π src
+ β β π protect
+ β β β π index.ts
+ β β β π schema.ts
+ β β π index.ts
+ β π .env
+ β π cipherstash.toml
+ β π cipherstash.secret.toml
+ β π package.json
+ β π tsconfig.json
```
-If you are using ES6:
+### Defining your schema
-```typescript
+Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt.
+
+In the `src/protect/schema.ts` file, you can define your tables and columns.
+
+```ts
+import { csTable, csColumn } from '@cipherstash/protect'
+
+export const users = csTable('users', {
+ email: csColumn('email'),
+})
+
+export const orders = csTable('orders', {
+ address: csColumn('address'),
+})
+```
+
+**Searchable encryption**
+
+If you are looking to enable searchable encryption in a PostgreSQL database, you must declaratively enable the indexes in your schema.
+
+```ts
+import { csTable, csColumn } from '@cipherstash/protect'
+
+export const users = csTable('users', {
+ email: csColumn('email').freeTextSearch().equality().orderAndRange(),
+})
+```
+
+Read more about [defining your schema here](./docs/reference/schema.md).
+
+### Initializing the Protect client
+
+To initialize the protect client, import the `protect` function and initialize a client with your defined schema.
+
+In the `src/protect/index.ts` file:
+
+```ts
import { protect } from '@cipherstash/protect'
-const protectClient = await protect()
+import { users } from './schema'
+
+// Pass all your tables to the protect function to initialize the client
+export const protectClient = await protect(users, orders)
```
+The `protect` function requires at least one `csTable` to be passed in.
+
### Encrypting data
Use the `encrypt` function to encrypt data.
-`encrypt` takes a plaintext string, and an object with the table and column name as parameters.
+`encrypt` takes a plaintext string, and an object with the table and column as parameters.
```typescript
+import { users } from './protect/schema'
+import { protectClient } from './protect'
+
const encryptResult = await protectClient.encrypt('secret@squirrel.example', {
- column: 'email',
- table: 'users',
+ column: users.email,
+ table: users,
})
if (encryptResult.failure) {
@@ -177,9 +248,11 @@ The `encryptResult` will return one of the following:
### Decrypting data
Use the `decrypt` function to decrypt data.
-`decrypt` takes an encrypted data object, and an object with the lock context as parameters.
+`decrypt` takes an encrypted data object as a parameter.
```typescript
+import { protectClient } from './protect'
+
const decryptResult = await protectClient.decrypt(ciphertext)
if (decryptResult.failure) {
@@ -212,7 +285,9 @@ The `decryptResult` will return one of the following:
### Storing encrypted data in a database
-To store the encrypted data in PostgreSQL, you will need to specify the column type as `jsonb`.
+Encrypted data can be stored in any database that supports JSONB, noting that searchable encryption is only supported in PostgreSQL at the moment.
+
+To store the encrypted data, you will need to specify the column type as `jsonb`.
```sql
CREATE TABLE users (
@@ -221,8 +296,37 @@ CREATE TABLE users (
);
```
+#### Searchable encryption in PostgreSQL
+
+To enable searchable encryption in PostgreSQL, you need to [install the EQL custom types and functions](https://github.com/cipherstash/encrypt-query-language?tab=readme-ov-file#installation).
+
+1. Download the latest EQL install script:
+
+ ```sh
+ curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql
+ ```
+
+2. Run this command to install the custom types and functions:
+
+ ```sh
+ psql -f cipherstash-encrypt.sql
+ ```
+
+EQL is now installed in your database and you can enable searchable encryption by adding the `cs_encrypted_v1` type to a column.
+
+```sql
+CREATE TABLE users (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ email cs_encrypted_v1
+);
+```
+
## Identity-aware encryption
+> [!IMPORTANT]
+> Right now identity-aware encryption is only supported if you are using [Clerk](https://clerk.com/) as your identity provider.
+> Read more about [lock contexts with Clerk and Next.js](./docs/how-to/lock-contexts-with-clerk.md).
+
Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption.
This ensures that only the user who encrypted data is able to decrypt it.
@@ -270,9 +374,12 @@ const lockContext = identifyResult.data
To encrypt data with a lock context, call the optional `withLockContext` method on the `encrypt` function and pass the lock context object as a parameter:
```typescript
+import { protectClient } from './protect'
+import { users } from './protect/schema'
+
const encryptResult = await protectClient.encrypt('plaintext', {
- table: 'users',
- column: 'email',
+ table: users,
+ column: users.email,
}).withLockContext(lockContext)
if (encryptResult.failure) {
@@ -287,6 +394,8 @@ const ciphertext = encryptResult.data
To decrypt data with a lock context, call the optional `withLockContext` method on the `decrypt` function and pass the lock context object as a parameter:
```typescript
+import { protectClient } from './protect'
+
const decryptResult = await protectClient.decrypt(ciphertext).withLockContext(lockContext)
if (decryptResult.failure) {
@@ -338,8 +447,8 @@ const encryptedValues = encryptedResults.data
// encryptedValues might look like:
// [
-// { c: 'ENCRYPTED_VALUE_1', id: '1' },
-// { c: 'ENCRYPTED_VALUE_2', id: '2' },
+// { encryptedData: { c: 'ENCRYPTED_VALUE_1', k: 'ct' }, id: '1' },
+// { encryptedData: { c: 'ENCRYPTED_VALUE_2', k: 'ct' }, id: '2' },
// ]
```
@@ -350,12 +459,12 @@ encryptedValues.forEach((result) => {
// Find the corresponding user
const user = users.find((u) => u.id === result.id)
if (user) {
- user.email = result.c // Store ciphertext back into the user object
+ user.email = result.encryptedData // Store the encrypted data back into the user object
}
})
```
-Learn more about [bulk encryption](./docs/bulk-encryption-decryption.md#bulk-encrypting-data)
+Learn more about [bulk encryption](./docs/reference/bulk-encryption-decryption.md#bulk-encrypting-data)
### Bulk decrypting data
@@ -406,25 +515,18 @@ decryptedValues.forEach((result) => {
})
```
-Learn more about [bulk decryption](./docs/bulk-encryption-decryption.md#bulk-decrypting-data)
+Learn more about [bulk decryption](./docs/reference/bulk-encryption-decryption.md#bulk-decrypting-data)
## Supported data types
-`@cipherstash/protect` currently supports encrypting and decrypting text.
-Other data types like booleans, dates, ints, floats, and JSON are extremely well supported in other CipherStash products, and will be coming to `@cipherstash/protect`.
-Until support for other data types are available in `@cipherstash/protect`, you can:
+Protect.js currently supports encrypting and decrypting text.
+Other data types like booleans, dates, ints, floats, and JSON are well supported in other CipherStash products, and will be coming to Protect.js soon.
-- Read [about how these data types work in EQL](https://github.com/cipherstash/encrypt-query-language/blob/main/docs/reference/INDEX.md)
-- Express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/48).
+Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/48).
## Searchable encryption
-`@cipherstash/protect` does not currently support searching encrypted data.
-Searchable encryption is an extremely well supported capability in other CipherStash products, and will be coming to `@cipherstash/protect`.
-Until searchable encryption support is released in `@cipherstash/protect`, you can:
-
-- Read [about how searchable encryption works in EQL](https://github.com/cipherstash/encrypt-query-language)
-- Express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/46).
+Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) in the docs.
## Logging
@@ -446,7 +548,7 @@ PROTECT_LOG_LEVEL=error # Enable error logging
Protect.js is built on top of the CipherStash Client Rust SDK which is embedded with the `@cipherstash/protect-ffi` package.
The `@cipherstash/protect-ffi` source code is available on [GitHub](https://github.com/cipherstash/protectjs-ffi).
-Read more about configuring the CipherStash client in the [configuration docs](./docs/configuration.md).
+Read more about configuring the CipherStash client in the [configuration docs](./docs/reference/configuration.md).
## Builds and bundling
@@ -454,8 +556,8 @@ Read more about configuring the CipherStash client in the [configuration docs](.
Here are a few resources to help based on your tool set:
-- [Required Next.js configuration](./docs/nextjs.md).
-- [SST and AWS serverless functions](./docs/sst.md).
+- [Required Next.js configuration](./docs/how-to/nextjs-external-packages.md).
+- [SST and AWS serverless functions](./docs/how-to/sst-external-packages.md).
## Contributing
diff --git a/apps/basic/index.mjs b/apps/basic/index.ts
similarity index 68%
rename from apps/basic/index.mjs
rename to apps/basic/index.ts
index af64d49c..d9233cbd 100644
--- a/apps/basic/index.mjs
+++ b/apps/basic/index.ts
@@ -1,27 +1,26 @@
import 'dotenv/config'
-import { protect } from '@cipherstash/protect'
-import readline from 'node:readline';
+import { protectClient, users } from './protect'
+import readline from 'node:readline'
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
-});
+})
-const askQuestion = () => {
+const askQuestion = (): Promise => {
return new Promise((resolve) => {
- rl.question("\nπHello\n\nWhat is your name? ", (answer) => {
- resolve(answer);
- });
- });
-};
+ rl.question('\nπHello\n\nWhat is your name? ', (answer) => {
+ resolve(answer)
+ })
+ })
+}
async function main() {
- const protectClient = await protect()
- const input = await askQuestion();
+ const input = await askQuestion()
const encryptResult = await protectClient.encrypt(input, {
- column: 'column_name',
- table: 'users',
+ column: users.name,
+ table: users,
})
if (encryptResult.failure) {
@@ -44,7 +43,7 @@ async function main() {
console.log('Decrypting the ciphertext...')
console.log('The plaintext is:', plaintext)
- rl.close();
+ rl.close()
}
main()
diff --git a/apps/basic/package.json b/apps/basic/package.json
index ac4968c9..9299d3d4 100644
--- a/apps/basic/package.json
+++ b/apps/basic/package.json
@@ -4,7 +4,7 @@
"version": "1.0.3",
"main": "index.mjs",
"scripts": {
- "start": "node index.mjs"
+ "start": "tsx index.ts"
},
"keywords": [],
"author": "",
@@ -12,6 +12,8 @@
"description": "",
"dependencies": {
"@cipherstash/protect": "workspace:*",
+ "typescript": "^5.0.0",
+ "tsx": "^4.19.2",
"dotenv": "^16.4.7"
}
}
diff --git a/apps/basic/protect.ts b/apps/basic/protect.ts
new file mode 100644
index 00000000..40e1fc8c
--- /dev/null
+++ b/apps/basic/protect.ts
@@ -0,0 +1,8 @@
+import 'dotenv/config'
+import { protect, csColumn, csTable } from '@cipherstash/protect'
+
+export const users = csTable('users', {
+ name: csColumn('name'),
+})
+
+export const protectClient = await protect(users)
diff --git a/apps/basic/tsconfig.json b/apps/basic/tsconfig.json
new file mode 100644
index 00000000..238655f2
--- /dev/null
+++ b/apps/basic/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ // Enable latest features
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ }
+}
diff --git a/apps/drizzle/package.json b/apps/drizzle/package.json
index f0e6eccc..f99652e6 100644
--- a/apps/drizzle/package.json
+++ b/apps/drizzle/package.json
@@ -8,17 +8,15 @@
"@types/pg": "^8.11.10",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.24.2",
+ "typescript": "^5.0.0",
"tsx": "^4.19.2"
},
"scripts": {
- "insert": "ts-node src/insert.ts",
- "select": "ts-node src/select.ts",
+ "insert": "tsx src/insert.ts",
+ "select": "tsx src/select.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
},
- "peerDependencies": {
- "typescript": "^5.0.0"
- },
"dependencies": {
"@cipherstash/protect": "workspace:*",
"drizzle-orm": "^0.33.0",
diff --git a/apps/drizzle/src/insert.ts b/apps/drizzle/src/insert.ts
index b39efbe9..2f3bd194 100644
--- a/apps/drizzle/src/insert.ts
+++ b/apps/drizzle/src/insert.ts
@@ -1,9 +1,8 @@
import 'dotenv/config'
import { parseArgs } from 'node:util'
-import { getTableName } from 'drizzle-orm'
import { db } from './db'
import { users } from './db/schema'
-import { protectClient } from './protect'
+import { protectClient, users as protectUsers } from './protect'
const getEmail = () => {
const { values, positionals } = parseArgs({
@@ -27,8 +26,8 @@ if (!email) {
}
const encryptedResult = await protectClient.encrypt(email, {
- column: users.email_encrypted.name,
- table: getTableName(users),
+ column: protectUsers.email_encrypted,
+ table: protectUsers,
})
if (encryptedResult.failure) {
diff --git a/apps/drizzle/src/protect.ts b/apps/drizzle/src/protect.ts
index a992c4e9..8e683cd5 100644
--- a/apps/drizzle/src/protect.ts
+++ b/apps/drizzle/src/protect.ts
@@ -1,4 +1,11 @@
import 'dotenv/config'
-import { protect } from '@cipherstash/protect'
+import { protect, csColumn, csTable } from '@cipherstash/protect'
-export const protectClient = await protect()
+export const users = csTable('users', {
+ email_encrypted: csColumn('email_encrypted')
+ .equality()
+ .orderAndRange()
+ .freeTextSearch(),
+})
+
+export const protectClient = await protect(users)
diff --git a/apps/drizzle/src/select.ts b/apps/drizzle/src/select.ts
index b13cf52e..414ab404 100644
--- a/apps/drizzle/src/select.ts
+++ b/apps/drizzle/src/select.ts
@@ -1,23 +1,91 @@
import 'dotenv/config'
import { db } from './db'
import { users } from './db/schema'
-import { protectClient } from './protect'
-import type { ProtectError, Result } from '@cipherstash/protect'
+import { protectClient, users as protectUsers } from './protect'
+import { bindIfParam, sql } from 'drizzle-orm'
+import type { BinaryOperator, SQL, SQLWrapper } from 'drizzle-orm'
+import { parseArgs } from 'node:util'
+import type { EncryptedData } from '@cipherstash/protect'
-const sql = db
+const getArgs = () => {
+ const { values, positionals } = parseArgs({
+ args: process.argv,
+ options: {
+ filter: {
+ type: 'string',
+ },
+ op: {
+ type: 'string',
+ default: 'match',
+ },
+ },
+ strict: true,
+ allowPositionals: true,
+ })
+
+ return values
+}
+
+const { filter, op } = getArgs()
+
+if (!filter) {
+ throw new Error('filter is required')
+}
+
+const fnForOp: (op: string) => BinaryOperator = (op) => {
+ switch (op) {
+ case 'match':
+ return csMatch
+ case 'eq':
+ return csEq
+ default:
+ throw new Error(`unknown op: ${op}`)
+ }
+}
+
+const csEq: BinaryOperator = (left: SQLWrapper, right: unknown): SQL => {
+ return sql`cs_unique_v1(${left}) = cs_unique_v1(${bindIfParam(right, left)})`
+}
+
+const csGt: BinaryOperator = (left: SQLWrapper, right: unknown): SQL => {
+ return sql`cs_ore_64_8_v1(${left}) > cs_ore_64_8_v1(${bindIfParam(right, left)})`
+}
+
+const csLt: BinaryOperator = (left: SQLWrapper, right: unknown): SQL => {
+ return sql`cs_ore_64_8_v1(${left}) < cs_ore_64_8_v1(${bindIfParam(right, left)})`
+}
+
+const csMatch: BinaryOperator = (left: SQLWrapper, right: unknown): SQL => {
+ return sql`cs_match_v1(${left}) @> cs_match_v1(${bindIfParam(right, left)})`
+}
+
+const filterInput = await protectClient.encrypt(filter, {
+ column: protectUsers.email_encrypted,
+ table: protectUsers,
+})
+
+if (filterInput.failure) {
+ throw new Error(`[protect]: ${filterInput.failure.message}`)
+}
+
+const filterFn = fnForOp(op)
+
+const query = db
.select({
email: users.email_encrypted,
})
.from(users)
+ .where(filterFn(users.email_encrypted, filterInput.data))
+ .orderBy(sql`cs_ore_64_8_v1(users.email_encrypted)`)
-const sqlResult = sql.toSQL()
+const sqlResult = query.toSQL()
console.log('[INFO] SQL statement:', sqlResult)
-const data = await sql.execute()
+const data = await query.execute()
const emails = await Promise.all(
data.map(
- async (row) => await protectClient.decrypt(row.email as { c: string }),
+ async (row) => await protectClient.decrypt(row.email as EncryptedData),
),
)
diff --git a/apps/hono-supabase/README.md b/apps/hono-supabase/README.md
index cad79090..b34ed0b6 100644
--- a/apps/hono-supabase/README.md
+++ b/apps/hono-supabase/README.md
@@ -14,7 +14,6 @@ This project demonstrates how to encrypt data using [@cipherstash/protect](https
- [API Endpoints](#api-endpoints)
- [GET /users](#get-users)
- [POST /users](#post-users)
-- [Explanation of the Code](#explanation-of-the-code)
- [Additional Resources](#additional-resources)
- [License](#license)
@@ -156,112 +155,6 @@ Creates a new user with an **encrypted** email field.
}
```
----
-
-## Explanation of the Code
-
-```js
-import 'dotenv/config'
-import { serve } from '@hono/node-server'
-import { createClient } from '@supabase/supabase-js'
-import { Hono } from 'hono'
-import { createRequire } from 'node:module'
-
-// We use ES6 require for @cipherstash/protect due to dynamic import limitations
-const require = createRequire(import.meta.url)
-const { protect } = require('@cipherstash/protect')
-
-// 1. Initialize the CipherStash EQL client using environment variables
-const protectClient = await protect()
-
-// 2. Initialize Supabase client
-const supabaseUrl = process.env.SUPABASE_URL
-const supabaseKey = process.env.SUPABASE_ANON_KEY
-export const supabase = createClient(supabaseUrl, supabaseKey)
-
-// 3. Create your Hono application
-const app = new Hono()
-
-// 4. GET /users
-// - Pulls records from the `users` table
-// - Decrypts the `email` field
-app.get('/users', async (c) => {
- const { data: users } = await supabase.from('users').select()
- if (users && users.length > 0) {
- const decryptedusers = await Promise.all(
- users.map(async (user) => {
- const plaintextResult = await protectClient.decrypt(user.email)
-
- if (plaintextResult.failure) {
- console.error(
- 'Failed to decrypt the email for user',
- user.id,
- plaintextResult.failure.message,
- )
-
- return user
- }
-
- const plaintext = plaintextResult.data
- return { ...user, email: plaintext }
- })
- )
- return c.json({ users: decryptedusers })
- }
- return c.json({ users: [] })
-})
-
-// 5. POST /users
-// - Encrypts the `email` field using jsprotect
-// - Inserts the encrypted data into the `users` table
-app.post('/users', async (c) => {
- const { email, name } = await c.req.json()
- if (!email || !name) {
- return c.json({ message: 'Email and name are required to create a user' }, 400)
- }
-
- // Encrypt the email
- const encryptedResult = await protectClient.encrypt(email, {
- column: 'email',
- table: 'users',
- })
-
- if (encryptedResult.failure) {
- return c.json({ message: 'Failed to encrypt the email' }, 500)
- }
-
- const encryptedEmail = encryptedResult.data
-
- // Insert the encrypted data
- const result = await supabase
- .from('users')
- .insert({ email: encryptedEmail, name, role: 'admin' })
-
- if (result.statusText === 'Created') {
- return c.json({ message: 'User created successfully' })
- }
-
- // Log and return an error message if the insertion fails
- console.error('User creation failed:', result)
- return c.json({ message: 'User creation failed. Please check the logs' }, 500)
-})
-
-// 6. Start the server on port 3000
-serve({
- fetch: app.fetch,
- port: 3000,
-})
-```
-
-**Key points to note:**
-- `@cipherstash/protect` provides two primary functions: `encrypt()` and `decrypt()`.
-- The encrypted field is stored as JSON in the format `{ c: "ciphertext" }`.
-- `@hono/node-server` is used to run the Hono application as a Node.js server.
-- `dotenv/config` automatically loads environment variables from your `.env` file.
-- We leverage Supabaseβs client (`@supabase/supabase-js`) to insert and select data.
-
----
-
## Additional Resources
- [@cipherstash/protect Documentation](https://github.com/cipherstash/protectjs)
diff --git a/apps/hono-supabase/src/index.ts b/apps/hono-supabase/src/index.ts
index e7f31db6..0ab806d4 100644
--- a/apps/hono-supabase/src/index.ts
+++ b/apps/hono-supabase/src/index.ts
@@ -2,12 +2,15 @@ import 'dotenv/config'
import { serve } from '@hono/node-server'
import { createClient } from '@supabase/supabase-js'
import { Hono } from 'hono'
-import { protect } from '@cipherstash/protect'
-// Initialize the EQL client
-// Make sure you have the following environment variables defined in your .env file:
-// CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY, CS_WORKSPACE_ID
-const protectClient = await protect()
+// Consolidated protect and it's schemas into a single file
+import { protect, csColumn, csTable } from '@cipherstash/protect'
+
+export const users = csTable('users', {
+ email: csColumn('email'),
+})
+
+export const protectClient = await protect(users)
// Create a single supabase client for interacting with the database
const supabaseUrl = process.env.SUPABASE_URL
@@ -71,8 +74,8 @@ app.post('/users', async (c) => {
// and the second argument to be an object with the table and column
// names of the table where you are storing the data.
const encryptedResult = await protectClient.encrypt(email, {
- column: 'email',
- table: 'users',
+ column: users.email,
+ table: users,
})
if (encryptedResult.failure) {
@@ -85,8 +88,6 @@ app.post('/users', async (c) => {
const encryptedEmail = encryptedResult.data
- // The encrypt function will return an object with a c key, which is the encrypted data.
- // We are logging the encrypted data to the console for demonstration purposes.
console.log(
'Encrypted email that will be stored in the database:',
encryptedEmail,
diff --git a/apps/nextjs-clerk/src/app/page.tsx b/apps/nextjs-clerk/src/app/page.tsx
index 2f1c6d99..b8b22002 100644
--- a/apps/nextjs-clerk/src/app/page.tsx
+++ b/apps/nextjs-clerk/src/app/page.tsx
@@ -5,10 +5,7 @@ import { db } from '@/core/db'
import { protectClient, getLockContext } from '@/core/protect'
import { auth, currentUser } from '@clerk/nextjs/server'
import { getCtsToken } from '@cipherstash/nextjs'
-
-export type EqlPayload = {
- c: string // Ciphertext
-}
+import type { EncryptedData } from '@cipherstash/protect'
export type EncryptedUser = {
id: number
@@ -29,7 +26,7 @@ async function getUsers(): Promise {
const promises = results.map(async (row) => {
const decryptResult = await protectClient
- .decrypt(row.email as { c: string })
+ .decrypt(row.email as EncryptedData)
.withLockContext(lockContext)
if (decryptResult.failure) {
diff --git a/apps/nextjs-clerk/src/core/protect/index.ts b/apps/nextjs-clerk/src/core/protect/index.ts
index 29871671..18a4d4e6 100644
--- a/apps/nextjs-clerk/src/core/protect/index.ts
+++ b/apps/nextjs-clerk/src/core/protect/index.ts
@@ -1,6 +1,17 @@
import 'dotenv/config'
-import { protect, LockContext, type CtsToken } from '@cipherstash/protect'
-export const protectClient = await protect()
+import {
+ protect,
+ LockContext,
+ type CtsToken,
+ csColumn,
+ csTable,
+} from '@cipherstash/protect'
+
+export const users = csTable('users', {
+ email: csColumn('email'),
+})
+
+export const protectClient = await protect(users)
export const getLockContext = (cts_token?: CtsToken) => {
if (!cts_token) {
diff --git a/apps/nextjs-clerk/src/lib/actions.ts b/apps/nextjs-clerk/src/lib/actions.ts
index 27f21321..c1d505a6 100644
--- a/apps/nextjs-clerk/src/lib/actions.ts
+++ b/apps/nextjs-clerk/src/lib/actions.ts
@@ -2,7 +2,7 @@
import { users } from '@/core/db/schema'
import { db } from '@/core/db'
-import { protectClient } from '@/core/protect'
+import { protectClient, users as protectUsers } from '@/core/protect'
import { getLockContext } from '@/core/protect'
import { getCtsToken } from '@cipherstash/nextjs'
import { revalidatePath } from 'next/cache'
@@ -32,8 +32,8 @@ export async function addUser(formData: FormData) {
const lockContext = getLockContext(ctsToken.ctsToken)
const encryptedResult = await protectClient
.encrypt(email, {
- column: users.email.name,
- table: 'users',
+ column: protectUsers.email,
+ table: protectUsers,
})
.withLockContext(lockContext)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..45f5ab1c
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,22 @@
+services:
+ postgres: &postgres
+ image: postgres:latest
+ environment:
+ PGPORT: 5432
+ POSTGRES_DB: "cipherstash"
+ POSTGRES_USER: "cipherstash"
+ PGUSER: "cipherstash"
+ POSTGRES_PASSWORD: password
+ ports:
+ - 5432:5432
+ deploy:
+ resources:
+ limits:
+ cpus: "${CPU_LIMIT:-2}"
+ memory: 2048mb
+ restart: always
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready" ]
+ interval: 1s
+ timeout: 5s
+ retries: 10
diff --git a/docs/README.md b/docs/README.md
index 4837ddb8..8ad3a190 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,11 +1,22 @@
# Protect.js Documentation
-The main documentation for Protect.js is located in the [main README](https://github.com/cipherstash/protectjs).
+The documentation for Protect.js is organized into the following sections:
-## Docs table of contents
+- [Getting started](../README.md)
-- [Configuration and production deployment](configuration.md)
-- [Bulk encryption and decryption examples and best practices](bulk-encryption-and-decryption.md)
-- [Lock contexts with Clerk and Next.js](lock-context.md)
-- [Next.js build notes](nextjs.md)
-- [SST and serverless function notes](sst.md)
\ No newline at end of file
+## Concepts
+
+- [Searchable encryption](./concepts/searchable-encryption.md)
+
+## Reference
+
+- [Configuration and production deployment](./reference/configuration.md)
+- [Bulk encryption and decryption](./reference/bulk-encryption-decryption.md)
+- [Searchable encryption with PostgreSQL](./reference/searchable-encryption-postgres.md)q
+- [Protect.js schemas](./reference/schema.md)
+
+## How-to guides
+
+- [Lock contexts with Clerk and Next.js](./how-to/lock-contexts-with-clerk.md)
+- [Next.js build notes](./how-to/nextjs-external-packages.md)
+- [SST and serverless function notes](./how-to/sst-external-packages.md)
diff --git a/docs/concepts/searchable-encryption.md b/docs/concepts/searchable-encryption.md
new file mode 100644
index 00000000..69500aee
--- /dev/null
+++ b/docs/concepts/searchable-encryption.md
@@ -0,0 +1,130 @@
+# Searchable encryption
+
+Protect.js supports searching encrypted data, which enabled trusted data access so that you can:
+
+1. Prove to your customers that you can track exactly what data is being accessed in your application.
+2. Provide evidence for compliance requirements, such as [SOC2](https://cipherstash.com/compliance/soc2) and [BDSG](https://cipherstash.com/compliance/bdsg).
+
+## What does searchable encryption even mean?
+
+The best way to describe searchable encryption is with an example.
+Let's say you have a table of users in your database, and you want to search for a user by their email address:
+
+```sql
+# SELECT * FROM users WHERE email = 'alice.johnson@example.com';
+ id | name | email
+----+----------------+----------------------------
+ 1 | Alice Johnson | alice.johnson@example.com
+```
+
+Whether you executed this query directly in the database, or through an application ORM, you'd expect the result to be the same.
+
+**But what if the email address is encrypted before it's stored in the database?**
+
+Executing the following query will return all the rows in the table with the encrypted email address:
+
+```sql
+# SELECT * FROM users;
+ id | name | email
+----+----------------+----------------------------
+ 1 | Alice Johnson | mBbKmsMMkbKBSN...
+ 2 | Jane Doe | s1THy_NfQdN892...
+ 3 | Bob Smith | 892!dercydsd0s...
+```
+
+Now, what's the issue if you execute the equality query with this data set?
+
+```sql
+# SELECT * FROM users WHERE email = 'alice.johnson@example.com';
+ id | name | email
+----+----------------+----------------------------
+```
+
+There would be no results returned, because `alice.johnson@example.com` does not equal `mBbKmsMMkbKBSN...`!
+
+## How do you search on encrypted data?
+
+There is prior art for this, and it's called [Homomorphic Encryption](https://en.wikipedia.org/wiki/Homomorphic_encryption), and is defined as:
+
+> "a form of encryption that allows computations to be performed on encrypted data without first having to decrypt it"
+
+The issue with homomorphic encryption isn't around the functionality, but rather performance in a modern application use case.
+
+CipherStash's approach to searchable encryption solves the performance problem without sacrificing security, usability, or functionality.
+
+### Using Encrypt Query Language (EQL)
+
+CipherStash uses [EQL](https://github.com/cipherstash/encrypt-query-language) to perform queries on encrypted data, and Protect.js makes it easy to use EQL with any TypeScipt application.
+
+```ts
+// 1) Encrypt the search term
+const searchTerm = 'alice.johnson@example.com'
+
+const encryptedParam = await protectClient.encrypt(searchTerm, {
+ column: protectedUsers.email, // Your Protect column definition
+ table: protectedUsers, // Reference to the table schema
+})
+
+if (encryptedParam.failure) {
+ // Handle the failure
+}
+
+// 2) Build an equality query using EQL
+const equalitySQL = `
+ SELECT email
+ FROM users
+ WHERE cs_unique_v1($1) = cs_unique_v1($2)
+`
+
+// 3) Execute the query, passing in the Postgres column name
+// and the encrypted search term as the second parameter
+// (client is an arbitrary Postgres client)
+const result = await client.query(equalitySQL, [ protectedUser.email.getName(), encryptedParam.data ])
+```
+
+Using the above approach, Protect.js is generating the EQL payloads and which means you never have to drop down to writing complex SQL queries.
+
+So does this solve the original problem of searching on encrypted data?
+
+```sql
+# SELECT * FROM users WHERE WHERE cs_unique_v1(email) = cs_unique_v1(eql_payload_created_by_protect);
+ id | name | email
+----+----------------+----------------------------
+ 1 | Alice Johnson | mBbKmsMMkbKBSN...
+```
+
+The answer is yes! And you can use Protect.js to [decrypt the results in your application](../../README.md#decrypting-data).
+
+## How fast is CipherStash's searchable encryption?
+
+Based on some [benchmarks](https://github.com/cipherstash/tfhe-ore-bench?tab=readme-ov-file#results) CipherStash's approach is ***410,000x faster*** than homomorphic encryption:
+
+| Operation | Homomorphic | CipherStash | Speedup |
+|--------------------|----------------|---------------|-------------|
+| **Encrypt** | 1.97β―ms | 48β―Β΅s | ~41Γ |
+| **a == b** | 111β―ms | 238β―ns | ~466β―000Γ |
+| **a > b** | 192β―ms | 238β―ns | ~807β―000Γ |
+| **a < b** | 190β―ms | 240β―ns | ~792β―000Γ |
+| **a >= a** | 44β―ms | 221β―ns | ~199β―000Γ |
+| **a <= a** | 44β―ms | 226β―ns | ~195β―000Γ |
+
+## How does searchable encryption help me?
+
+Every single decryption event is logged in CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms), giving you an audit trail of data access events to help you prove compliance with your data protection policies.
+
+With searchable encryption, you can:
+
+1. Prove to your customers that you can track exactly what data is being accessed in your application.
+2. Provide evidence for compliance requirements, such as [SOC2](https://cipherstash.com/compliance/soc2) and [BDSG](https://cipherstash.com/compliance/bdsg).
+
+## Bringing everything together
+
+With searchable encryption:
+
+1. Data can be encrypted, stored, and searched in your existing PostgreSQL database.
+2. Encrypted data can be searched using equality, free text search, and range queries.
+3. Data remains encrypted, and will be decrypted using the Protect.js library in your application.
+4. Queries are blazing fast, and won't slow down your application experience.
+5. Every decryption event is logged, giving you an audit trail of data access events.
+
+Read more about the [implementation details](../reference/searchable-encryption-postgres.md) to get started.
\ No newline at end of file
diff --git a/docs/lock-context.md b/docs/how-to/lock-contexts-with-clerk.md
similarity index 100%
rename from docs/lock-context.md
rename to docs/how-to/lock-contexts-with-clerk.md
diff --git a/docs/nextjs.md b/docs/how-to/nextjs-external-packages.md
similarity index 100%
rename from docs/nextjs.md
rename to docs/how-to/nextjs-external-packages.md
diff --git a/docs/sst.md b/docs/how-to/sst-external-packages.md
similarity index 100%
rename from docs/sst.md
rename to docs/how-to/sst-external-packages.md
diff --git a/docs/bulk-encryption-decryption.md b/docs/reference/bulk-encryption-decryption.md
similarity index 78%
rename from docs/bulk-encryption-decryption.md
rename to docs/reference/bulk-encryption-decryption.md
index 9d9e223f..c65aaa88 100644
--- a/docs/bulk-encryption-decryption.md
+++ b/docs/reference/bulk-encryption-decryption.md
@@ -3,12 +3,17 @@
If you have a large list of items to encrypt or decrypt, you can use the **`bulkEncrypt`** and **`bulkDecrypt`** methods to batch encryption/decryption.
`bulkEncrypt` and `bulkDecrypt` give your app significantly better throughput than the single-item [`encrypt`](../README.md#encrypting-data) / [`decrypt`](../README.md#decrypting-data) methods.
+Assuming you've [defined your schema and initialized the client](../README.md#defining-your-schema), you can use the `bulkEncrypt` and `bulkDecrypt` methods to batch encryption/decryption.
+
### Bulk encrypting data
```ts
+import { users } from './protect/schema'
+import { protectClient } from './protect'
+
const encryptedResults = await protectClient.bulkEncrypt(plaintextsToEncrypt, {
- column: 'email',
- table: 'Users',
+ column: users.email,
+ table: users,
})
if (encryptedResults.failure) {
@@ -20,8 +25,8 @@ const encryptedValues = encryptedResults.data
// or with lock context
const encryptedResults = await protectClient.bulkEncrypt(plaintextsToEncrypt, {
- column: 'email',
- table: 'Users',
+ column: users.email,
+ table: users,
}).withLockContext(lockContext)
if (encryptedResults.failure) {
@@ -52,10 +57,10 @@ const encryptedValues = encryptedResults.data
**Return value**
-- **Type**: `Promise | null, ProtectError>>`
+- **Type**: `Promise | null, ProtectError>>`
- Returns a `Result` object, where:
- **`data`** is an array of objects or `null`, where:
- - **`c`** is the ciphertext.
+ - **`encryptedData`** is the encrypted data.
- **`id`** is the same **id** you passed in, so you can correlate which ciphertext matches which original plaintext.
- **`failure`** is an object with the following properties:
- **`type`** is a string with the error type.
@@ -64,22 +69,25 @@ const encryptedValues = encryptedResults.data
#### Example usage
```ts
+import { users } from './protect/schema'
+import { protectClient } from './protect'
+
// 1) Gather your data. For example, a list of users with plaintext fields.
-const users = [
+const examples = [
{ id: '1', name: 'CJ', email: 'cj@example.com' },
{ id: '2', name: 'Alex', email: 'alex@example.com' },
]
// 2) Prepare the array for bulk encryption (only encrypting the "email" field here).
-const plaintextsToEncrypt = users.map((user) => ({
+const plaintextsToEncrypt = examples.map((user) => ({
plaintext: user.email, // The data to encrypt
id: user.id, // Keep track by user ID
}))
// 3) Call bulkEncrypt
const encryptedResults = await bulkEncrypt(plaintextsToEncrypt, {
- column: 'email',
- table: 'Users',
+ column: users.email,
+ table: users,
})
if (encryptedResults.failure) {
@@ -90,8 +98,8 @@ const encryptedValues = encryptedResults.data
// encryptedValues might look like:
// [
-// { c: 'ENCRYPTED_VALUE_1', id: '1' },
-// { c: 'ENCRYPTED_VALUE_2', id: '2' },
+// { encryptedData: { c: 'ENCRYPTED_VALUE_1', k: 'ct' }, id: '1' },
+// { encryptedData: { c: 'ENCRYPTED_VALUE_2', k: 'ct' }, id: '2' },
// ]
// 4) Reassemble data by matching IDs
@@ -100,7 +108,7 @@ if (encryptedValues) {
// Find the corresponding user
const user = users.find((u) => u.id === result.id)
if (user) {
- user.email = result.c // Store ciphertext back into the user object
+ user.email = result.encryptedData // Store the encrypted data back into the user object
}
})
}
@@ -119,9 +127,10 @@ const decryptedResults = await protectClient.bulkDecrypt(encryptedPayloads).with
**Parameters**
1. **`encryptedPayloads`**
- - **Type**: `Array<{ c: string; id: string }> | null`
+ - **Type**: `Array<{ encryptedData: EncryptedData; id: string }> | null`
- **Description**:
- An array of objects containing the **ciphertext** (`c`) and the **id**. If this array is empty or `null`, the function returns `null`.
+ An array of objects containing the **encrypted data** (`encryptedData`) and the **id**.
+ If this array is empty or `null`, the function returns `null`.
**Return value**
@@ -138,14 +147,14 @@ const decryptedResults = await protectClient.bulkDecrypt(encryptedPayloads).with
```ts
// Suppose you've retrieved an array of users where their email fields are ciphertext:
-const users = [
+const myUsers = [
{ id: '1', name: 'CJ', email: 'ENCRYPTED_VALUE_1' },
{ id: '2', name: 'Alex', email: 'ENCRYPTED_VALUE_2' },
]
// 1) Prepare the array for bulk decryption
-const encryptedPayloads = users.map((user) => ({
- c: user.email,
+const encryptedPayloads = myUsers.map((user) => ({
+ encryptedData: user.email,
id: user.id,
}))
diff --git a/docs/configration.md b/docs/reference/configuration.md
similarity index 100%
rename from docs/configration.md
rename to docs/reference/configuration.md
diff --git a/docs/reference/schema.md b/docs/reference/schema.md
new file mode 100644
index 00000000..fc718481
--- /dev/null
+++ b/docs/reference/schema.md
@@ -0,0 +1,93 @@
+# Protect.js schema
+
+Protect.js lets you define a schema in TypeScript with properties that map to your database columns, and define indexes and casting for each column which are used when searching on encrypted data.
+
+## Creating schema files
+
+You can declare your Protect.js schema directly in TypeScript either in a single `schema.ts` file, or you can split your schema into multiple files. It's up to you.
+
+Example in a single file:
+
+```
+π¦
+ β π src
+ β β π protect
+ β β β π schema.ts
+```
+
+or in multiple files:
+
+```
+π¦
+ β π src
+ β β π protect
+ β | β π schemas
+ β β β π users.ts
+ β β β π posts.ts
+```
+
+## Understanding schema files
+
+A schema represents a mapping of your database, and which columns you want to encrypt and index. Thus, it's a collection of tables and columns represented with `csTable` and `csColumn`.
+
+The below is pseudo-code for how these mappings are defined:
+
+```ts
+import { csTable, csColumn } from "@cipherstash/protect";
+
+export const tableNameInTypeScript = csTable("tableNameInDatabase", {
+ columnNameInTypeScript: csColumn("columnNameInDatabase"),
+});
+```
+
+## Defining your schema
+
+Now that you understand how your schema is defined, let's dive into how you can configure your schema.
+
+Start by importing the `csTable` and `csColumn` functions from `@cipherstash/protect` and create a new table with a column.
+
+```ts
+import { csTable, csColumn } from "@cipherstash/protect";
+
+export const protectedUsers = csTable("users", {
+ email: csColumn("email"),
+});
+```
+
+**Searchable encryption**
+
+If you are looking to enable searchable encryption in a PostgreSQL database, you must declaratively enable the indexes in your schema by chanining the index options to the column.
+
+```ts
+import { csTable, csColumn } from "@cipherstash/protect";
+
+export const protectedUsers = csTable("users", {
+ email: csColumn("email").freeTextSearch().equality().orderAndRange(),
+});
+```
+
+## Available index options
+
+The following index options are available for your schema:
+
+| **Method** | **Description** | **SQL equivalent** |
+| ----------- | --------------- | ------------------ |
+| equality | Enables a exact index for equality queries. | `WHERE email = 'example@example.com'` |
+| freeTextSearch | Enables a match index for free text queries. | `WHERE description LIKE '%example%'` |
+| orderAndRange | Enables an sorting and range queries index. | `ORDER BY price ASC` |
+
+You can chain these methods to your column to configure them in any combination.
+
+## Initializing the Protect client
+
+You will use your defined schemas to initialize the EQL client.
+Simply import your schemas and pass them to the `protect` function.
+
+```ts
+import { protect } from "@cipherstash/protect";
+import { protectedUsers } from "./schemas/users";
+
+const protectClient = await protect(protectedUsers, ...);
+```
+
+The `protect` function requires at least one `csTable` to be passed in.
\ No newline at end of file
diff --git a/docs/reference/searchable-encryption-postgres.md b/docs/reference/searchable-encryption-postgres.md
new file mode 100644
index 00000000..8a87d618
--- /dev/null
+++ b/docs/reference/searchable-encryption-postgres.md
@@ -0,0 +1,111 @@
+# Searchable encryption with Protect.js and PostgreSQL
+
+This reference guide outlines the different query patterns you can use to search encrypted data with Protect.js.
+
+You will have needed to [define your schema and initialized the protect client](../../README.md#defining-your-schema), and have [installed the EQL custom types and functions](../../README.md#searchable-encryption-in-postgresql).
+
+The below examples assume you have a schema defined:
+
+```ts
+import { csTable, csColumn } from '@cipherstash/protect'
+
+export const protectedUsers = csTable('users', {
+ email: csColumn('email').equality().freeTextSearch().orderAndRange(),
+})
+```
+
+> [!TIP]
+> To see an example using the [Drizzle ORM](https://github.com/drizzle-team/drizzle-orm) see the example [here](../../apps/drizzle/src/select.ts).
+
+## Equality query
+
+For an equality query, use the `cs_unique_v1` function.
+
+```ts
+// 1) Encrypt the search term
+const searchTerm = 'alice@example.com'
+
+const encryptedParam = await protectClient.encrypt(searchTerm, {
+ column: protectedUsers.email, // Your Protect column definition
+ table: protectedUsers, // Reference to the table schema
+})
+
+if (encryptedParam.failure) {
+ // Handle the failure
+}
+
+// 2) Build an equality query (cs_unique_v1)
+const equalitySQL = `
+ SELECT email
+ FROM users
+ WHERE cs_unique_v1($1) = cs_unique_v1($2)
+`
+
+// Use your flavor or ORM to execute the query. `client` is a PostgreSQL client.
+const result = await client.query(equalitySQL, [ protectedUser.email.getName(), encryptedParam.data ])
+```
+
+### Explanation
+
+`WHERE cs_unique_v1($1) = cs_unique_v1($2)`
+
+The first `$1` is the Postgres column name, and the second `$2` is the encrypted search term.
+
+## Free-Text search
+
+For partial matches or full-text searches, use the `cs_match_v1` function.
+
+```ts
+// Suppose you're searching for emails containing "alice"
+const searchTerm = 'alice'
+
+const encryptedParam = await protectClient.encrypt(searchTerm, {
+ column: protectedUsers.email,
+ table: protectedUsers,
+})
+
+if (encryptedParam.failure) {
+ // Handle the failure
+}
+
+// Build and execute a "match" query (cs_match_v1)
+const matchSQL = `
+ SELECT email
+ FROM users
+ WHERE cs_match_v1($1) @> cs_match_v1($2)
+`
+
+// Use your flavor or ORM to execute the query. `client` is a PostgreSQL client.
+const result = await client.query(matchSQL, [ protectedUser.email.getName(), encryptedParam.data ])
+```
+
+### Explanation
+
+`WHERE cs_match_v1($1) @> cs_match_v1($2)`
+
+The first `$1` is the Postgres column name, and the second `$2` is the encrypted search term.
+
+## Sorting data
+
+For `order by` queries, use the `cs_ore_64_8_v1` function.
+
+```ts
+// Suppose you're sorting by email address
+const orderSQL = `
+ SELECT email
+ FROM users
+ ORDER BY cs_ore_64_8_v1(email) ASC
+`
+const orderedResult = await client.query(orderSQL)
+
+// Use your flavor or ORM to execute the query. `client` is a PostgreSQL client.
+const result = await client.query(orderSQL)
+```
+
+### Explanation
+
+The `cs_ore_64_8_v1` function doesn't require any encrypted parameters, but rather the name of the Postgres column you want to sort by.
+
+## Range queries
+
+TODO: flesh this out (sorry it's not done yet)
\ No newline at end of file
diff --git a/mise.toml b/mise.toml
index 44dcb556..a9ad3052 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,3 +1,47 @@
[tools]
node = "22.11.0"
pnpm = "9.15.3"
+
+[tasks."postgres:eql:download"]
+alias = 'e'
+description = "Download latest EQL release"
+outputs = [
+ "{{config_root}}/sql/cipherstash-encrypt.sql",
+ "{{config_root}}/sql/cipherstash-encrypt-uninstall.sql",
+]
+run = """
+mkdir sql
+
+# install script
+if [ -z "$CS_EQL_PATH" ]; then
+ curl -sLo sql/cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql
+else
+ echo "Using EQL: ${CS_EQL_PATH}"
+ cp "$CS_EQL_PATH" sql/cipherstash-encrypt.sql
+fi
+
+# uninstall script
+if [ -z "$CS_EQL_UNINSTALL_PATH" ]; then
+ curl -sLo sql/cipherstash-encrypt-uninstall.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt-uninstall.sql
+else
+ echo "Using EQL: ${CS_EQL_PATH}"
+ cp "$CS_EQL_UNINSTALL_PATH" sql/cipherstash-encrypt-uninstall.sql
+fi
+"""
+
+[tasks."postgres:setup"]
+alias = 's'
+description = "Installs EQL and applies schema to database"
+run = """
+#!/bin/bash
+
+mise run postgres:eql:download
+cat sql/cipherstash-encrypt.sql | docker exec -i protectjs-postgres-1 psql postgresql://cipherstash:password@postgres:5432/cipherstash -f-
+"""
+
+[tasks."postgres:psql"]
+description = "Run psql (interactively) against the Postgres instance; assumes Postgres is already up"
+run = """
+set -eu
+docker exec -it protectjs-postgres-1 psql "postgresql://cipherstash:password@postgres:5432/cipherstash"
+"""
diff --git a/packages/protect/.npmignore b/packages/protect/.npmignore
new file mode 100644
index 00000000..3490e24d
--- /dev/null
+++ b/packages/protect/.npmignore
@@ -0,0 +1,5 @@
+.env
+.turbo
+node_modules
+cipherstash.secret.toml
+cipherstash.toml
\ No newline at end of file
diff --git a/packages/protect/__tests__/protect.test.ts b/packages/protect/__tests__/protect.test.ts
index 9128e57f..d658e231 100644
--- a/packages/protect/__tests__/protect.test.ts
+++ b/packages/protect/__tests__/protect.test.ts
@@ -1,123 +1,19 @@
import 'dotenv/config'
import { describe, expect, it } from 'vitest'
-import { LockContext, createEqlPayload, getPlaintext, protect } from '../src'
-import type { CsPlaintextV1Schema } from '../src/cs_plaintext_v1'
-
-describe('createEqlPayload', () => {
- it('should create a payload with the correct default values', () => {
- const result = createEqlPayload({
- plaintext: 'test',
- table: 'users',
- column: 'email',
- })
-
- const expectedPayload: CsPlaintextV1Schema = {
- v: 1,
- k: 'pt',
- p: 'test',
- i: {
- t: 'users',
- c: 'email',
- },
- }
-
- expect(result).toEqual(expectedPayload)
- })
-
- it('should set custom schemaVersion and queryType values when provided', () => {
- const result = createEqlPayload({
- plaintext: 'test',
- table: 'users',
- column: 'email',
- schemaVersion: 2,
- queryType: 'match',
- })
-
- const expectedPayload: CsPlaintextV1Schema = {
- v: 2,
- k: 'pt',
- p: 'test',
- i: {
- t: 'users',
- c: 'email',
- },
- q: 'match',
- }
-
- expect(result).toEqual(expectedPayload)
- })
-
- it('should set plaintext to an empty string if undefined', () => {
- const result = createEqlPayload({
- plaintext: '',
- table: 'users',
- column: 'email',
- })
-
- expect(result.p).toBe('')
- })
-})
-
-describe('getPlaintext', () => {
- it('should return plaintext if payload is valid and key is "pt"', () => {
- const payload: CsPlaintextV1Schema = {
- v: 1,
- k: 'pt',
- p: 'test',
- i: {
- t: 'users',
- c: 'email',
- },
- }
-
- const result = getPlaintext(payload)
-
- expect(result).toEqual({
- failure: false,
- plaintext: 'test',
- })
- })
-
- it('should return an error if payload is missing "p" or key is not "pt"', () => {
- const invalidPayload = {
- v: 1,
- k: 'ct',
- c: 'ciphertext',
- p: '',
- i: {
- t: 'users',
- c: 'email',
- },
- }
-
- const result = getPlaintext(
- invalidPayload as unknown as CsPlaintextV1Schema,
- )
-
- expect(result).toEqual({
- failure: true,
- error: new Error('No plaintext data found in the EQL payload'),
- })
- })
-
- it('should return an error and log if payload is invalid', () => {
- const result = getPlaintext(null as unknown as CsPlaintextV1Schema)
+import { LockContext, protect, csTable, csColumn } from '../src'
- expect(result).toEqual({
- failure: true,
- error: new Error('No plaintext data found in the EQL payload'),
- })
- })
+const users = csTable('users', {
+ email: csColumn('email').freeTextSearch().equality().orderAndRange(),
})
describe('encryption and decryption', () => {
it('should encrypt and decrypt a payload', async () => {
- const protectClient = await protect()
+ const protectClient = await protect(users)
const ciphertext = await protectClient.encrypt('plaintext', {
- column: 'column_name',
- table: 'users',
+ column: users.email,
+ table: users,
})
if (ciphertext.failure) {
@@ -132,11 +28,11 @@ describe('encryption and decryption', () => {
}, 30000)
it('should return null if plaintext is null', async () => {
- const protectClient = await protect()
+ const protectClient = await protect(users)
const ciphertext = await protectClient.encrypt(null, {
- column: 'column_name',
- table: 'users',
+ column: users.email,
+ table: users,
})
if (ciphertext.failure) {
@@ -153,7 +49,7 @@ describe('encryption and decryption', () => {
describe('bulk encryption', () => {
it('should bulk encrypt and decrypt a payload', async () => {
- const protectClient = await protect()
+ const protectClient = await protect(users)
const ciphertexts = await protectClient.bulkEncrypt(
[
{
@@ -166,8 +62,8 @@ describe('bulk encryption', () => {
},
],
{
- table: 'users',
- column: 'column_name',
+ table: users,
+ column: users.email,
},
)
@@ -175,27 +71,31 @@ describe('bulk encryption', () => {
throw new Error(`[protect]: ${ciphertexts.failure.message}`)
}
- const plaintexts = await protectClient.bulkDecrypt(ciphertexts.data)
+ const plaintextResult = await protectClient.bulkDecrypt(ciphertexts.data)
- expect(plaintexts).toEqual({
- data: [
- {
- plaintext: 'test',
- id: '1',
- },
- {
- plaintext: 'test2',
- id: '2',
- },
- ],
- })
+ if (plaintextResult.failure) {
+ throw new Error(`[protect]: ${plaintextResult.failure.message}`)
+ }
+
+ const plaintexts = plaintextResult.data
+
+ expect(plaintexts).toEqual([
+ {
+ plaintext: 'test',
+ id: '1',
+ },
+ {
+ plaintext: 'test2',
+ id: '2',
+ },
+ ])
}, 30000)
it('should return null if plaintexts is empty', async () => {
- const protectClient = await protect()
+ const protectClient = await protect(users)
const ciphertexts = await protectClient.bulkEncrypt([], {
- table: 'users',
- column: 'column_name',
+ table: users,
+ column: users.email,
})
expect(ciphertexts).toEqual({
data: null,
@@ -203,7 +103,7 @@ describe('bulk encryption', () => {
}, 30000)
it('should return null if decrypting empty ciphertexts', async () => {
- const protectClient = await protect()
+ const protectClient = await protect(users)
const ciphertexts = null
const plaintexts = await protectClient.bulkDecrypt(ciphertexts)
expect(plaintexts).toEqual({
@@ -217,43 +117,64 @@ describe('bulk encryption', () => {
// These tests pass locally, given you provide a valid JWT.
// To manually test locally, uncomment the following lines and provide a valid JWT in the userJwt variable.
// ------------------------
-// const userJwt = ''
+// const userJwt =
+// ''
// describe('encryption and decryption with lock context', () => {
// it('should encrypt and decrypt a payload with lock context', async () => {
-// const protectClient = await protect()
+// const protectClient = await protect(users)
// const lc = new LockContext()
// const lockContext = await lc.identify(userJwt)
-// const ciphertext = await protectClient
+// if (lockContext.failure) {
+// throw new Error(`[protect]: ${lockContext.failure.message}`)
+// }
+
+// const encryptResult = await protectClient
// .encrypt('plaintext', {
-// column: 'column_name',
-// table: 'users',
+// column: users.email,
+// table: users,
// })
-// .withLockContext(lockContext)
+// .withLockContext(lockContext.data)
+
+// if (encryptResult.failure) {
+// throw new Error(`[protect]: ${encryptResult.failure.message}`)
+// }
// const plaintext = await protectClient
-// .decrypt(ciphertext)
-// .withLockContext(lockContext)
+// .decrypt(encryptResult.data)
+// .withLockContext(lockContext.data)
-// expect(plaintext).toEqual('plaintext')
+// if (plaintext.failure) {
+// throw new Error(`[protect]: ${plaintext.failure.message}`)
+// }
+
+// expect(plaintext.data).toEqual('plaintext')
// }, 30000)
// it('should encrypt with context and be unable to decrypt without context', async () => {
-// const protectClient = await protect()
+// const protectClient = await protect(users)
// const lc = new LockContext()
// const lockContext = await lc.identify(userJwt)
+// if (lockContext.failure) {
+// throw new Error(`[protect]: ${lockContext.failure.message}`)
+// }
+
// const ciphertext = await protectClient
// .encrypt('plaintext', {
-// column: 'column_name',
-// table: 'users',
+// column: users.email,
+// table: users,
// })
-// .withLockContext(lockContext)
+// .withLockContext(lockContext.data)
+
+// if (ciphertext.failure) {
+// throw new Error(`[protect]: ${ciphertext.failure.message}`)
+// }
// try {
-// await protectClient.decrypt(ciphertext)
+// await protectClient.decrypt(ciphertext.data)
// } catch (error) {
// const e = error as Error
// expect(e.message.startsWith('Failed to retrieve key')).toEqual(true)
@@ -261,11 +182,15 @@ describe('bulk encryption', () => {
// }, 30000)
// it('should bulk encrypt and decrypt a payload with lock context', async () => {
-// const protectClient = await protect()
+// const protectClient = await protect(users)
// const lc = new LockContext()
// const lockContext = await lc.identify(userJwt)
+// if (lockContext.failure) {
+// throw new Error(`[protect]: ${lockContext.failure.message}`)
+// }
+
// const ciphertexts = await protectClient
// .bulkEncrypt(
// [
@@ -279,17 +204,25 @@ describe('bulk encryption', () => {
// },
// ],
// {
-// table: 'users',
-// column: 'column_name',
+// table: users,
+// column: users.email,
// },
// )
-// .withLockContext(lockContext)
+// .withLockContext(lockContext.data)
+
+// if (ciphertexts.failure) {
+// throw new Error(`[protect]: ${ciphertexts.failure.message}`)
+// }
// const plaintexts = await protectClient
-// .bulkDecrypt(ciphertexts)
-// .withLockContext(lockContext)
+// .bulkDecrypt(ciphertexts.data)
+// .withLockContext(lockContext.data)
+
+// if (plaintexts.failure) {
+// throw new Error(`[protect]: ${plaintexts.failure.message}`)
+// }
-// expect(plaintexts).toEqual([
+// expect(plaintexts.data).toEqual([
// {
// plaintext: 'test',
// id: '1',
diff --git a/packages/protect/cs_plaintext_v1.schema.json b/packages/protect/cs_plaintext_v1.schema.json
deleted file mode 100644
index 56bec1b7..00000000
--- a/packages/protect/cs_plaintext_v1.schema.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "description": "The EQL plaintext JSON payload sent by a client (such as an application) to CipherStash Proxy.",
- "type": "object",
- "properties": {
- "v": {
- "title": "Schema version",
- "type": "integer"
- },
- "k": {
- "title": "kind",
- "type": "string",
- "const": "pt"
- },
- "i": {
- "title": "ident",
- "type": "object",
- "properties": {
- "t": {
- "title": "table",
- "type": "string",
- "pattern": "^[a-zA-Z_]{1}[0-9a-zA-Z_]*$"
- },
- "c": {
- "title": "column",
- "type": "string",
- "pattern": "^[a-zA-Z_]{1}[0-9a-zA-Z_]*$"
- }
- },
- "required": ["t", "c"]
- },
- "p": {
- "title": "plaintext",
- "type": "string"
- },
- "q": {
- "title": "for query",
- "description": "Specifies that the plaintext should be encrypted for a specific query operation. If null, source encryption and encryption for all indexes will be performed.",
- "type": "string",
- "enum": ["match", "ore", "unique", "ste_vec", "ejson_path"]
- }
- },
- "required": ["v", "k", "i", "p"]
-}
diff --git a/packages/protect/eql.schema.json b/packages/protect/eql.schema.json
new file mode 100644
index 00000000..1481c16b
--- /dev/null
+++ b/packages/protect/eql.schema.json
@@ -0,0 +1,102 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "The EQL encrypted JSON payload used for storage.",
+ "type": "object",
+ "properties": {
+ "v": {
+ "title": "Schema version",
+ "type": "integer"
+ },
+ "k": {
+ "title": "kind",
+ "type": "string",
+ "enum": [
+ "ct",
+ "sv"
+ ]
+ },
+ "i": {
+ "title": "ident",
+ "type": "object",
+ "properties": {
+ "t": {
+ "title": "table",
+ "type": "string",
+ "pattern": "^[a-zA-Z_]{1}[0-9a-zA-Z_]*$"
+ },
+ "c": {
+ "title": "column",
+ "type": "string",
+ "pattern": "^[a-zA-Z_]{1}[0-9a-zA-Z_]*$"
+ }
+ },
+ "required": [
+ "t",
+ "c"
+ ]
+ }
+ },
+ "oneOf": [
+ {
+ "properties": {
+ "k": {
+ "const": "ct"
+ },
+ "c": {
+ "title": "ciphertext",
+ "type": "string"
+ },
+ "u": {
+ "title": "unique index",
+ "type": "string"
+ },
+ "o": {
+ "title": "ore index",
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string"
+ }
+ },
+ "m": {
+ "title": "match index",
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "number"
+ }
+ }
+ },
+ "required": [
+ "c"
+ ]
+ },
+ {
+ "properties": {
+ "k": {
+ "const": "sv"
+ },
+ "sv": {
+ "title": "Structured Encryption vector",
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minItems": 3,
+ "maxItems": 3
+ }
+ }
+ }
+ },
+ "required": [
+ "sv"
+ ]
+ }
+ ],
+ "required": [
+ "v",
+ "k",
+ "i"
+ ]
+}
\ No newline at end of file
diff --git a/packages/protect/generateEqlSchema.ts b/packages/protect/generateEqlSchema.ts
new file mode 100644
index 00000000..6888195a
--- /dev/null
+++ b/packages/protect/generateEqlSchema.ts
@@ -0,0 +1,28 @@
+import fs from 'node:fs/promises'
+import { execa } from 'execa'
+
+async function main() {
+ const url =
+ 'https://raw.githubusercontent.com/cipherstash/encrypt-query-language/main/sql/schemas/cs_encrypted_storage_v1.schema.json'
+
+ const response = await fetch(url)
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch schema, status = ${response.status}`)
+ }
+
+ const data = await response.json()
+
+ await fs.writeFile('./eql.schema.json', JSON.stringify(data, null, 2))
+
+ await execa('pnpm', ['run', 'eql:generate'], { stdio: 'inherit' })
+
+ console.log(
+ 'The EQL schema has been updated from the source repo and the types have been generated. See the `eql.schema.json` file for the latest schema.',
+ )
+}
+
+main().catch((err) => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/packages/protect/generateSchema.mjs b/packages/protect/generateSchema.mjs
deleted file mode 100644
index 34b635ab..00000000
--- a/packages/protect/generateSchema.mjs
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/usr/bin/env node
-
-import fs from 'node:fs/promises'
-import { execa } from 'execa'
-
-async function main() {
- const url =
- 'https://raw.githubusercontent.com/cipherstash/encrypt-query-language/main/sql/schemas/cs_plaintext_v1.schema.json'
-
- const response = await fetch(url)
- const data = await response.json()
-
- await fs.writeFile(
- './cs_plaintext_v1.schema.json',
- JSON.stringify(data, null, 2),
- )
-
- await execa('pnpm', ['run', 'generate-types'], { stdio: 'inherit' })
-
- console.log('Types generated!')
-}
-
-main().catch((err) => {
- console.error(err)
- process.exit(1)
-})
diff --git a/packages/protect/package.json b/packages/protect/package.json
index 32496a79..de29da31 100644
--- a/packages/protect/package.json
+++ b/packages/protect/package.json
@@ -1,7 +1,7 @@
{
"name": "@cipherstash/protect",
"version": "6.0.0",
- "description": "CipherStash Protech for JavaScript",
+ "description": "CipherStash Protect for JavaScript",
"keywords": [
"encrypted",
"query",
@@ -35,8 +35,8 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
- "update-schema": "node ./generateSchema.mjs",
- "generate-types": "json2ts ./cs_plaintext_v1.schema.json --output ./src/cs_plaintext_v1.ts",
+ "eql:update": "tsx ./generateEqlSchema.ts",
+ "eql:generate": "json2ts ./eql.schema.json --output ./src/eql.schema.ts",
"test": "vitest run",
"release": "tsup"
},
@@ -45,6 +45,7 @@
"execa": "^9.5.2",
"json-schema-to-typescript": "^15.0.2",
"tsup": "^8.3.0",
+ "tsx": "^4.19.3",
"vitest": "^2.1.9"
},
"peerDependencies": {
@@ -55,7 +56,8 @@
},
"dependencies": {
"@byteslice/result": "^0.2.0",
- "@cipherstash/protect-ffi": "0.11.0"
+ "@cipherstash/protect-ffi": "0.12.0",
+ "zod": "^3.24.2"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.24.0"
diff --git a/packages/protect/src/cs_plaintext_v1.ts b/packages/protect/src/cs_plaintext_v1.ts
deleted file mode 100644
index 37c80d51..00000000
--- a/packages/protect/src/cs_plaintext_v1.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/* eslint-disable */
-/**
- * This file was automatically generated by json-schema-to-typescript.
- * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
- * and run json-schema-to-typescript to regenerate this file.
- */
-
-export type SchemaVersion = number
-export type Kind = 'pt'
-export type Table = string
-export type Column = string
-export type Plaintext = string
-/**
- * Specifies that the plaintext should be encrypted for a specific query operation. If null, source encryption and encryption for all indexes will be performed.
- */
-export type ForQuery = 'match' | 'ore' | 'unique' | 'ste_vec' | 'ejson_path'
-
-/**
- * The EQL plaintext JSON payload sent by a client (such as an application) to CipherStash Proxy.
- */
-export interface CsPlaintextV1Schema {
- v: SchemaVersion
- k: Kind
- i: Ident
- p: Plaintext
- q?: ForQuery
- [k: string]: unknown
-}
-export interface Ident {
- t: Table
- c: Column
- [k: string]: unknown
-}
diff --git a/packages/protect/src/eql.schema.ts b/packages/protect/src/eql.schema.ts
new file mode 100644
index 00000000..8add2fc0
--- /dev/null
+++ b/packages/protect/src/eql.schema.ts
@@ -0,0 +1,51 @@
+/* eslint-disable */
+/**
+ * This file was automatically generated by json-schema-to-typescript.
+ * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
+ * and run json-schema-to-typescript to regenerate this file.
+ */
+
+/**
+ * The EQL encrypted JSON payload used for storage.
+ */
+export type EqlSchema = {
+ v: SchemaVersion;
+ k: Kind;
+ i: Ident;
+ [k: string]: unknown;
+} & (
+ | {
+ k?: "ct";
+ c: Ciphertext;
+ u?: UniqueIndex;
+ o?: OreIndex;
+ m?: MatchIndex;
+ [k: string]: unknown;
+ }
+ | {
+ k?: "sv";
+ sv: StructuredEncryptionVector;
+ [k: string]: unknown;
+ }
+);
+export type SchemaVersion = number;
+export type Kind = "ct" | "sv";
+export type Table = string;
+export type Column = string;
+export type Ciphertext = string;
+export type UniqueIndex = string;
+/**
+ * @minItems 1
+ */
+export type OreIndex = [string, ...string[]];
+/**
+ * @minItems 1
+ */
+export type MatchIndex = [number, ...number[]];
+export type StructuredEncryptionVector = string[][];
+
+export interface Ident {
+ t: Table;
+ c: Column;
+ [k: string]: unknown;
+}
diff --git a/packages/protect/src/eql/index.ts b/packages/protect/src/eql/index.ts
deleted file mode 100644
index 62b154d1..00000000
--- a/packages/protect/src/eql/index.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { logger } from '../../../utils/logger'
-import type {
- Column,
- CsPlaintextV1Schema,
- ForQuery,
- Plaintext,
- SchemaVersion,
- Table,
-} from '../cs_plaintext_v1'
-
-export type CreateEqlPayload = {
- plaintext: Plaintext
- table: Table
- column: Column
- schemaVersion?: SchemaVersion
- queryType?: ForQuery | null
-}
-
-export type GetPlaintextResult = {
- failure?: boolean
- error?: Error
- plaintext?: Plaintext
-}
-
-export const createEqlPayload = ({
- plaintext,
- table,
- column,
- schemaVersion = 1,
- queryType = null,
-}: CreateEqlPayload): CsPlaintextV1Schema => {
- const payload: CsPlaintextV1Schema = {
- v: schemaVersion,
- k: 'pt',
- p: plaintext ?? '',
- i: {
- t: table,
- c: column,
- },
- }
-
- if (queryType) {
- payload.q = queryType
- }
-
- logger.debug('Creating the EQL payload', payload)
- return payload
-}
-
-export const getPlaintext = (
- payload: CsPlaintextV1Schema,
-): GetPlaintextResult => {
- if (payload?.p && payload?.k === 'pt') {
- logger.debug('Returning the plaintext data from the EQL payload', payload)
- return {
- failure: false,
- plaintext: payload.p,
- }
- }
-
- logger.error('No plaintext data found in the EQL payload', payload ?? {})
- return {
- failure: true,
- error: new Error('No plaintext data found in the EQL payload'),
- }
-}
diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts
index 1a57d965..848a8185 100644
--- a/packages/protect/src/ffi/index.ts
+++ b/packages/protect/src/ffi/index.ts
@@ -10,19 +10,26 @@ import { type ProtectError, ProtectErrorTypes } from '..'
import { loadWorkSpaceId } from '../../../utils/config'
import { logger } from '../../../utils/logger'
import type { LockContext } from '../identify'
+import type { EqlSchema } from '../eql.schema'
import {
normalizeBulkDecryptPayloads,
normalizeBulkDecryptPayloadsWithLockContext,
normalizeBulkEncryptPayloads,
normalizeBulkEncryptPayloadsWithLockContext,
} from './payload-helpers'
+import {
+ type EncryptConfig,
+ encryptConfigSchema,
+ type ProtectTable,
+ type ProtectColumn,
+ type ProtectTableColumn,
+} from '../schema'
// ------------------------
// Type Definitions
// ------------------------
export type EncryptPayload = string | null
-
-export type EncryptedPayload = { c: string } | null
+export type EncryptedData = EqlSchema | null
export type BulkEncryptPayload = {
plaintext: string
@@ -31,7 +38,7 @@ export type BulkEncryptPayload = {
export type BulkEncryptedData =
| {
- c: string
+ encryptedData: EncryptedData
id: string
}[]
| null
@@ -44,8 +51,8 @@ export type BulkDecryptedData =
| null
export type EncryptOptions = {
- column: string
- table: string
+ column: ProtectColumn
+ table: ProtectTable>
}
type Client = Awaited> | undefined
@@ -62,12 +69,12 @@ const noClientError = () =>
// Encrhyption operation implementations
// ------------------------
class EncryptOperation
- implements PromiseLike>
+ implements PromiseLike>
{
private client: Client
private plaintext: EncryptPayload
- private column: string
- private table: string
+ private column: ProtectColumn
+ private table: ProtectTable
constructor(client: Client, plaintext: EncryptPayload, opts: EncryptOptions) {
this.client = client
@@ -83,13 +90,10 @@ class EncryptOperation
}
/** Implement the PromiseLike interface so `await` works. */
- public then<
- TResult1 = Result,
- TResult2 = never,
- >(
+ public then, TResult2 = never>(
onfulfilled?:
| ((
- value: Result,
+ value: Result,
) => TResult1 | PromiseLike)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
@@ -99,10 +103,10 @@ class EncryptOperation
}
/** Actual encryption logic, deferred until `then()` is called. */
- private async execute(): Promise> {
+ private async execute(): Promise> {
logger.debug('Encrypting data WITHOUT a lock context', {
- column: this.column,
- table: this.table,
+ column: this.column.getName(),
+ table: this.table.tableName,
})
return await withResult(
@@ -115,8 +119,14 @@ class EncryptOperation
return null
}
- const val = await ffiEncrypt(this.client, this.plaintext, this.column)
- return { c: val }
+ const val = await ffiEncrypt(
+ this.client,
+ this.plaintext,
+ this.column.getName(),
+ this.table.tableName,
+ )
+
+ return JSON.parse(val)
},
(error) => ({
type: ProtectErrorTypes.EncryptionError,
@@ -128,8 +138,8 @@ class EncryptOperation
public getOperation(): {
client: Client
plaintext: EncryptPayload
- column: string
- table: string
+ column: ProtectColumn
+ table: ProtectTable
} {
return {
client: this.client,
@@ -141,7 +151,7 @@ class EncryptOperation
}
class EncryptOperationWithLockContext
- implements PromiseLike>
+ implements PromiseLike>
{
private operation: EncryptOperation
private lockContext: LockContext
@@ -151,13 +161,10 @@ class EncryptOperationWithLockContext
this.lockContext = lockContext
}
- public then<
- TResult1 = Result,
- TResult2 = never,
- >(
+ public then, TResult2 = never>(
onfulfilled?:
| ((
- value: Result,
+ value: Result,
) => TResult1 | PromiseLike)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
@@ -166,7 +173,7 @@ class EncryptOperationWithLockContext
return this.execute().then(onfulfilled, onrejected)
}
- private async execute(): Promise> {
+ private async execute(): Promise> {
return await withResult(
async () => {
const { client, plaintext, column, table } =
@@ -185,7 +192,7 @@ class EncryptOperationWithLockContext
return null
}
- const context = await this.lockContext?.getLockContext()
+ const context = await this.lockContext.getLockContext()
if (context.failure) {
throw new Error(`[protect]: ${context.failure.message}`)
@@ -194,11 +201,13 @@ class EncryptOperationWithLockContext
const val = await ffiEncrypt(
client,
plaintext,
- column,
+ column.getName(),
+ table.tableName,
context.data.context,
context.data.ctsToken,
)
- return { c: val }
+
+ return JSON.parse(val)
},
(error) => ({
type: ProtectErrorTypes.EncryptionError,
@@ -215,11 +224,11 @@ class DecryptOperation
implements PromiseLike>
{
private client: Client
- private encryptedPayload: EncryptedPayload
+ private encryptedData: EncryptedData
- constructor(client: Client, encryptedPayload: EncryptedPayload) {
+ constructor(client: Client, encryptedData: EncryptedData) {
this.client = client
- this.encryptedPayload = encryptedPayload
+ this.encryptedData = encryptedData
}
public withLockContext(
@@ -247,12 +256,18 @@ class DecryptOperation
throw noClientError()
}
- if (this.encryptedPayload === null) {
+ if (this.encryptedData === null) {
return null
}
+ if (this.encryptedData.k !== 'ct') {
+ throw new Error(
+ 'The encrypted data is not compliant with the EQL schema',
+ )
+ }
+
logger.debug('Decrypting data WITHOUT a lock context')
- return await ffiDecrypt(this.client, this.encryptedPayload.c)
+ return await ffiDecrypt(this.client, this.encryptedData.c)
},
(error) => ({
type: ProtectErrorTypes.DecryptionError,
@@ -263,11 +278,11 @@ class DecryptOperation
public getOperation(): {
client: Client
- encryptedPayload: EncryptedPayload
+ encryptedData: EncryptedData
} {
return {
client: this.client,
- encryptedPayload: this.encryptedPayload,
+ encryptedData: this.encryptedData,
}
}
}
@@ -298,27 +313,33 @@ class DecryptOperationWithLockContext
private async execute(): Promise> {
return await withResult(
async () => {
- const { client, encryptedPayload } = this.operation.getOperation()
+ const { client, encryptedData } = this.operation.getOperation()
if (!client) {
throw noClientError()
}
- if (encryptedPayload === null) {
+ if (encryptedData === null) {
return null
}
logger.debug('Decrypting data WITH a lock context')
- const context = await this.lockContext?.getLockContext()
+ const context = await this.lockContext.getLockContext()
if (context.failure) {
throw new Error(`[protect]: ${context.failure.message}`)
}
+ if (encryptedData.k !== 'ct') {
+ throw new Error(
+ 'The encrypted data is not compliant with the EQL schema',
+ )
+ }
+
return await ffiDecrypt(
client,
- encryptedPayload.c,
+ encryptedData.c,
context.data.context,
context.data.ctsToken,
)
@@ -339,8 +360,8 @@ class BulkEncryptOperation
{
private client: Client
private plaintexts: BulkEncryptPayload
- private column: string
- private table: string
+ private column: ProtectColumn
+ private table: ProtectTable
constructor(
client: Client,
@@ -387,17 +408,18 @@ class BulkEncryptOperation
const encryptPayloads = normalizeBulkEncryptPayloads(
this.plaintexts,
- this.column,
+ this.column.getName(),
+ this.table.tableName,
)
logger.debug('Bulk encrypting data WITHOUT a lock context', {
- column: this.column,
- table: this.table,
+ column: this.column.getName(),
+ table: this.table.tableName,
})
const encryptedData = await ffiEncryptBulk(this.client, encryptPayloads)
return encryptedData.map((enc, index) => ({
- c: enc,
+ encryptedData: JSON.parse(enc),
id: this.plaintexts[index].id,
}))
},
@@ -411,8 +433,8 @@ class BulkEncryptOperation
public getOperation(): {
client: Client
plaintexts: BulkEncryptPayload
- column: string
- table: string
+ column: ProtectColumn
+ table: ProtectTable
} {
return {
client: this.client,
@@ -466,7 +488,8 @@ class BulkEncryptOperationWithLockContext
const encryptPayloads =
await normalizeBulkEncryptPayloadsWithLockContext(
plaintexts,
- column,
+ column.getName(),
+ table.tableName,
this.lockContext,
)
@@ -492,7 +515,7 @@ class BulkEncryptOperationWithLockContext
)
return encryptedData.map((enc, index) => ({
- c: enc,
+ encryptedData: JSON.parse(enc),
id: plaintexts[index].id,
}))
},
@@ -511,11 +534,11 @@ class BulkDecryptOperation
implements PromiseLike>
{
private client: Client
- private encryptedPayloads: BulkEncryptedData
+ private encryptedDatas: BulkEncryptedData
- constructor(client: Client, encryptedPayloads: BulkEncryptedData) {
+ constructor(client: Client, encryptedDatas: BulkEncryptedData) {
this.client = client
- this.encryptedPayloads = encryptedPayloads
+ this.encryptedDatas = encryptedDatas
}
public withLockContext(
@@ -546,12 +569,12 @@ class BulkDecryptOperation
throw noClientError()
}
- if (!this.encryptedPayloads) {
+ if (!this.encryptedDatas) {
return null
}
const decryptPayloads = normalizeBulkDecryptPayloads(
- this.encryptedPayloads,
+ this.encryptedDatas,
)
if (!decryptPayloads) {
@@ -562,10 +585,10 @@ class BulkDecryptOperation
const decryptedData = await ffiDecryptBulk(this.client, decryptPayloads)
return decryptedData.map((dec, index) => {
- if (!this.encryptedPayloads) return null
+ if (!this.encryptedDatas) return null
return {
plaintext: dec,
- id: this.encryptedPayloads[index].id,
+ id: this.encryptedDatas[index].id,
}
})
},
@@ -578,11 +601,11 @@ class BulkDecryptOperation
public getOperation(): {
client: Client
- encryptedPayloads: BulkEncryptedData
+ encryptedDatas: BulkEncryptedData
} {
return {
client: this.client,
- encryptedPayloads: this.encryptedPayloads,
+ encryptedDatas: this.encryptedDatas,
}
}
}
@@ -616,19 +639,19 @@ class BulkDecryptOperationWithLockContext
private async execute(): Promise> {
return await withResult(
async () => {
- const { client, encryptedPayloads } = this.operation.getOperation()
+ const { client, encryptedDatas } = this.operation.getOperation()
if (!client) {
throw noClientError()
}
- if (!encryptedPayloads) {
+ if (!encryptedDatas) {
return null
}
const decryptPayloads =
await normalizeBulkDecryptPayloadsWithLockContext(
- encryptedPayloads,
+ encryptedDatas,
this.lockContext,
)
@@ -655,10 +678,10 @@ class BulkDecryptOperationWithLockContext
)
return decryptedData.map((dec, index) => {
- if (!encryptedPayloads) return null
+ if (!encryptedDatas) return null
return {
plaintext: dec,
- id: encryptedPayloads[index].id,
+ id: encryptedDatas[index].id,
}
})
},
@@ -675,22 +698,43 @@ class BulkDecryptOperationWithLockContext
// ------------------------
export class ProtectClient {
private client: Client
+ private encryptConfig: EncryptConfig | undefined
private workspaceId: string | undefined
constructor() {
const workspaceId = loadWorkSpaceId()
-
- logger.info(
- 'Successfully initialized the EQL client with your defined environment variables.',
- )
-
- this.workspaceId = process.env.CS_WORKSPACE_ID
+ this.workspaceId = workspaceId
}
- async init(): Promise> {
+ async init(
+ encryptConifg?: EncryptConfig,
+ ): Promise> {
return await withResult(
async () => {
- const c = await newClient()
+ let c: Client
+
+ if (encryptConifg) {
+ const validated: EncryptConfig =
+ encryptConfigSchema.parse(encryptConifg)
+
+ logger.debug(
+ 'Initializing the Protect.js client with the following encrypt config:',
+ {
+ encryptConfig: validated,
+ },
+ )
+
+ c = await newClient(JSON.stringify(validated))
+ this.encryptConfig = validated
+ } else {
+ logger.debug(
+ 'Initializing the Protect.js client with default encrypt config.',
+ )
+
+ c = await newClient()
+ }
+
+ logger.info('Successfully initialized the Protect.js client.')
this.client = c
return this
},
@@ -714,11 +758,11 @@ export class ProtectClient {
/**
* Decryption - returns a thenable object.
* Usage:
- * await eqlClient.decrypt(encryptedPayload)
- * await eqlClient.decrypt(encryptedPayload).withLockContext(lockContext)
+ * await eqlClient.decrypt(encryptedData)
+ * await eqlClient.decrypt(encryptedData).withLockContext(lockContext)
*/
- decrypt(encryptedPayload: EncryptedPayload): DecryptOperation {
- return new DecryptOperation(this.client, encryptedPayload)
+ decrypt(encryptedData: EncryptedData): DecryptOperation {
+ return new DecryptOperation(this.client, encryptedData)
}
/**
@@ -739,11 +783,11 @@ export class ProtectClient {
/**
* Bulk Decrypt - returns a thenable object.
* Usage:
- * await eqlClient.bulkDecrypt(encryptedPayloads)
- * await eqlClient.bulkDecrypt(encryptedPayloads).withLockContext(lockContext)
+ * await eqlClient.bulkDecrypt(encryptedDatas)
+ * await eqlClient.bulkDecrypt(encryptedDatas).withLockContext(lockContext)
*/
- bulkDecrypt(encryptedPayloads: BulkEncryptedData): BulkDecryptOperation {
- return new BulkDecryptOperation(this.client, encryptedPayloads)
+ bulkDecrypt(encryptedDatas: BulkEncryptedData): BulkDecryptOperation {
+ return new BulkDecryptOperation(this.client, encryptedDatas)
}
/** e.g., debugging or environment info */
diff --git a/packages/protect/src/ffi/payload-helpers.ts b/packages/protect/src/ffi/payload-helpers.ts
index 5dafecea..67f6bf46 100644
--- a/packages/protect/src/ffi/payload-helpers.ts
+++ b/packages/protect/src/ffi/payload-helpers.ts
@@ -11,12 +11,18 @@ import type { BulkEncryptPayload, BulkEncryptedData } from './index'
const getLockContextPayload = async (lockContext: LockContext) =>
await lockContext.getLockContext()
-export const normalizeBulkDecryptPayloads = (
- encryptedPayloads: BulkEncryptedData,
-) =>
- encryptedPayloads?.reduce((acc, encryptedPayload) => {
+export const normalizeBulkDecryptPayloads = (payload: BulkEncryptedData) =>
+ payload?.reduce((acc, data) => {
+ if (!data.encryptedData) {
+ return acc
+ }
+
+ if (data.encryptedData.k !== 'ct') {
+ throw new Error('The encrypted data is not compliant with the EQL schema')
+ }
+
const payload = {
- ciphertext: encryptedPayload.c,
+ ciphertext: data.encryptedData.c,
}
acc.push(payload)
@@ -26,11 +32,13 @@ export const normalizeBulkDecryptPayloads = (
export const normalizeBulkEncryptPayloads = (
plaintexts: BulkEncryptPayload,
column: string,
+ table: string,
) =>
plaintexts.reduce((acc, plaintext) => {
const payload = {
plaintext: plaintext.plaintext,
column,
+ table,
}
acc.push(payload)
@@ -38,18 +46,28 @@ export const normalizeBulkEncryptPayloads = (
}, [] as InternalBulkEncryptPayload[])
export async function normalizeBulkDecryptPayloadsWithLockContext(
- encryptedPayloads: BulkEncryptedData,
+ payloads: BulkEncryptedData,
lockContext: LockContext,
): Promise> {
const lockContextPayload = await getLockContextPayload(lockContext)
if (lockContextPayload.failure) return lockContextPayload
- if (!encryptedPayloads) return { data: [] }
+ if (!payloads) return { data: [] }
return {
- data: encryptedPayloads?.reduce((acc, encryptedPayload) => {
+ data: payloads.reduce((acc, data) => {
+ if (!data.encryptedData) {
+ return acc
+ }
+
+ if (data.encryptedData.k !== 'ct') {
+ throw new Error(
+ 'The encrypted data is not compliant with the EQL schema',
+ )
+ }
+
const payload = {
- ciphertext: encryptedPayload.c,
+ ciphertext: data.encryptedData.c,
...lockContextPayload,
}
@@ -62,6 +80,7 @@ export async function normalizeBulkDecryptPayloadsWithLockContext(
export async function normalizeBulkEncryptPayloadsWithLockContext(
plaintexts: BulkEncryptPayload,
column: string,
+ table: string,
lockContext: LockContext,
): Promise> {
const lockContextPayload = await getLockContextPayload(lockContext)
@@ -74,6 +93,7 @@ export async function normalizeBulkEncryptPayloadsWithLockContext(
const payload = {
plaintext: plaintext.plaintext,
column,
+ table,
...lockContextPayload,
}
diff --git a/packages/protect/src/index.ts b/packages/protect/src/index.ts
index ae8eb604..bb78eb43 100644
--- a/packages/protect/src/index.ts
+++ b/packages/protect/src/index.ts
@@ -1,4 +1,9 @@
import { ProtectClient } from './ffi'
+import {
+ type ProtectTable,
+ type ProtectTableColumn,
+ buildEncryptConfig,
+} from './schema'
export const ProtectErrorTypes = {
ClientInitError: 'ClientInitError',
@@ -13,9 +18,20 @@ export interface ProtectError {
message: string
}
-export const protect = async (): Promise => {
+type AtLeastOneCsTable = [T, ...T[]]
+export const protect = async (
+ ...tables: AtLeastOneCsTable>
+): Promise => {
+ if (!tables.length) {
+ throw new Error(
+ '[protect]: At least one csTable must be provided to initialize the protect client',
+ )
+ }
+
const client = new ProtectClient()
- const result = await client.init()
+ const encryptConfig = buildEncryptConfig(...tables)
+
+ const result = await client.init(encryptConfig)
if (result.failure) {
throw new Error(`[protect]: ${result.failure.message}`)
@@ -25,7 +41,6 @@ export const protect = async (): Promise => {
}
export type { Result } from '@byteslice/result'
-export type { ProtectClient } from './ffi'
-export * from './cs_plaintext_v1'
+export type { ProtectClient, EncryptedData } from './ffi'
+export { csTable, csColumn } from './schema'
export * from './identify'
-export * from './eql'
diff --git a/packages/protect/src/schema/index.ts b/packages/protect/src/schema/index.ts
new file mode 100644
index 00000000..e71a7102
--- /dev/null
+++ b/packages/protect/src/schema/index.ts
@@ -0,0 +1,242 @@
+import { z } from 'zod'
+
+// ------------------------
+// Zod schemas
+// ------------------------
+const castAsEnum = z
+ .enum([
+ 'big_int',
+ 'boolean',
+ 'date',
+ 'real',
+ 'double',
+ 'int',
+ 'small_int',
+ 'text',
+ 'jsonb',
+ ])
+ .default('text')
+
+const tokenFilterSchema = z.object({
+ kind: z.literal('downcase'),
+})
+
+const tokenizerSchema = z
+ .union([
+ z.object({
+ kind: z.literal('standard'),
+ }),
+ z.object({
+ kind: z.literal('ngram'),
+ token_length: z.number(),
+ }),
+ ])
+ .default({ kind: 'ngram', token_length: 3 })
+ .optional()
+
+const oreIndexOptsSchema = z.object({})
+
+const uniqueIndexOptsSchema = z.object({
+ token_filters: z.array(tokenFilterSchema).default([]).optional(),
+})
+
+const matchIndexOptsSchema = z.object({
+ tokenizer: tokenizerSchema,
+ token_filters: z.array(tokenFilterSchema).default([]).optional(),
+ k: z.number().default(6).optional(),
+ m: z.number().default(2048).optional(),
+ include_original: z.boolean().default(false).optional(),
+})
+
+const steVecIndexOptsSchema = z.object({
+ prefix: z.string(),
+})
+
+const indexesSchema = z
+ .object({
+ ore: oreIndexOptsSchema.optional(),
+ unique: uniqueIndexOptsSchema.optional(),
+ match: matchIndexOptsSchema.optional(),
+ ste_vec: steVecIndexOptsSchema.optional(),
+ })
+ .default({})
+
+const columnSchema = z
+ .object({
+ cast_as: castAsEnum,
+ indexes: indexesSchema,
+ })
+ .default({})
+
+const tableSchema = z.record(columnSchema).default({})
+
+const tablesSchema = z.record(tableSchema).default({})
+
+export const encryptConfigSchema = z.object({
+ v: z.number(),
+ tables: tablesSchema,
+})
+
+// ------------------------
+// Type definitions
+// ------------------------
+type CastAs = z.infer
+type TokenFilter = z.infer
+type MatchIndexOpts = z.infer
+type SteVecIndexOpts = z.infer
+type UniqueIndexOpts = z.infer
+type OreIndexOpts = z.infer
+type ColumnSchema = z.infer
+
+export type ProtectTableColumn = Record
+export type EncryptConfig = z.infer
+
+// ------------------------
+// Interface definitions
+// ------------------------
+export class ProtectColumn {
+ private columnName: string
+ private castAsValue: CastAs
+ private indexesValue: {
+ ore?: OreIndexOpts
+ unique?: UniqueIndexOpts
+ match?: Required
+ ste_vec?: SteVecIndexOpts
+ } = {}
+
+ constructor(columnName: string) {
+ this.columnName = columnName
+ this.castAsValue = 'text'
+ }
+
+ /**
+ * Set or override the cast_as value.
+ */
+ dataType(castAs: CastAs) {
+ this.castAsValue = castAs
+ return this
+ }
+
+ /**
+ * Enable ORE indexing (Order-Revealing Encryption).
+ */
+ orderAndRange() {
+ this.indexesValue.ore = {}
+ return this
+ }
+
+ /**
+ * Enable an Exact index. Optionally pass tokenFilters.
+ */
+ equality(tokenFilters?: TokenFilter[]) {
+ this.indexesValue.unique = {
+ token_filters: tokenFilters ?? [],
+ }
+ return this
+ }
+
+ /**
+ * Enable a Match index. Allows passing of custom match options.
+ */
+ freeTextSearch(opts?: MatchIndexOpts) {
+ // Provide defaults
+ this.indexesValue.match = {
+ tokenizer: opts?.tokenizer ?? { kind: 'ngram', token_length: 3 },
+ token_filters: opts?.token_filters ?? [
+ {
+ kind: 'downcase',
+ },
+ ],
+ k: opts?.k ?? 6,
+ m: opts?.m ?? 2048,
+ include_original: opts?.include_original ?? true,
+ }
+ return this
+ }
+
+ /**
+ * Enable a STE Vec index, requires a prefix.
+ */
+ josn(prefix: string) {
+ this.indexesValue.ste_vec = { prefix }
+ return this
+ }
+
+ build() {
+ return {
+ cast_as: this.castAsValue,
+ indexes: this.indexesValue,
+ }
+ }
+
+ getName() {
+ return this.columnName
+ }
+}
+
+interface TableDefinition {
+ tableName: string
+ columns: Record
+}
+
+export class ProtectTable {
+ constructor(
+ public readonly tableName: string,
+ private readonly columnBuilders: T,
+ ) {}
+
+ /**
+ * Build a TableDefinition object: tableName + built column configs.
+ */
+ build(): TableDefinition {
+ const builtColumns: Record = {}
+ for (const [colName, builder] of Object.entries(this.columnBuilders)) {
+ builtColumns[colName] = builder.build()
+ }
+
+ return {
+ tableName: this.tableName,
+ columns: builtColumns,
+ }
+ }
+}
+
+// ------------------------
+// User facing functions
+// ------------------------
+export function csTable(
+ tableName: string,
+ columns: T,
+): ProtectTable & T {
+ const tableBuilder = new ProtectTable(tableName, columns) as ProtectTable &
+ T
+
+ for (const [colName, colBuilder] of Object.entries(columns)) {
+ ;(tableBuilder as ProtectTableColumn)[colName] = colBuilder
+ }
+
+ return tableBuilder
+}
+
+export function csColumn(columnName: string) {
+ return new ProtectColumn(columnName)
+}
+
+// ------------------------
+// Internal functions
+// ------------------------
+export function buildEncryptConfig(
+ ...protectTables: Array>
+): EncryptConfig {
+ const config: EncryptConfig = {
+ v: 1,
+ tables: {},
+ }
+
+ for (const tb of protectTables) {
+ const tableDef = tb.build()
+ config.tables[tableDef.tableName] = tableDef.columns
+ }
+
+ return config
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f83f730b..c0f5a858 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,12 @@ importers:
dotenv:
specifier: ^16.4.7
version: 16.4.7
+ tsx:
+ specifier: ^4.19.2
+ version: 4.19.3
+ typescript:
+ specifier: ^5.0.0
+ version: 5.7.2
apps/drizzle:
dependencies:
@@ -47,9 +53,6 @@ importers:
postgres:
specifier: ^3.4.4
version: 3.4.5
- typescript:
- specifier: ^5.0.0
- version: 5.7.2
devDependencies:
'@types/node':
specifier: ^22.10.2
@@ -66,6 +69,9 @@ importers:
tsx:
specifier: ^4.19.2
version: 4.19.2
+ typescript:
+ specifier: ^5.0.0
+ version: 5.7.2
apps/hono-supabase:
dependencies:
@@ -204,7 +210,7 @@ importers:
version: 16.4.7
tsup:
specifier: ^8.3.0
- version: 8.3.5(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.7.0)
+ version: 8.3.5(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.3)(typescript@5.7.2)(yaml@2.7.0)
vitest:
specifier: ^2.1.9
version: 2.1.9(@types/node@22.10.5)
@@ -215,11 +221,14 @@ importers:
specifier: ^0.2.0
version: 0.2.0
'@cipherstash/protect-ffi':
- specifier: 0.11.0
- version: 0.11.0
+ specifier: 0.12.0
+ version: 0.12.0
typescript:
specifier: ^5.0.0
version: 5.7.2
+ zod:
+ specifier: ^3.24.2
+ version: 3.24.2
optionalDependencies:
'@rollup/rollup-linux-x64-gnu':
specifier: 4.24.0
@@ -236,7 +245,10 @@ importers:
version: 15.0.3
tsup:
specifier: ^8.3.0
- version: 8.3.5(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.7.0)
+ version: 8.3.5(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.3)(typescript@5.7.2)(yaml@2.7.0)
+ tsx:
+ specifier: ^4.19.3
+ version: 4.19.3
vitest:
specifier: ^2.1.9
version: 2.1.9(@types/node@22.10.5)
@@ -366,33 +378,33 @@ packages:
'@changesets/write@0.3.2':
resolution: {integrity: sha512-kDxDrPNpUgsjDbWBvUo27PzKX4gqeKOlhibaOXDJA6kuBisGqNHv/HwGJrAu8U/dSf8ZEFIeHIPtvSlZI1kULw==}
- '@cipherstash/protect-ffi-darwin-arm64@0.11.0':
- resolution: {integrity: sha512-dOmknDJxbH6RdmwSleNBgnHvM0nrOG/aAKs4pFpIBRSdRV0oFQDynaIAFa/hAcBtE37RRvZCOX6/zF1FQioDlg==}
+ '@cipherstash/protect-ffi-darwin-arm64@0.12.0':
+ resolution: {integrity: sha512-Oh7A9Mbn17QDHLLbugbT5bo/hZRrtgzcJZBTvXNTQpW9FtNmscCz1Wn79CJX+IhpdVrVxIQk7GOnG7JhTxJ/JA==}
cpu: [arm64]
os: [darwin]
- '@cipherstash/protect-ffi-darwin-x64@0.11.0':
- resolution: {integrity: sha512-1eaXoY/RT3dhByZyErYocH7jGjxkXju9KsJ7Wx4sniwk5v+6bmmDrkhduORY7AdrQglNhLTKnUf48NIU+zkuiQ==}
+ '@cipherstash/protect-ffi-darwin-x64@0.12.0':
+ resolution: {integrity: sha512-1UkwOm5lUukIFPXYaI1KvlBy+WafMGX0qw5lYBq1ybFMOhQgAdCloRxON60yM1aEM6MCWgqw6J6eQbisLms/Ow==}
cpu: [x64]
os: [darwin]
- '@cipherstash/protect-ffi-linux-arm64-gnu@0.11.0':
- resolution: {integrity: sha512-O2o2gZpIzp0ADLGcswl2jk2G18MgevTB0zv4ij1b3Ut+c+8h7QMWjtzZaJpG6+2PZK91OAgb3GBdSgIv5e2jFQ==}
+ '@cipherstash/protect-ffi-linux-arm64-gnu@0.12.0':
+ resolution: {integrity: sha512-zIOQoAgWXqPXE+wXQwiTu0+GNkLvXhN9US49nKezL2P+X97PuzZd0XaL+e5AXuDdrt5wC1WjUDvKvP1cQpNYPg==}
cpu: [arm64]
os: [linux]
- '@cipherstash/protect-ffi-linux-x64-gnu@0.11.0':
- resolution: {integrity: sha512-tp8hHR9fxTrcbv2E+MNN/e8uwJS4ncBorAlxksosnkYnz28FBmnagz0kQiltOkCieMEZv9c1EqaGjTt1s2tr0g==}
+ '@cipherstash/protect-ffi-linux-x64-gnu@0.12.0':
+ resolution: {integrity: sha512-wSJPi+pNR9+7DgcLZz/6V1VZAyejeEByIh+URfuRDaqbv8oxhh957WPfZTrWZXP5paIhXEdzkH62g6rnAxKaiQ==}
cpu: [x64]
os: [linux]
- '@cipherstash/protect-ffi-win32-x64-msvc@0.11.0':
- resolution: {integrity: sha512-DqUpDhsLSdO9JaDP7Jxfn5T++k4lM0fT+GoFQK5ITL+FBETN4eearV7BlPWNbH7iucAd624P88LKt98vg2orOg==}
+ '@cipherstash/protect-ffi-win32-x64-msvc@0.12.0':
+ resolution: {integrity: sha512-45icvAH+3QUaZVzCkpz4Xb8+EZ4jcQs0pd9Cvk6CMX4srkzdzGcN61NaEJmcWmYaq4XQqGSHYQi9afznpfvJow==}
cpu: [x64]
os: [win32]
- '@cipherstash/protect-ffi@0.11.0':
- resolution: {integrity: sha512-wyk9gEqQy1zFXUXU6Oaygcgy3NXx4M26sBN3HoRSmIVGU+C2agWDUqBqoGOdz2Q8v/YYApu8DnmVHDBaJeevgg==}
+ '@cipherstash/protect-ffi@0.12.0':
+ resolution: {integrity: sha512-h1EdM+erAWBCy37jq4XExTQY4Pheg+wqLRyBGDRkS9vgxOQTt2g4TquqYVaS+aFZsYE1pMw76aSJNdca9sP2jg==}
'@clerk/backend@1.21.5':
resolution: {integrity: sha512-fZ+HuHQkPngYp9vAEqsJ1XJkhWjSWTin90UixeV3jNrbuPY2jd70LKYTAIEvggnnG42pYxdVupaQDUfqj0dy4Q==}
@@ -506,6 +518,12 @@ packages:
cpu: [ppc64]
os: [aix]
+ '@esbuild/aix-ppc64@0.25.0':
+ resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
'@esbuild/android-arm64@0.18.20':
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
engines: {node: '>=12'}
@@ -536,6 +554,12 @@ packages:
cpu: [arm64]
os: [android]
+ '@esbuild/android-arm64@0.25.0':
+ resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
'@esbuild/android-arm@0.18.20':
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
engines: {node: '>=12'}
@@ -566,6 +590,12 @@ packages:
cpu: [arm]
os: [android]
+ '@esbuild/android-arm@0.25.0':
+ resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
'@esbuild/android-x64@0.18.20':
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
engines: {node: '>=12'}
@@ -596,6 +626,12 @@ packages:
cpu: [x64]
os: [android]
+ '@esbuild/android-x64@0.25.0':
+ resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
'@esbuild/darwin-arm64@0.18.20':
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
engines: {node: '>=12'}
@@ -626,6 +662,12 @@ packages:
cpu: [arm64]
os: [darwin]
+ '@esbuild/darwin-arm64@0.25.0':
+ resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
'@esbuild/darwin-x64@0.18.20':
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
engines: {node: '>=12'}
@@ -656,6 +698,12 @@ packages:
cpu: [x64]
os: [darwin]
+ '@esbuild/darwin-x64@0.25.0':
+ resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
'@esbuild/freebsd-arm64@0.18.20':
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
engines: {node: '>=12'}
@@ -686,6 +734,12 @@ packages:
cpu: [arm64]
os: [freebsd]
+ '@esbuild/freebsd-arm64@0.25.0':
+ resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
'@esbuild/freebsd-x64@0.18.20':
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
engines: {node: '>=12'}
@@ -716,6 +770,12 @@ packages:
cpu: [x64]
os: [freebsd]
+ '@esbuild/freebsd-x64@0.25.0':
+ resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
'@esbuild/linux-arm64@0.18.20':
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
engines: {node: '>=12'}
@@ -746,6 +806,12 @@ packages:
cpu: [arm64]
os: [linux]
+ '@esbuild/linux-arm64@0.25.0':
+ resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
'@esbuild/linux-arm@0.18.20':
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
engines: {node: '>=12'}
@@ -776,6 +842,12 @@ packages:
cpu: [arm]
os: [linux]
+ '@esbuild/linux-arm@0.25.0':
+ resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
'@esbuild/linux-ia32@0.18.20':
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
engines: {node: '>=12'}
@@ -806,6 +878,12 @@ packages:
cpu: [ia32]
os: [linux]
+ '@esbuild/linux-ia32@0.25.0':
+ resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
'@esbuild/linux-loong64@0.18.20':
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
engines: {node: '>=12'}
@@ -836,6 +914,12 @@ packages:
cpu: [loong64]
os: [linux]
+ '@esbuild/linux-loong64@0.25.0':
+ resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/linux-mips64el@0.18.20':
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
engines: {node: '>=12'}
@@ -866,6 +950,12 @@ packages:
cpu: [mips64el]
os: [linux]
+ '@esbuild/linux-mips64el@0.25.0':
+ resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
'@esbuild/linux-ppc64@0.18.20':
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
engines: {node: '>=12'}
@@ -896,6 +986,12 @@ packages:
cpu: [ppc64]
os: [linux]
+ '@esbuild/linux-ppc64@0.25.0':
+ resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
'@esbuild/linux-riscv64@0.18.20':
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
engines: {node: '>=12'}
@@ -926,6 +1022,12 @@ packages:
cpu: [riscv64]
os: [linux]
+ '@esbuild/linux-riscv64@0.25.0':
+ resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
'@esbuild/linux-s390x@0.18.20':
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
engines: {node: '>=12'}
@@ -956,6 +1058,12 @@ packages:
cpu: [s390x]
os: [linux]
+ '@esbuild/linux-s390x@0.25.0':
+ resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
'@esbuild/linux-x64@0.18.20':
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
engines: {node: '>=12'}
@@ -986,12 +1094,24 @@ packages:
cpu: [x64]
os: [linux]
+ '@esbuild/linux-x64@0.25.0':
+ resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
'@esbuild/netbsd-arm64@0.24.2':
resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
+ '@esbuild/netbsd-arm64@0.25.0':
+ resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
'@esbuild/netbsd-x64@0.18.20':
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
engines: {node: '>=12'}
@@ -1022,6 +1142,12 @@ packages:
cpu: [x64]
os: [netbsd]
+ '@esbuild/netbsd-x64@0.25.0':
+ resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
'@esbuild/openbsd-arm64@0.23.1':
resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==}
engines: {node: '>=18'}
@@ -1034,6 +1160,12 @@ packages:
cpu: [arm64]
os: [openbsd]
+ '@esbuild/openbsd-arm64@0.25.0':
+ resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
'@esbuild/openbsd-x64@0.18.20':
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
engines: {node: '>=12'}
@@ -1064,6 +1196,12 @@ packages:
cpu: [x64]
os: [openbsd]
+ '@esbuild/openbsd-x64@0.25.0':
+ resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
'@esbuild/sunos-x64@0.18.20':
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
engines: {node: '>=12'}
@@ -1094,6 +1232,12 @@ packages:
cpu: [x64]
os: [sunos]
+ '@esbuild/sunos-x64@0.25.0':
+ resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
'@esbuild/win32-arm64@0.18.20':
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
engines: {node: '>=12'}
@@ -1124,6 +1268,12 @@ packages:
cpu: [arm64]
os: [win32]
+ '@esbuild/win32-arm64@0.25.0':
+ resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
'@esbuild/win32-ia32@0.18.20':
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
engines: {node: '>=12'}
@@ -1154,6 +1304,12 @@ packages:
cpu: [ia32]
os: [win32]
+ '@esbuild/win32-ia32@0.25.0':
+ resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
'@esbuild/win32-x64@0.18.20':
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
engines: {node: '>=12'}
@@ -1184,6 +1340,12 @@ packages:
cpu: [x64]
os: [win32]
+ '@esbuild/win32-x64@0.25.0':
+ resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
'@floating-ui/core@1.6.9':
resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==}
@@ -2485,6 +2647,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ esbuild@0.25.0:
+ resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
@@ -3435,6 +3602,11 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
+ tsx@4.19.3:
+ resolution: {integrity: sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
turbo-darwin-64@2.1.1:
resolution: {integrity: sha512-aYNuJpZlCoi0Htd79fl/2DywpewGKijdXeOfg9KzNuPVKzSMYlAXuAlNGh0MKjiOcyqxQGL7Mq9LFhwA0VpDpQ==}
cpu: [x64]
@@ -3647,6 +3819,9 @@ packages:
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
engines: {node: '>=18'}
+ zod@3.24.2:
+ resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
+
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -3840,30 +4015,30 @@ snapshots:
human-id: 1.0.2
prettier: 2.8.8
- '@cipherstash/protect-ffi-darwin-arm64@0.11.0':
+ '@cipherstash/protect-ffi-darwin-arm64@0.12.0':
optional: true
- '@cipherstash/protect-ffi-darwin-x64@0.11.0':
+ '@cipherstash/protect-ffi-darwin-x64@0.12.0':
optional: true
- '@cipherstash/protect-ffi-linux-arm64-gnu@0.11.0':
+ '@cipherstash/protect-ffi-linux-arm64-gnu@0.12.0':
optional: true
- '@cipherstash/protect-ffi-linux-x64-gnu@0.11.0':
+ '@cipherstash/protect-ffi-linux-x64-gnu@0.12.0':
optional: true
- '@cipherstash/protect-ffi-win32-x64-msvc@0.11.0':
+ '@cipherstash/protect-ffi-win32-x64-msvc@0.12.0':
optional: true
- '@cipherstash/protect-ffi@0.11.0':
+ '@cipherstash/protect-ffi@0.12.0':
dependencies:
'@neon-rs/load': 0.1.82
optionalDependencies:
- '@cipherstash/protect-ffi-darwin-arm64': 0.11.0
- '@cipherstash/protect-ffi-darwin-x64': 0.11.0
- '@cipherstash/protect-ffi-linux-arm64-gnu': 0.11.0
- '@cipherstash/protect-ffi-linux-x64-gnu': 0.11.0
- '@cipherstash/protect-ffi-win32-x64-msvc': 0.11.0
+ '@cipherstash/protect-ffi-darwin-arm64': 0.12.0
+ '@cipherstash/protect-ffi-darwin-x64': 0.12.0
+ '@cipherstash/protect-ffi-linux-arm64-gnu': 0.12.0
+ '@cipherstash/protect-ffi-linux-x64-gnu': 0.12.0
+ '@cipherstash/protect-ffi-win32-x64-msvc': 0.12.0
'@clerk/backend@1.21.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
@@ -3995,6 +4170,9 @@ snapshots:
'@esbuild/aix-ppc64@0.24.2':
optional: true
+ '@esbuild/aix-ppc64@0.25.0':
+ optional: true
+
'@esbuild/android-arm64@0.18.20':
optional: true
@@ -4010,6 +4188,9 @@ snapshots:
'@esbuild/android-arm64@0.24.2':
optional: true
+ '@esbuild/android-arm64@0.25.0':
+ optional: true
+
'@esbuild/android-arm@0.18.20':
optional: true
@@ -4025,6 +4206,9 @@ snapshots:
'@esbuild/android-arm@0.24.2':
optional: true
+ '@esbuild/android-arm@0.25.0':
+ optional: true
+
'@esbuild/android-x64@0.18.20':
optional: true
@@ -4040,6 +4224,9 @@ snapshots:
'@esbuild/android-x64@0.24.2':
optional: true
+ '@esbuild/android-x64@0.25.0':
+ optional: true
+
'@esbuild/darwin-arm64@0.18.20':
optional: true
@@ -4055,6 +4242,9 @@ snapshots:
'@esbuild/darwin-arm64@0.24.2':
optional: true
+ '@esbuild/darwin-arm64@0.25.0':
+ optional: true
+
'@esbuild/darwin-x64@0.18.20':
optional: true
@@ -4070,6 +4260,9 @@ snapshots:
'@esbuild/darwin-x64@0.24.2':
optional: true
+ '@esbuild/darwin-x64@0.25.0':
+ optional: true
+
'@esbuild/freebsd-arm64@0.18.20':
optional: true
@@ -4085,6 +4278,9 @@ snapshots:
'@esbuild/freebsd-arm64@0.24.2':
optional: true
+ '@esbuild/freebsd-arm64@0.25.0':
+ optional: true
+
'@esbuild/freebsd-x64@0.18.20':
optional: true
@@ -4100,6 +4296,9 @@ snapshots:
'@esbuild/freebsd-x64@0.24.2':
optional: true
+ '@esbuild/freebsd-x64@0.25.0':
+ optional: true
+
'@esbuild/linux-arm64@0.18.20':
optional: true
@@ -4115,6 +4314,9 @@ snapshots:
'@esbuild/linux-arm64@0.24.2':
optional: true
+ '@esbuild/linux-arm64@0.25.0':
+ optional: true
+
'@esbuild/linux-arm@0.18.20':
optional: true
@@ -4130,6 +4332,9 @@ snapshots:
'@esbuild/linux-arm@0.24.2':
optional: true
+ '@esbuild/linux-arm@0.25.0':
+ optional: true
+
'@esbuild/linux-ia32@0.18.20':
optional: true
@@ -4145,6 +4350,9 @@ snapshots:
'@esbuild/linux-ia32@0.24.2':
optional: true
+ '@esbuild/linux-ia32@0.25.0':
+ optional: true
+
'@esbuild/linux-loong64@0.18.20':
optional: true
@@ -4160,6 +4368,9 @@ snapshots:
'@esbuild/linux-loong64@0.24.2':
optional: true
+ '@esbuild/linux-loong64@0.25.0':
+ optional: true
+
'@esbuild/linux-mips64el@0.18.20':
optional: true
@@ -4175,6 +4386,9 @@ snapshots:
'@esbuild/linux-mips64el@0.24.2':
optional: true
+ '@esbuild/linux-mips64el@0.25.0':
+ optional: true
+
'@esbuild/linux-ppc64@0.18.20':
optional: true
@@ -4190,6 +4404,9 @@ snapshots:
'@esbuild/linux-ppc64@0.24.2':
optional: true
+ '@esbuild/linux-ppc64@0.25.0':
+ optional: true
+
'@esbuild/linux-riscv64@0.18.20':
optional: true
@@ -4205,6 +4422,9 @@ snapshots:
'@esbuild/linux-riscv64@0.24.2':
optional: true
+ '@esbuild/linux-riscv64@0.25.0':
+ optional: true
+
'@esbuild/linux-s390x@0.18.20':
optional: true
@@ -4220,6 +4440,9 @@ snapshots:
'@esbuild/linux-s390x@0.24.2':
optional: true
+ '@esbuild/linux-s390x@0.25.0':
+ optional: true
+
'@esbuild/linux-x64@0.18.20':
optional: true
@@ -4235,9 +4458,15 @@ snapshots:
'@esbuild/linux-x64@0.24.2':
optional: true
+ '@esbuild/linux-x64@0.25.0':
+ optional: true
+
'@esbuild/netbsd-arm64@0.24.2':
optional: true
+ '@esbuild/netbsd-arm64@0.25.0':
+ optional: true
+
'@esbuild/netbsd-x64@0.18.20':
optional: true
@@ -4253,12 +4482,18 @@ snapshots:
'@esbuild/netbsd-x64@0.24.2':
optional: true
+ '@esbuild/netbsd-x64@0.25.0':
+ optional: true
+
'@esbuild/openbsd-arm64@0.23.1':
optional: true
'@esbuild/openbsd-arm64@0.24.2':
optional: true
+ '@esbuild/openbsd-arm64@0.25.0':
+ optional: true
+
'@esbuild/openbsd-x64@0.18.20':
optional: true
@@ -4274,6 +4509,9 @@ snapshots:
'@esbuild/openbsd-x64@0.24.2':
optional: true
+ '@esbuild/openbsd-x64@0.25.0':
+ optional: true
+
'@esbuild/sunos-x64@0.18.20':
optional: true
@@ -4289,6 +4527,9 @@ snapshots:
'@esbuild/sunos-x64@0.24.2':
optional: true
+ '@esbuild/sunos-x64@0.25.0':
+ optional: true
+
'@esbuild/win32-arm64@0.18.20':
optional: true
@@ -4304,6 +4545,9 @@ snapshots:
'@esbuild/win32-arm64@0.24.2':
optional: true
+ '@esbuild/win32-arm64@0.25.0':
+ optional: true
+
'@esbuild/win32-ia32@0.18.20':
optional: true
@@ -4319,6 +4563,9 @@ snapshots:
'@esbuild/win32-ia32@0.24.2':
optional: true
+ '@esbuild/win32-ia32@0.25.0':
+ optional: true
+
'@esbuild/win32-x64@0.18.20':
optional: true
@@ -4334,6 +4581,9 @@ snapshots:
'@esbuild/win32-x64@0.24.2':
optional: true
+ '@esbuild/win32-x64@0.25.0':
+ optional: true
+
'@floating-ui/core@1.6.9':
dependencies:
'@floating-ui/utils': 0.2.9
@@ -5428,6 +5678,34 @@ snapshots:
'@esbuild/win32-ia32': 0.24.2
'@esbuild/win32-x64': 0.24.2
+ esbuild@0.25.0:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.0
+ '@esbuild/android-arm': 0.25.0
+ '@esbuild/android-arm64': 0.25.0
+ '@esbuild/android-x64': 0.25.0
+ '@esbuild/darwin-arm64': 0.25.0
+ '@esbuild/darwin-x64': 0.25.0
+ '@esbuild/freebsd-arm64': 0.25.0
+ '@esbuild/freebsd-x64': 0.25.0
+ '@esbuild/linux-arm': 0.25.0
+ '@esbuild/linux-arm64': 0.25.0
+ '@esbuild/linux-ia32': 0.25.0
+ '@esbuild/linux-loong64': 0.25.0
+ '@esbuild/linux-mips64el': 0.25.0
+ '@esbuild/linux-ppc64': 0.25.0
+ '@esbuild/linux-riscv64': 0.25.0
+ '@esbuild/linux-s390x': 0.25.0
+ '@esbuild/linux-x64': 0.25.0
+ '@esbuild/netbsd-arm64': 0.25.0
+ '@esbuild/netbsd-x64': 0.25.0
+ '@esbuild/openbsd-arm64': 0.25.0
+ '@esbuild/openbsd-x64': 0.25.0
+ '@esbuild/sunos-x64': 0.25.0
+ '@esbuild/win32-arm64': 0.25.0
+ '@esbuild/win32-ia32': 0.25.0
+ '@esbuild/win32-x64': 0.25.0
+
esprima@4.0.1: {}
estree-walker@3.0.3:
@@ -5896,13 +6174,13 @@ snapshots:
postcss: 8.5.1
ts-node: 10.9.2(@types/node@20.17.12)(typescript@5.7.2)
- postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.2)(yaml@2.7.0):
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.3)(yaml@2.7.0):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
postcss: 8.5.1
- tsx: 4.19.2
+ tsx: 4.19.3
yaml: 2.7.0
postcss-nested@6.2.0(postcss@8.5.1):
@@ -6332,7 +6610,7 @@ snapshots:
tslib@2.8.1: {}
- tsup@8.3.5(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.7.0):
+ tsup@8.3.5(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.3)(typescript@5.7.2)(yaml@2.7.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.24.2)
cac: 6.7.14
@@ -6342,7 +6620,7 @@ snapshots:
esbuild: 0.24.2
joycon: 3.1.1
picocolors: 1.1.1
- postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.2)(yaml@2.7.0)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.1)(tsx@4.19.3)(yaml@2.7.0)
resolve-from: 5.0.0
rollup: 4.30.1
source-map: 0.8.0-beta.0
@@ -6366,6 +6644,13 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ tsx@4.19.3:
+ dependencies:
+ esbuild: 0.25.0
+ get-tsconfig: 4.8.1
+ optionalDependencies:
+ fsevents: 2.3.3
+
turbo-darwin-64@2.1.1:
optional: true
@@ -6539,3 +6824,5 @@ snapshots:
optional: true
yoctocolors@2.1.1: {}
+
+ zod@3.24.2: {}