58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/components/AddUserForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useToast } from '@/hooks/use-toast'
4 | import { useRouter } from 'next/navigation'
5 | import { useState } from 'react'
6 | import { addUser } from '../lib/actions'
7 | import { Button } from './ui/button'
8 | import { Input } from './ui/input'
9 | import { Label } from './ui/label'
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectItem,
14 | SelectTrigger,
15 | SelectValue,
16 | } from './ui/select'
17 |
18 | export default function AddUserForm() {
19 | const [role, setRole] = useState('')
20 | const router = useRouter()
21 | const { toast } = useToast()
22 |
23 | const handleSubmit = async (formData: FormData) => {
24 | formData.append('role', role)
25 | const result = await addUser(formData)
26 | if (result.error) {
27 | toast({
28 | title: 'Error',
29 | description: result.error,
30 | variant: 'destructive',
31 | })
32 | } else {
33 | toast({
34 | title: 'Success',
35 | description: 'User added successfully',
36 | })
37 | router.push('/')
38 | }
39 | }
40 |
41 | return (
42 |
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/examples/nest/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nest",
3 | "version": "0.0.8",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "build": "nest build",
10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
11 | "start": "nest start",
12 | "start:dev": "nest start --watch",
13 | "start:debug": "nest start --debug --watch",
14 | "start:prod": "node dist/main",
15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
16 | "test": "jest",
17 | "test:watch": "jest --watch",
18 | "test:cov": "jest --coverage",
19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
20 | "test:e2e": "jest --config ./test/jest-e2e.json"
21 | },
22 | "dependencies": {
23 | "@cipherstash/protect": "workspace:*",
24 | "@nestjs/common": "^11.0.1",
25 | "@nestjs/config": "^3.2.0",
26 | "@nestjs/core": "^11.0.1",
27 | "@nestjs/platform-express": "^11.0.1",
28 | "dotenv": "^16.4.7",
29 | "reflect-metadata": "^0.2.2",
30 | "rxjs": "^7.8.1"
31 | },
32 | "devDependencies": {
33 | "@eslint/eslintrc": "^3.2.0",
34 | "@eslint/js": "^9.18.0",
35 | "@nestjs/cli": "^11.0.0",
36 | "@nestjs/schematics": "^11.0.0",
37 | "@nestjs/testing": "^11.0.1",
38 | "@types/express": "^5.0.0",
39 | "@types/jest": "^30.0.0",
40 | "@types/node": "^22.10.7",
41 | "@types/supertest": "^6.0.2",
42 | "globals": "^16.0.0",
43 | "jest": "^30.0.0",
44 | "prettier": "^3.4.2",
45 | "source-map-support": "^0.5.21",
46 | "supertest": "^7.0.0",
47 | "ts-jest": "^29.2.5",
48 | "ts-loader": "^9.5.2",
49 | "ts-node": "^10.9.2",
50 | "tsconfig-paths": "^4.2.0",
51 | "typescript": "^5.7.3",
52 | "typescript-eslint": "^8.20.0"
53 | },
54 | "jest": {
55 | "moduleFileExtensions": [
56 | "js",
57 | "json",
58 | "ts"
59 | ],
60 | "rootDir": "src",
61 | "testRegex": ".*\\.spec\\.ts$",
62 | "transform": {
63 | "^.+\\.(t|j)s$": "ts-jest"
64 | },
65 | "collectCoverageFrom": [
66 | "**/*.(t|j)s"
67 | ],
68 | "coverageDirectory": "../coverage",
69 | "testEnvironment": "node"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/examples/dynamo/src/encrypted-partition-key.ts:
--------------------------------------------------------------------------------
1 | import { GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb'
2 | import { protectDynamoDB } from '@cipherstash/protect-dynamodb'
3 | import { createTable, docClient } from './common/dynamo'
4 | import { log } from './common/log'
5 | import { protectClient, users } from './common/protect'
6 |
7 | const tableName = 'UsersEncryptedPartitionKey'
8 |
9 | type User = {
10 | email: string
11 | }
12 |
13 | const main = async () => {
14 | await createTable({
15 | TableName: tableName,
16 | AttributeDefinitions: [
17 | {
18 | AttributeName: 'email__hmac',
19 | AttributeType: 'S',
20 | },
21 | ],
22 | KeySchema: [
23 | {
24 | AttributeName: 'email__hmac',
25 | KeyType: 'HASH',
26 | },
27 | ],
28 | })
29 |
30 | const protectDynamo = protectDynamoDB({
31 | protectClient,
32 | })
33 |
34 | const user = {
35 | // `email` will be encrypted because it's included in the `users` protected table schema.
36 | email: 'abc@example.com',
37 | // `somePlaintextAttr` won't be encrypted because it's not in the protected table schema.
38 | somePlaintextAttr: 'abc',
39 | }
40 |
41 | const encryptResult = await protectDynamo.encryptModel(user, users)
42 |
43 | log('encrypted item', encryptResult)
44 |
45 | const putCommand = new PutCommand({
46 | TableName: tableName,
47 | Item: encryptResult,
48 | })
49 |
50 | await docClient.send(putCommand)
51 |
52 | const searchTermsResult = await protectDynamo.createSearchTerms([
53 | {
54 | value: 'abc@example.com',
55 | column: users.email,
56 | table: users,
57 | },
58 | ])
59 |
60 | if (searchTermsResult.failure) {
61 | throw new Error(
62 | `Failed to create search terms: ${searchTermsResult.failure.message}`,
63 | )
64 | }
65 |
66 | const [emailHmac] = searchTermsResult.data
67 |
68 | const getCommand = new GetCommand({
69 | TableName: tableName,
70 | Key: { email__hmac: emailHmac },
71 | })
72 |
73 | const getResult = await docClient.send(getCommand)
74 |
75 | const decryptedItem = await protectDynamo.decryptModel(
76 | getResult.Item,
77 | users,
78 | )
79 |
80 | log('decrypted item', decryptedItem)
81 | }
82 |
83 | main()
84 |
--------------------------------------------------------------------------------
/examples/nest/README.md:
--------------------------------------------------------------------------------
1 | # Protect.js Example with NestJS
2 |
3 | > ⚠️ **Heads-up:** This example was generated with AI with some very specific prompting to make it as useful as possible for you :)
4 | > If you find any issues, think this example is absolutely terrible, or would like to speak with a human, book a call with the [CipherStash solutions engineering team](https://calendly.com/cipherstash-gtm/cipherstash-discovery-call?month=2025-09)
5 |
6 | ## What this shows
7 | - Field-level encryption on 2+ properties via `encryptModel`/`decryptModel` and bulk variants
8 | - Identity-aware encryption is supported (optional `LockContext` chaining)
9 | - Result contract preserved: operations return `{ data }` or `{ failure }`
10 |
11 | ## 90-second Quickstart
12 | ```bash
13 | pnpm install
14 | cp .env.example .env
15 | pnpm start:dev
16 | ```
17 |
18 | Environment variables (in `.env`):
19 | ```bash
20 | CS_WORKSPACE_CRN=
21 | CS_CLIENT_ID=
22 | CS_CLIENT_KEY=
23 | CS_CLIENT_ACCESS_KEY=
24 | ```
25 |
26 | ### How encryption works here
27 | - `src/protect/schema.ts` defines tables with `.equality()`, `.orderAndRange()`, `.freeTextSearch()` for searchable encryption on Postgres.
28 | - `ProtectModule` initializes a `ProtectClient` with those schemas and injects a `ProtectService`.
29 | - `AppService` uses `encryptModel`/`decryptModel` and bulk variants to demonstrate single and bulk flows.
30 |
31 | ### Minimal API demo
32 | - `GET /` — returns a demo payload with encrypted and decrypted models and a bulk example
33 | - `POST /users` — encrypts provided fields and returns the encrypted model
34 | - `GET /users/:id` — decrypts a provided encrypted model (demo flow)
35 |
36 | ### Scripts
37 | - `pnpm start:dev` — run in watch mode
38 | - `pnpm test` / `pnpm test:e2e`
39 |
40 | ### Troubleshooting
41 | - Ensure `.env` has all required `CS_*` variables; lock-context flows require user JWTs.
42 | - Node 22+ is required; Bun is not supported.
43 | - If you integrate bundlers, externalize `@cipherstash/protect-ffi` (native module).
44 |
45 | ### References
46 | - Protect.js: see repo root `README.md`
47 | - NestJS docs: `https://docs.nestjs.com/`
48 | - Next.js external packages: `docs/how-to/nextjs-external-packages.md`
49 | - SST external packages: `docs/how-to/sst-external-packages.md`
50 | - npm lockfile v3 on Linux: `docs/how-to/npm-lockfile-v3.md`
--------------------------------------------------------------------------------
/packages/protect-dynamodb/src/index.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Encrypted,
3 | ProtectTable,
4 | ProtectTableColumn,
5 | SearchTerm,
6 | } from '@cipherstash/protect'
7 | import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models'
8 | import { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models'
9 | import { DecryptModelOperation } from './operations/decrypt-model'
10 | import { EncryptModelOperation } from './operations/encrypt-model'
11 | import { SearchTermsOperation } from './operations/search-terms'
12 | import type { ProtectDynamoDBConfig, ProtectDynamoDBInstance } from './types'
13 |
14 | export function protectDynamoDB(
15 | config: ProtectDynamoDBConfig,
16 | ): ProtectDynamoDBInstance {
17 | const { protectClient, options } = config
18 |
19 | return {
20 | encryptModel>(
21 | item: T,
22 | protectTable: ProtectTable,
23 | ) {
24 | return new EncryptModelOperation(
25 | protectClient,
26 | item,
27 | protectTable,
28 | options,
29 | )
30 | },
31 |
32 | bulkEncryptModels>(
33 | items: T[],
34 | protectTable: ProtectTable,
35 | ) {
36 | return new BulkEncryptModelsOperation(
37 | protectClient,
38 | items,
39 | protectTable,
40 | options,
41 | )
42 | },
43 |
44 | decryptModel>(
45 | item: Record,
46 | protectTable: ProtectTable,
47 | ) {
48 | return new DecryptModelOperation(
49 | protectClient,
50 | item,
51 | protectTable,
52 | options,
53 | )
54 | },
55 |
56 | bulkDecryptModels>(
57 | items: Record[],
58 | protectTable: ProtectTable,
59 | ) {
60 | return new BulkDecryptModelsOperation(
61 | protectClient,
62 | items,
63 | protectTable,
64 | options,
65 | )
66 | },
67 |
68 | createSearchTerms(terms: SearchTerm[]) {
69 | return new SearchTermsOperation(protectClient, terms, options)
70 | },
71 | }
72 | }
73 |
74 | export * from './types'
75 |
--------------------------------------------------------------------------------
/examples/basic/README.md:
--------------------------------------------------------------------------------
1 | # Basic example of using @cipherstash/protect
2 |
3 | This basic example demonstrates how to use the `@cipherstash/protect` package to encrypt arbitrary input.
4 |
5 | ## Installing the basic example
6 |
7 | > [!IMPORTANT]
8 | > Make sure you have installed Node.js and [pnpm](https://pnpm.io/installation) before following these steps.
9 |
10 | Clone this repo:
11 |
12 | ```bash
13 | git clone https://github.com/cipherstash/protectjs
14 | ```
15 |
16 | Install dependencies:
17 |
18 | ```bash
19 | # Build Project.js
20 | cd protectjs
21 | pnpm build
22 |
23 | # Install deps for basic example
24 | cd examples/basic
25 | pnpm install
26 | ```
27 |
28 | Lastly, install the CipherStash CLI:
29 |
30 | - On macOS:
31 |
32 | ```bash
33 | brew install cipherstash/tap/stash
34 | ```
35 |
36 | - On Linux, download the binary for your platform, and put it on your `PATH`:
37 | - [Linux ARM64](https://github.com/cipherstash/cli-releases/releases/latest/download/stash-aarch64-unknown-linux-gnu)
38 | - [Linux x86_64](https://github.com/cipherstash/cli-releases/releases/latest/download/stash-x86_64-unknown-linux-gnu)
39 |
40 |
41 | ## Configuring the basic example
42 |
43 | > [!IMPORTANT]
44 | > Make sure you have [installed the CipherStash CLI](#installation) before following these steps.
45 |
46 | Set up all the configuration and credentials required for Protect.js:
47 |
48 | ```bash
49 | stash setup
50 | ```
51 |
52 | If you have not already signed up for a CipherStash account, this will prompt you to do so along the way.
53 |
54 | At the end of `stash setup`, you will have two files in your project:
55 |
56 | - `cipherstash.toml` which contains the configuration for Protect.js
57 | - `cipherstash.secret.toml` which contains the credentials for Protect.js
58 |
59 | > [!WARNING]
60 | > Do not commit `cipherstash.secret.toml` to git, because it contains sensitive credentials.
61 |
62 |
63 | ## Using the basic example
64 |
65 | Run the example:
66 |
67 | ```
68 | pnpm start
69 | ```
70 |
71 | The application will log the plaintext to the console that has been encrypted using the CipherStash, decrypted, and logged the original plaintext.
72 |
73 | ## Next steps
74 |
75 | Check out the [Protect.js + Next.js + Clerk example app](../nextjs-clerk) to see how to add end-user identity as an extra control when encrypting data.
76 |
--------------------------------------------------------------------------------
/packages/drizzle/__tests__/utils/code-executor.ts:
--------------------------------------------------------------------------------
1 | export interface ExecutionContext {
2 | [key: string]: unknown
3 | }
4 |
5 | export interface ExecutionResult {
6 | success: boolean
7 | result?: unknown
8 | error?: string
9 | }
10 |
11 | /**
12 | * Execute a documentation code block in a controlled context.
13 | *
14 | * ## Security Considerations
15 | *
16 | * This function uses the `Function()` constructor to execute arbitrary code.
17 | * This is equivalent to `eval()` and would normally be a serious security risk.
18 | *
19 | * **Why it's safe in this context:**
20 | * 1. **Trusted source:** Code comes only from our own documentation files in the
21 | * repository, not from user input or external sources.
22 | * 2. **Code review:** All documentation code examples go through PR review before
23 | * being merged, same as production code.
24 | * 3. **No network exposure:** Tests run in CI or local dev, never in production
25 | * environments handling user requests.
26 | * 4. **Controlled context:** Executed code only has access to explicitly provided
27 | * context variables (db, operators), not global scope or filesystem.
28 | *
29 | * **When this would NOT be safe:**
30 | * - If code came from user input (web forms, API requests)
31 | * - If code came from external/untrusted sources
32 | * - If executed in a production environment
33 | * - If the execution context included sensitive globals
34 | *
35 | * The eslint-disable comment below acknowledges we've considered the security
36 | * implications and determined this usage is appropriate for the use case.
37 | */
38 | export async function executeCodeBlock(
39 | code: string,
40 | context: ExecutionContext,
41 | ): Promise {
42 | try {
43 | // Create an async function with access to context variables
44 | const contextKeys = Object.keys(context)
45 | const contextValues = Object.values(context)
46 |
47 | // eslint-disable-next-line @typescript-eslint/no-implied-eval
48 | const asyncFn = new Function(
49 | ...contextKeys,
50 | `return (async () => { ${code} })()`,
51 | )
52 |
53 | const result = await asyncFn(...contextValues)
54 |
55 | return {
56 | success: true,
57 | result,
58 | }
59 | } catch (error) {
60 | return {
61 | success: false,
62 | error: error instanceof Error ? error.message : String(error),
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/examples/dynamo/src/bulk-operations.ts:
--------------------------------------------------------------------------------
1 | import { BatchGetCommand, BatchWriteCommand } from '@aws-sdk/lib-dynamodb'
2 | import { protectDynamoDB } from '@cipherstash/protect-dynamodb'
3 | import { createTable, docClient, dynamoClient } from './common/dynamo'
4 | import { log } from './common/log'
5 | import { protectClient, users } from './common/protect'
6 |
7 | const tableName = 'UsersBulkOperations'
8 |
9 | type User = {
10 | pk: string
11 | email: string
12 | }
13 |
14 | const main = async () => {
15 | await createTable({
16 | TableName: tableName,
17 | AttributeDefinitions: [
18 | {
19 | AttributeName: 'pk',
20 | AttributeType: 'S',
21 | },
22 | ],
23 | KeySchema: [
24 | {
25 | AttributeName: 'pk',
26 | KeyType: 'HASH',
27 | },
28 | ],
29 | })
30 |
31 | const protectDynamo = protectDynamoDB({
32 | protectClient,
33 | })
34 |
35 | const items = [
36 | {
37 | // `pk` won't be encrypted because it's not included in the `users` protected table schema.
38 | pk: 'user#1',
39 | // `email` will be encrypted because it's included in the `users` protected table schema.
40 | email: 'abc@example.com',
41 | },
42 | {
43 | pk: 'user#2',
44 | email: 'def@example.com',
45 | },
46 | ]
47 |
48 | const encryptResult = await protectDynamo.bulkEncryptModels(items, users)
49 |
50 | if (encryptResult.failure) {
51 | throw new Error(`Failed to encrypt items: ${encryptResult.failure.message}`)
52 | }
53 |
54 | const putRequests = encryptResult.data.map(
55 | (item: Record) => ({
56 | PutRequest: {
57 | Item: item,
58 | },
59 | }),
60 | )
61 |
62 | log('encrypted items', encryptResult)
63 |
64 | const batchWriteCommand = new BatchWriteCommand({
65 | RequestItems: {
66 | [tableName]: putRequests,
67 | },
68 | })
69 |
70 | await dynamoClient.send(batchWriteCommand)
71 |
72 | const batchGetCommand = new BatchGetCommand({
73 | RequestItems: {
74 | [tableName]: {
75 | Keys: [{ pk: 'user#1' }, { pk: 'user#2' }],
76 | },
77 | },
78 | })
79 |
80 | const getResult = await docClient.send(batchGetCommand)
81 |
82 | const decryptedItems = await protectDynamo.bulkDecryptModels(
83 | getResult.Responses?.[tableName],
84 | users,
85 | )
86 |
87 | log('decrypted items', decryptedItems)
88 | }
89 |
90 | main()
91 |
--------------------------------------------------------------------------------
/examples/next-drizzle-mysql/src/components/form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { createUser } from '@/app/actions'
4 | import { zodResolver } from '@hookform/resolvers/zod'
5 | import { useTransition } from 'react'
6 | import { useForm } from 'react-hook-form'
7 | import * as z from 'zod'
8 |
9 | const formSchema = z.object({
10 | name: z.string().min(1, 'Name is required'),
11 | email: z.string().email('Invalid email address'),
12 | })
13 |
14 | export type FormData = z.infer
15 |
16 | export function ClientForm() {
17 | const [isPending, startTransition] = useTransition()
18 | const {
19 | register,
20 | handleSubmit,
21 | reset,
22 | formState: { errors },
23 | } = useForm({
24 | resolver: zodResolver(formSchema),
25 | })
26 |
27 | const onSubmit = (data: FormData) => {
28 | startTransition(async () => {
29 | await createUser(data)
30 | reset()
31 | })
32 | }
33 |
34 | return (
35 |
77 | )
78 | }
79 |
--------------------------------------------------------------------------------
/packages/protect/__tests__/keysets.test.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import { csColumn, csTable } from '@cipherstash/schema'
3 | import { describe, expect, it } from 'vitest'
4 | import { protect } from '../src'
5 |
6 | const users = csTable('users', {
7 | email: csColumn('email'),
8 | })
9 |
10 | describe('encryption and decryption with keyset id', () => {
11 | it('should encrypt and decrypt a payload', async () => {
12 | const protectClient = await protect({
13 | schemas: [users],
14 | keyset: {
15 | id: '4152449b-505a-4186-93b6-d3d87eba7a47',
16 | },
17 | })
18 |
19 | const email = 'hello@example.com'
20 |
21 | const ciphertext = await protectClient.encrypt(email, {
22 | column: users.email,
23 | table: users,
24 | })
25 |
26 | if (ciphertext.failure) {
27 | throw new Error(`[protect]: ${ciphertext.failure.message}`)
28 | }
29 |
30 | // Verify encrypted field
31 | expect(ciphertext.data).toHaveProperty('c')
32 |
33 | const a = ciphertext.data
34 |
35 | const plaintext = await protectClient.decrypt(ciphertext.data)
36 |
37 | expect(plaintext).toEqual({
38 | data: email,
39 | })
40 | }, 30000)
41 | })
42 |
43 | describe('encryption and decryption with keyset name', () => {
44 | it('should encrypt and decrypt a payload', async () => {
45 | const protectClient = await protect({
46 | schemas: [users],
47 | keyset: {
48 | name: 'Test',
49 | },
50 | })
51 |
52 | const email = 'hello@example.com'
53 |
54 | const ciphertext = await protectClient.encrypt(email, {
55 | column: users.email,
56 | table: users,
57 | })
58 |
59 | if (ciphertext.failure) {
60 | throw new Error(`[protect]: ${ciphertext.failure.message}`)
61 | }
62 |
63 | // Verify encrypted field
64 | expect(ciphertext.data).toHaveProperty('c')
65 |
66 | const a = ciphertext.data
67 |
68 | const plaintext = await protectClient.decrypt(ciphertext.data)
69 |
70 | expect(plaintext).toEqual({
71 | data: email,
72 | })
73 | }, 30000)
74 | })
75 |
76 | describe('encryption and decryption with invalid keyset id', () => {
77 | it('should throw an error', async () => {
78 | await expect(
79 | protect({
80 | schemas: [users],
81 | keyset: {
82 | id: 'invalid-uuid',
83 | },
84 | }),
85 | ).rejects.toThrow(
86 | '[protect]: Invalid UUID provided for keyset id. Must be a valid UUID.',
87 | )
88 | })
89 | })
90 |
--------------------------------------------------------------------------------
/examples/next-drizzle-mysql/README.md:
--------------------------------------------------------------------------------
1 | # Next.js + Drizzle ORM + MySQL + Protect.js Example
2 |
3 | This example demonstrates how to build a modern web application using:
4 | - [Next.js](https://nextjs.org/) - React framework for production
5 | - [Drizzle ORM](https://orm.drizzle.team/) - TypeScript ORM for SQL databases
6 | - [MySQL](https://www.mysql.com/) - Popular open-source relational database
7 | - [Protect.js](https://cipherstash.com/protect) - Data protection and encryption library
8 |
9 | ## Features
10 |
11 | - Full-stack TypeScript application
12 | - Database migrations and schema management with Drizzle
13 | - Data protection and encryption with Protect.js
14 | - Modern UI with Tailwind CSS
15 | - Form handling with React Hook Form and Zod validation
16 | - Docker-based MySQL database setup
17 |
18 | ## Prerequisites
19 |
20 | - Node.js 18+
21 | - Docker and Docker Compose
22 | - MySQL (if running locally without Docker)
23 |
24 | ## Getting Started
25 |
26 | 1. Clone the repository and install dependencies:
27 | ```bash
28 | pnpm install
29 | ```
30 |
31 | 2. Set up your environment variables:
32 | Copy the `.env.example` file to `.env.local`:
33 | ```bash
34 | cp .env.example .env.local
35 | ```
36 | Then update the environment variables in `.env.local` with your Protect.js configuration values.
37 |
38 | 3. Start the MySQL database using Docker:
39 | ```bash
40 | docker compose up -d
41 | ```
42 |
43 | 4. Run database migrations:
44 | ```bash
45 | pnpm run db:generate
46 | pnpm run db:migrate
47 | ```
48 |
49 | 5. Start the development server:
50 | ```bash
51 | pnpm run dev
52 | ```
53 |
54 | The application will be available at `http://localhost:3000`.
55 |
56 | ## Project Structure
57 |
58 | - `/src` - Application source code
59 | - `/drizzle` - Database migrations and schema
60 | - `/public` - Static assets
61 | - `drizzle.config.ts` - Drizzle ORM configuration
62 | - `docker-compose.yml` - Docker configuration for MySQL
63 |
64 | ## Available Scripts
65 |
66 | - `npm run dev` - Start development server
67 | - `npm run build` - Build for production
68 | - `npm run start` - Start production server
69 | - `npm run db:generate` - Generate database migrations
70 | - `npm run db:migrate` - Run database migrations
71 |
72 | ## Learn More
73 |
74 | - [Next.js Documentation](https://nextjs.org/docs)
75 | - [Drizzle ORM Documentation](https://orm.drizzle.team/docs/overview)
76 | - [Protect.js Documentation](https://cipherstash.com/protect/docs)
77 | - [MySQL Documentation](https://dev.mysql.com/doc/)
78 |
--------------------------------------------------------------------------------
/examples/dynamo/src/encrypted-sort-key.ts:
--------------------------------------------------------------------------------
1 | import { GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb'
2 | import { protectDynamoDB } from '@cipherstash/protect-dynamodb'
3 | import { createTable, docClient, dynamoClient } from './common/dynamo'
4 | import { log } from './common/log'
5 | import { protectClient, users } from './common/protect'
6 |
7 | const tableName = 'UsersEncryptedSortKey'
8 |
9 | type User = {
10 | pk: string
11 | email: string
12 | }
13 |
14 | const main = async () => {
15 | await createTable({
16 | TableName: tableName,
17 | AttributeDefinitions: [
18 | {
19 | AttributeName: 'pk',
20 | AttributeType: 'S',
21 | },
22 | {
23 | AttributeName: 'email__hmac',
24 | AttributeType: 'S',
25 | },
26 | ],
27 | KeySchema: [
28 | {
29 | AttributeName: 'pk',
30 | KeyType: 'HASH',
31 | },
32 | {
33 | AttributeName: 'email__hmac',
34 | KeyType: 'RANGE',
35 | },
36 | ],
37 | })
38 |
39 | const protectDynamo = protectDynamoDB({
40 | protectClient,
41 | })
42 |
43 | const user = {
44 | // `pk` won't be encrypted because it's not in the protected table schema.
45 | pk: 'user#1',
46 | // `email` will be encrypted because it's included in the `users` protected table schema.
47 | email: 'abc@example.com',
48 | }
49 |
50 | const encryptResult = await protectDynamo.encryptModel(user, users)
51 |
52 | log('encrypted item', encryptResult)
53 |
54 | const putCommand = new PutCommand({
55 | TableName: tableName,
56 | Item: encryptResult,
57 | })
58 |
59 | await docClient.send(putCommand)
60 |
61 | const searchTermsResult = await protectDynamo.createSearchTerms([
62 | {
63 | value: 'abc@example.com',
64 | column: users.email,
65 | table: users,
66 | },
67 | ])
68 |
69 | if (searchTermsResult.failure) {
70 | throw new Error(
71 | `Failed to create search terms: ${searchTermsResult.failure.message}`,
72 | )
73 | }
74 |
75 | const [emailHmac] = searchTermsResult.data
76 |
77 | const getCommand = new GetCommand({
78 | TableName: tableName,
79 | Key: { pk: 'user#1', email__hmac: emailHmac },
80 | })
81 |
82 | const getResult = await docClient.send(getCommand)
83 |
84 | if (!getResult.Item) {
85 | throw new Error('Item not found')
86 | }
87 |
88 | const decryptedItem = await protectDynamo.decryptModel(
89 | getResult.Item,
90 | users,
91 | )
92 |
93 | log('decrypted item', decryptedItem)
94 | }
95 |
96 | main()
97 |
--------------------------------------------------------------------------------
/.cursorrules:
--------------------------------------------------------------------------------
1 | ## Protect.js Cursor Rules
2 |
3 | These rules guide agents when creating or updating example apps under `examples/*` in this repository.
4 |
5 | ### Example App Prompt (for agents)
6 |
7 | - **Goals**
8 | - Show end-to-end usage of Protect.js with clear, minimal code.
9 | - Demonstrate schema, encrypt/decrypt, and (when relevant) searchable encryption on PostgreSQL.
10 |
11 | - **Hard guardrails (do not violate)**
12 | - Do not log plaintext at any time.
13 | - Preserve the Result contract: operations return `{ data }` or `{ failure }` with stable error `type` strings.
14 | - Do not change EQL payload shapes or keys (e.g., `c`).
15 | - `@cipherstash/protect-ffi` is a native Node-API module and must be externalized by bundlers (loaded via runtime `require`).
16 | - Keep both ESM and CJS exports working; do not break `require`.
17 | - Bun is not supported; use Node.js.
18 |
19 | - **Prerequisites and workflow**
20 | - Use Node.js >= 22 and pnpm 9.x.
21 | - Install/build/test:
22 | - `pnpm install`
23 | - `pnpm --filter dev|build|test`
24 | - Environment variables for examples/tests that talk to CipherStash:
25 | - `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`
26 | - Optional for identity-aware encryption: `USER_JWT`, `USER_2_JWT`
27 |
28 | - **Docs to reference**
29 | - `docs/how-to/nextjs-external-packages.md`
30 | - `docs/how-to/sst-external-packages.md`
31 | - `docs/how-to/npm-lockfile-v3.md`
32 | - `docs/reference/schema.md`
33 | - `docs/concepts/searchable-encryption.md`
34 |
35 | - **Deliverables checklist for a new example**
36 | - A `protect.ts` (or equivalent) that initializes `protect({ schemas })` using `csTable`/`csColumn`.
37 | - If targeting Postgres searchable encryption, include `.freeTextSearch().equality().orderAndRange()` on appropriate columns.
38 | - A minimal script or route/handler that encrypts and decrypts at least one value.
39 | - A README covering:
40 | - Setup (env vars, install, run commands)
41 | - Notes on native module externalization if the framework builds/bundles (e.g., Next.js, SST)
42 | - How to run tests (if included)
43 | - Optional: demonstrate identity-aware encryption via `LockContext` and chaining `.withLockContext()` for both encrypt and decrypt.
44 |
45 | - **Quality bar**
46 | - Prefer bulk operations to demonstrate performance where appropriate.
47 | - Keep examples small, idiomatic, and runnable as-is with documented env vars.
48 | - Never leak secrets in code or logs; avoid any plaintext logging.
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Test JS
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 | pull_request:
8 | branches:
9 | - "**"
10 |
11 | jobs:
12 | run-tests:
13 | name: Run Tests
14 | runs-on: blacksmith-4vcpu-ubuntu-2404
15 |
16 | steps:
17 | - name: Checkout Repo
18 | uses: actions/checkout@v3
19 |
20 | - uses: pnpm/action-setup@v4
21 | name: Install pnpm
22 | with:
23 | run_install: false
24 |
25 | - name: Install Node.js
26 | uses: actions/setup-node@v4
27 | with:
28 | node-version: 20
29 | cache: 'pnpm'
30 |
31 | - name: Install dependencies
32 | run: pnpm install
33 |
34 | - name: Create .env file in ./packages/protect/
35 | run: |
36 | touch ./packages/protect/.env
37 | echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env
38 | echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/protect/.env
39 | echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env
40 | echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env
41 | echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> ./packages/protect/.env
42 | echo "SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" >> ./packages/protect/.env
43 |
44 | - name: Create .env file in ./packages/protect-dynamodb/
45 | run: |
46 | touch ./packages/protect-dynamodb/.env
47 | echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env
48 | echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env
49 | echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env
50 | echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env
51 |
52 | - name: Create .env file in ./packages/drizzle/
53 | run: |
54 | touch ./packages/drizzle/.env
55 | echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/drizzle/.env
56 | echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/drizzle/.env
57 | echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/drizzle/.env
58 | echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/drizzle/.env
59 | echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> ./packages/drizzle/.env
60 |
61 | # Run TurboRepo tests
62 | - name: Run tests
63 | run: pnpm run test
64 |
--------------------------------------------------------------------------------
/examples/dynamo/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @cipherstash/dynamo-example
2 |
3 | ## 0.2.15
4 |
5 | ### Patch Changes
6 |
7 | - @cipherstash/protect@10.2.1
8 | - @cipherstash/protect-dynamodb@6.0.1
9 |
10 | ## 0.2.14
11 |
12 | ### Patch Changes
13 |
14 | - Updated dependencies [de029de]
15 | - @cipherstash/protect@10.2.0
16 | - @cipherstash/protect-dynamodb@6.0.0
17 |
18 | ## 0.2.13
19 |
20 | ### Patch Changes
21 |
22 | - Updated dependencies [ff4421f]
23 | - @cipherstash/protect@10.1.1
24 | - @cipherstash/protect-dynamodb@5.1.1
25 |
26 | ## 0.2.12
27 |
28 | ### Patch Changes
29 |
30 | - Updated dependencies [6b87c17]
31 | - @cipherstash/protect@10.1.0
32 | - @cipherstash/protect-dynamodb@6.0.0
33 |
34 | ## 0.2.11
35 |
36 | ### Patch Changes
37 |
38 | - @cipherstash/protect@10.0.2
39 | - @cipherstash/protect-dynamodb@5.0.2
40 |
41 | ## 0.2.10
42 |
43 | ### Patch Changes
44 |
45 | - @cipherstash/protect@10.0.1
46 | - @cipherstash/protect-dynamodb@5.0.1
47 |
48 | ## 0.2.9
49 |
50 | ### Patch Changes
51 |
52 | - Updated dependencies [788dbfc]
53 | - @cipherstash/protect-dynamodb@5.0.0
54 | - @cipherstash/protect@10.0.0
55 |
56 | ## 0.2.8
57 |
58 | ### Patch Changes
59 |
60 | - Updated dependencies [c7ed7ab]
61 | - Updated dependencies [211e979]
62 | - @cipherstash/protect@9.6.0
63 | - @cipherstash/protect-dynamodb@4.0.0
64 |
65 | ## 0.2.7
66 |
67 | ### Patch Changes
68 |
69 | - Updated dependencies [6f45b02]
70 | - @cipherstash/protect-dynamodb@3.0.0
71 | - @cipherstash/protect@9.5.0
72 |
73 | ## 0.2.6
74 |
75 | ### Patch Changes
76 |
77 | - @cipherstash/protect@9.4.1
78 | - @cipherstash/protect-dynamodb@2.0.1
79 |
80 | ## 0.2.5
81 |
82 | ### Patch Changes
83 |
84 | - Updated dependencies [1cc4772]
85 | - @cipherstash/protect@9.4.0
86 | - @cipherstash/protect-dynamodb@2.0.0
87 |
88 | ## 0.2.4
89 |
90 | ### Patch Changes
91 |
92 | - Updated dependencies [01fed9e]
93 | - @cipherstash/protect-dynamodb@1.0.0
94 | - @cipherstash/protect@9.3.0
95 |
96 | ## 0.2.3
97 |
98 | ### Patch Changes
99 |
100 | - Updated dependencies [2b63ee1]
101 | - Updated dependencies [e33fbaf]
102 | - @cipherstash/protect-dynamodb@0.3.0
103 |
104 | ## 0.2.2
105 |
106 | ### Patch Changes
107 |
108 | - Updated dependencies [587f222]
109 | - @cipherstash/protect@9.2.0
110 | - @cipherstash/protect-dynamodb@0.2.0
111 |
112 | ## 0.2.1
113 |
114 | ### Patch Changes
115 |
116 | - Updated dependencies [5fc0150]
117 | - @cipherstash/protect-dynamodb@0.2.0
118 |
119 | ## 0.2.0
120 |
121 | ### Minor Changes
122 |
123 | - c8468ee: Released initial version of the DynamoDB helper interface.
124 |
125 | ### Patch Changes
126 |
127 | - Updated dependencies [c8468ee]
128 | - @cipherstash/protect-dynamodb@1.0.0
129 | - @cipherstash/protect@9.1.0
130 |
--------------------------------------------------------------------------------
/examples/typeorm/src/utils/encrypted-column.ts:
--------------------------------------------------------------------------------
1 | import type { EncryptedData } from '@cipherstash/protect'
2 | import type { ColumnOptions } from 'typeorm'
3 |
4 | /**
5 | * Transformer for encrypted data columns that handles PostgreSQL composite literal format
6 | * automatically. This eliminates the need for manual lifecycle hooks.
7 | */
8 | export const encryptedDataTransformer = {
9 | /**
10 | * Transform encrypted data to PostgreSQL composite literal format for storage
11 | */
12 | to(value: EncryptedData | null): string | null {
13 | if (value === null || value === undefined) {
14 | return null
15 | }
16 |
17 | // Convert to PostgreSQL composite literal format: (json_string)
18 | return `(${JSON.stringify(JSON.stringify(value))})`
19 | },
20 |
21 | /**
22 | * Transform PostgreSQL composite literal format back to encrypted data object
23 | */
24 | from(value: string | null): EncryptedData | null {
25 | if (!value || typeof value !== 'string') {
26 | return null
27 | }
28 |
29 | try {
30 | let jsonString: string = value.trim()
31 |
32 | // Remove outer parentheses if they exist
33 | if (jsonString.startsWith('(') && jsonString.endsWith(')')) {
34 | jsonString = jsonString.slice(1, -1)
35 | }
36 |
37 | // Handle PostgreSQL's double-quote escaping: "" -> "
38 | jsonString = jsonString.replace(/""/g, '"')
39 |
40 | // Remove outer quotes if they exist
41 | if (jsonString.startsWith('"') && jsonString.endsWith('"')) {
42 | jsonString = jsonString.slice(1, -1)
43 | }
44 |
45 | // Parse the JSON string
46 | return JSON.parse(jsonString)
47 | } catch (error: unknown) {
48 | console.error('Failed to parse encrypted data:', {
49 | original: value,
50 | error: error instanceof Error ? error.message : 'Unknown error',
51 | })
52 | // Return null if parsing fails to avoid breaking the application
53 | return null
54 | }
55 | },
56 | }
57 |
58 | /**
59 | * Enhanced column options for encrypted data with automatic transformation
60 | */
61 | export interface EncryptedColumnOptions
62 | extends Omit {
63 | /**
64 | * Whether the column can be null. Defaults to true for encrypted columns.
65 | */
66 | nullable?: boolean
67 | }
68 |
69 | /**
70 | * Creates column options for an encrypted column with automatic PostgreSQL transformation
71 | */
72 | export function createEncryptedColumnOptions(
73 | options: EncryptedColumnOptions = {},
74 | ): ColumnOptions {
75 | return {
76 | // biome-ignore lint/suspicious/noExplicitAny: TypeORM doesn't know about our custom type
77 | type: 'eql_v2_encrypted' as any,
78 | nullable: true, // Default to nullable for encrypted columns
79 | transformer: encryptedDataTransformer,
80 | ...options,
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/packages/utils/config/index.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs'
2 | import path from 'node:path'
3 |
4 | /**
5 | * A lightweight function that parses a TOML-like string
6 | * and returns the `workspace_crn` value found under `[auth]`.
7 | *
8 | * @param tomlString The contents of the TOML file as a string.
9 | * @returns The workspace_crn if found, otherwise undefined.
10 | */
11 | function getWorkspaceCrn(tomlString: string): string | undefined {
12 | let currentSection = ''
13 | let workspaceCrn: string | undefined
14 |
15 | const lines = tomlString.split(/\r?\n/)
16 |
17 | for (const line of lines) {
18 | const trimmedLine = line.trim()
19 |
20 | if (!trimmedLine || trimmedLine.startsWith('#')) {
21 | continue
22 | }
23 |
24 | const sectionMatch = trimmedLine.match(/^\[([^\]]+)\]$/)
25 | if (sectionMatch) {
26 | currentSection = sectionMatch[1]
27 | continue
28 | }
29 |
30 | const kvMatch = trimmedLine.match(/^(\w+)\s*=\s*"([^"]+)"$/)
31 | if (kvMatch) {
32 | const [_, key, value] = kvMatch
33 |
34 | if (currentSection === 'auth' && key === 'workspace_crn') {
35 | workspaceCrn = value
36 | break
37 | }
38 | }
39 | }
40 |
41 | return workspaceCrn
42 | }
43 |
44 | /**
45 | * Extracts the workspace ID from a CRN string.
46 | * CRN format: crn:region.aws:ID
47 | *
48 | * @param crn The CRN string to extract from
49 | * @returns The workspace ID portion of the CRN
50 | */
51 | function extractWorkspaceIdFromCrn(crn: string): string {
52 | const match = crn.match(/crn:[^:]+:([^:]+)$/)
53 | if (!match) {
54 | throw new Error('Invalid CRN format')
55 | }
56 | return match[1]
57 | }
58 |
59 | export function loadWorkSpaceId(suppliedCrn?: string): string {
60 | const configPath = path.join(process.cwd(), 'cipherstash.toml')
61 |
62 | if (suppliedCrn) {
63 | return extractWorkspaceIdFromCrn(suppliedCrn)
64 | }
65 |
66 | if (!fs.existsSync(configPath) && !process.env.CS_WORKSPACE_CRN) {
67 | throw new Error(
68 | 'You have not defined a workspace CRN in your config file, or the CS_WORKSPACE_CRN environment variable.',
69 | )
70 | }
71 |
72 | // Environment variables take precedence over config files
73 | if (process.env.CS_WORKSPACE_CRN) {
74 | return extractWorkspaceIdFromCrn(process.env.CS_WORKSPACE_CRN)
75 | }
76 |
77 | if (!fs.existsSync(configPath)) {
78 | throw new Error(
79 | 'You have not defined a workspace CRN in your config file, or the CS_WORKSPACE_CRN environment variable.',
80 | )
81 | }
82 |
83 | const tomlString = fs.readFileSync(configPath, 'utf8')
84 | const workspaceCrn = getWorkspaceCrn(tomlString)
85 |
86 | if (!workspaceCrn) {
87 | throw new Error(
88 | 'You have not defined a workspace CRN in your config file, or the CS_WORKSPACE_CRN environment variable.',
89 | )
90 | }
91 |
92 | return extractWorkspaceIdFromCrn(workspaceCrn)
93 | }
94 |
--------------------------------------------------------------------------------
/examples/nest/src/protect/decorators/decrypt.decorator.ts:
--------------------------------------------------------------------------------
1 | import { type ExecutionContext, createParamDecorator } from '@nestjs/common'
2 | import { getProtectService } from '../utils/get-protect-service.util'
3 |
4 | import type {
5 | ProtectColumn,
6 | ProtectTable,
7 | ProtectTableColumn,
8 | ProtectValue,
9 | } from '@cipherstash/protect'
10 |
11 | export interface DecryptOptions {
12 | table: ProtectTable
13 | column: ProtectColumn | ProtectValue
14 | lockContext?: unknown // JWT or LockContext
15 | }
16 |
17 | /**
18 | * Decorator to automatically decrypt a field or entire object
19 | *
20 | * @example
21 | * ```typescript
22 | * @Get(':id')
23 | * async getUser(@Param('id') id: string, @Decrypt('email', { table: 'users', column: 'email' }) decryptedEmail: string) {
24 | * // decryptedEmail is automatically decrypted
25 | * return { id, email: decryptedEmail };
26 | * }
27 | *
28 | * @Get(':id')
29 | * async getUser(@Param('id') id: string, @DecryptModel('users') user: User) {
30 | * // user is automatically decrypted based on schema
31 | * return user;
32 | * }
33 | * ```
34 | */
35 | export const Decrypt = createParamDecorator(
36 | async (field: string, ctx: ExecutionContext) => {
37 | const request = ctx.switchToHttp().getRequest()
38 | const protectService = getProtectService(ctx)
39 |
40 | if (!protectService) {
41 | throw new Error(
42 | 'ProtectService not found. Make sure ProtectModule is imported.',
43 | )
44 | }
45 |
46 | const value =
47 | request.body?.[field] || request.params?.[field] || request.query?.[field]
48 | if (value === undefined || value === null) {
49 | return value
50 | }
51 |
52 | // Check if value is already an encrypted payload
53 | if (typeof value === 'object' && value.c) {
54 | const result = await protectService.decrypt(value)
55 | if (result.failure) {
56 | throw new Error(`Decryption failed: ${result.failure.message}`)
57 | }
58 | return result.data
59 | }
60 |
61 | // If it's not encrypted, return as-is
62 | return value
63 | },
64 | )
65 |
66 | /**
67 | * Decorator to automatically decrypt an entire model based on schema
68 | */
69 | export const DecryptModel = createParamDecorator(
70 | async (tableName: string, ctx: ExecutionContext) => {
71 | const request = ctx.switchToHttp().getRequest()
72 | const protectService = getProtectService(ctx)
73 |
74 | if (!protectService) {
75 | throw new Error(
76 | 'ProtectService not found. Make sure ProtectModule is imported.',
77 | )
78 | }
79 |
80 | const model = request.body || request.params || request.query
81 | if (!model || typeof model !== 'object') {
82 | return model
83 | }
84 |
85 | // This would need to be enhanced to work with actual schema definitions
86 | // For now, it's a placeholder for the concept
87 | return model
88 | },
89 | )
90 |
--------------------------------------------------------------------------------
/examples/nest/src/protect/protect.service.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Decrypted,
3 | EncryptOptions,
4 | EncryptedPayload,
5 | LockContext,
6 | ProtectClient,
7 | ProtectTable,
8 | ProtectTableColumn,
9 | } from '@cipherstash/protect'
10 | import { Inject, Injectable } from '@nestjs/common'
11 | import { PROTECT_CLIENT } from './protect.constants'
12 |
13 | @Injectable()
14 | export class ProtectService {
15 | constructor(
16 | @Inject(PROTECT_CLIENT)
17 | private readonly client: ProtectClient,
18 | ) {}
19 |
20 | async encrypt(plaintext: string, options: EncryptOptions) {
21 | return this.client.encrypt(plaintext, options)
22 | }
23 |
24 | async decrypt(encryptedPayload: EncryptedPayload) {
25 | return this.client.decrypt(encryptedPayload)
26 | }
27 |
28 | async encryptModel>(
29 | model: Decrypted,
30 | table: ProtectTable,
31 | ) {
32 | return this.client.encryptModel(model, table)
33 | }
34 |
35 | async decryptModel>(model: T) {
36 | return this.client.decryptModel(model)
37 | }
38 |
39 | async bulkEncrypt(
40 | plaintexts: Array<{ id?: string; plaintext: string | null }>,
41 | options: EncryptOptions,
42 | ) {
43 | return this.client.bulkEncrypt(plaintexts, options)
44 | }
45 |
46 | async bulkDecrypt(
47 | encryptedData: Array<{ id?: string; data: EncryptedPayload | null }>,
48 | ) {
49 | return this.client.bulkDecrypt(encryptedData)
50 | }
51 |
52 | async bulkEncryptModels>(
53 | models: Decrypted[],
54 | table: ProtectTable,
55 | ) {
56 | return this.client.bulkEncryptModels(models, table)
57 | }
58 |
59 | async bulkDecryptModels>(models: T[]) {
60 | return this.client.bulkDecryptModels(models)
61 | }
62 |
63 | // Identity-aware encryption methods
64 | async encryptWithLockContext(
65 | plaintext: string,
66 | options: EncryptOptions,
67 | lockContext: LockContext,
68 | ) {
69 | return this.client.encrypt(plaintext, options).withLockContext(lockContext)
70 | }
71 |
72 | async decryptWithLockContext(
73 | encryptedPayload: EncryptedPayload,
74 | lockContext: LockContext,
75 | ) {
76 | return this.client.decrypt(encryptedPayload).withLockContext(lockContext)
77 | }
78 |
79 | async encryptModelWithLockContext>(
80 | model: Decrypted,
81 | table: ProtectTable,
82 | lockContext: LockContext,
83 | ) {
84 | return this.client
85 | .encryptModel(model, table)
86 | .withLockContext(lockContext)
87 | }
88 |
89 | async decryptModelWithLockContext>(
90 | model: T,
91 | lockContext: LockContext,
92 | ) {
93 | return this.client.decryptModel(model).withLockContext(lockContext)
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { db } from '@/core/db'
2 | import { users } from '@/core/db/schema'
3 | import { getLockContext, protectClient } from '@/core/protect'
4 | import { getCtsToken } from '@cipherstash/nextjs'
5 | import type { EncryptedData } from '@cipherstash/protect'
6 | import { auth, currentUser } from '@clerk/nextjs/server'
7 | import Header from '../components/Header'
8 | import UserTable from '../components/UserTable'
9 |
10 | export type EncryptedUser = {
11 | id: number
12 | name: string
13 | email: string | null
14 | authorized: boolean
15 | role: string
16 | }
17 |
18 | async function getUsers(): Promise {
19 | const { userId } = await auth()
20 | const token = await getCtsToken()
21 | const results = await db.select().from(users).limit(500)
22 |
23 | if (userId && token.success) {
24 | const cts_token = token.ctsToken
25 | const lockContext = getLockContext(cts_token)
26 |
27 | const promises = results.map(async (row) => {
28 | const decryptResult = await protectClient
29 | .decrypt(row.email as EncryptedData)
30 | .withLockContext(lockContext)
31 |
32 | if (decryptResult.failure) {
33 | console.error(
34 | 'Failed to decrypt the email for user',
35 | row.id,
36 | decryptResult.failure.message,
37 | )
38 |
39 | return row.email
40 | }
41 |
42 | return decryptResult.data
43 | })
44 |
45 | const data = (await Promise.allSettled(promises)) as PromiseSettledResult<
46 | string | null
47 | >[]
48 |
49 | return results.map((row, index) => ({
50 | ...row,
51 | authorized: data[index].status === 'fulfilled',
52 | email:
53 | data[index].status === 'fulfilled'
54 | ? data[index].value
55 | : (row.email as { c: string }).c,
56 | }))
57 | }
58 |
59 | return results.map((row) => ({
60 | id: row.id,
61 | name: row.name,
62 | authorized: false,
63 | email: (row.email as { c: string })?.c,
64 | role: row.role,
65 | }))
66 | }
67 |
68 | export default async function Home() {
69 | const users = await getUsers()
70 | const user = await currentUser()
71 |
72 | return (
73 |
74 |
75 |
76 |
77 |
Users
78 |
79 | The email address of each user was encrypted with CipherStash and{' '}
80 | locked to the individual who created the user. Only that
81 | individual will be able to decrypt the email.
82 |
83 |
84 |
88 |
89 |
90 | )
91 | }
92 |
--------------------------------------------------------------------------------
/packages/protect/__tests__/search-terms.test.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import { csColumn, csTable } from '@cipherstash/schema'
3 | import { describe, expect, it } from 'vitest'
4 | import { type SearchTerm, protect } from '../src'
5 |
6 | const users = csTable('users', {
7 | email: csColumn('email').freeTextSearch().equality().orderAndRange(),
8 | address: csColumn('address').freeTextSearch(),
9 | })
10 |
11 | describe('create search terms', () => {
12 | it('should create search terms with default return type', async () => {
13 | const protectClient = await protect({ schemas: [users] })
14 |
15 | const searchTerms = [
16 | {
17 | value: 'hello',
18 | column: users.email,
19 | table: users,
20 | },
21 | {
22 | value: 'world',
23 | column: users.address,
24 | table: users,
25 | },
26 | ] as SearchTerm[]
27 |
28 | const searchTermsResult = await protectClient.createSearchTerms(searchTerms)
29 |
30 | if (searchTermsResult.failure) {
31 | throw new Error(`[protect]: ${searchTermsResult.failure.message}`)
32 | }
33 |
34 | expect(searchTermsResult.data).toEqual(
35 | expect.arrayContaining([
36 | expect.objectContaining({
37 | c: expect.any(String),
38 | }),
39 | ]),
40 | )
41 | }, 30000)
42 |
43 | it('should create search terms with composite-literal return type', async () => {
44 | const protectClient = await protect({ schemas: [users] })
45 |
46 | const searchTerms = [
47 | {
48 | value: 'hello',
49 | column: users.email,
50 | table: users,
51 | returnType: 'composite-literal',
52 | },
53 | ] as SearchTerm[]
54 |
55 | const searchTermsResult = await protectClient.createSearchTerms(searchTerms)
56 |
57 | if (searchTermsResult.failure) {
58 | throw new Error(`[protect]: ${searchTermsResult.failure.message}`)
59 | }
60 |
61 | const result = searchTermsResult.data[0] as string
62 | expect(result).toMatch(/^\(.*\)$/)
63 | expect(() => JSON.parse(result.slice(1, -1))).not.toThrow()
64 | }, 30000)
65 |
66 | it('should create search terms with escaped-composite-literal return type', async () => {
67 | const protectClient = await protect({ schemas: [users] })
68 |
69 | const searchTerms = [
70 | {
71 | value: 'hello',
72 | column: users.email,
73 | table: users,
74 | returnType: 'escaped-composite-literal',
75 | },
76 | ] as SearchTerm[]
77 |
78 | const searchTermsResult = await protectClient.createSearchTerms(searchTerms)
79 |
80 | if (searchTermsResult.failure) {
81 | throw new Error(`[protect]: ${searchTermsResult.failure.message}`)
82 | }
83 |
84 | const result = searchTermsResult.data[0] as string
85 | expect(result).toMatch(/^".*"$/)
86 | const unescaped = JSON.parse(result)
87 | expect(unescaped).toMatch(/^\(.*\)$/)
88 | expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow()
89 | }, 30000)
90 | })
91 |
--------------------------------------------------------------------------------
/packages/nextjs/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @cipherstash/nextjs
2 |
3 | ## 4.1.0
4 |
5 | ### Minor Changes
6 |
7 | - 1535259: Remove node api calls which are incompatible with Next.js middleware.
8 |
9 | ## 4.0.0
10 |
11 | ### Major Changes
12 |
13 | - 95c891d: Implemented CipherStash CRN in favor of workspace ID.
14 |
15 | - Replaces the environment variable `CS_WORKSPACE_ID` with `CS_WORKSPACE_CRN`
16 | - Replaces `workspace_id` with `workspace_crn` in the `cipherstash.toml` file
17 |
18 | ## 3.2.0
19 |
20 | ### Minor Changes
21 |
22 | - 9377b47: Updated versions to address Next.js CVE.
23 |
24 | ## 3.1.0
25 |
26 | ### Minor Changes
27 |
28 | - a564f21: Bumped versions of dependencies to address CWE-346.
29 |
30 | ## 3.0.0
31 |
32 | ### Major Changes
33 |
34 | - 02dc980: Support configuration from environment variables or toml config.
35 |
36 | ## 2.1.0
37 |
38 | ### Minor Changes
39 |
40 | - 5a34e76: Rebranded logging context and fixed tests.
41 |
42 | ## 2.0.0
43 |
44 | ### Major Changes
45 |
46 | - 76599e5: Rebrand jseql to protect.
47 |
48 | ## 1.2.0
49 |
50 | ### Minor Changes
51 |
52 | - 3cb97c2: Added an optional argument to getCtsToken to fetch a new CTS token.
53 |
54 | ## 1.1.0
55 |
56 | ### Minor Changes
57 |
58 | - d0f5dd9: Enforced a check for the subject claims before setting cts session.
59 |
60 | ## 1.0.0
61 |
62 | ### Major Changes
63 |
64 | - 24f0a72: Implemented better error handling for fetching CTS tokens and accessing them in the Next.js application.
65 |
66 | ## 0.12.0
67 |
68 | ### Minor Changes
69 |
70 | - 14c0279: Fixed optional response argument getting called in setCtsToken.
71 |
72 | ## 0.11.0
73 |
74 | ### Minor Changes
75 |
76 | - ebc23ba: Added support for optional next response in generic jseql middleware functions.
77 |
78 | ## 0.10.0
79 |
80 | ### Minor Changes
81 |
82 | - 7d0fac0: Implemented a generic Next.js jseql middleware.
83 |
84 | ## 0.9.0
85 |
86 | ### Minor Changes
87 |
88 | - e885975: Fixed improper use of throwing errors, and log with jseql logger.
89 |
90 | ## 0.8.0
91 |
92 | ### Minor Changes
93 |
94 | - eeaec18: Implemented typing and import synatx for es6.
95 |
96 | ## 0.7.0
97 |
98 | ### Minor Changes
99 |
100 | - 7b8ec52: Implement packageless logging framework.
101 |
102 | ## 0.6.0
103 |
104 | ### Minor Changes
105 |
106 | - 7480cfd: Fixed node:util package bundling.
107 |
108 | ## 0.5.0
109 |
110 | ### Minor Changes
111 |
112 | - c0123be: Replaced logtape with native node debuglog.
113 |
114 | ## 0.4.0
115 |
116 | ### Minor Changes
117 |
118 | - 3bb4a10: Cleared session cookies when a user has logged out.
119 |
120 | ## 0.3.0
121 |
122 | ### Minor Changes
123 |
124 | - 9a3132c: Fixed the logtape peer dependency version.
125 |
126 | ## 0.2.0
127 |
128 | ### Minor Changes
129 |
130 | - 80ee5af: Fixed bugs when implmenting the lock context with CTS v2 tokens.
131 |
132 | ## 0.1.0
133 |
134 | ### Minor Changes
135 |
136 | - fbb2bcb: Released jseql clerk middleware.
137 |
--------------------------------------------------------------------------------
/examples/nest/src/protect/decorators/encrypt.decorator.ts:
--------------------------------------------------------------------------------
1 | import { type ExecutionContext, createParamDecorator } from '@nestjs/common'
2 | import type { ProtectService } from '../protect.service'
3 | import { users } from '../schema'
4 | import { getProtectService } from '../utils/get-protect-service.util'
5 |
6 | import type {
7 | ProtectColumn,
8 | ProtectTable,
9 | ProtectTableColumn,
10 | ProtectValue,
11 | } from '@cipherstash/protect'
12 |
13 | export interface EncryptOptions {
14 | table: ProtectTable
15 | column: ProtectColumn | ProtectValue
16 | lockContext?: unknown // JWT or LockContext
17 | }
18 |
19 | /**
20 | * Decorator to automatically encrypt a field or entire object
21 | *
22 | * @example
23 | * ```typescript
24 | * @Post()
25 | * async createUser(@Body() userData: CreateUserDto, @Encrypt('email', { table: 'users', column: 'email' }) encryptedEmail: string) {
26 | * // encryptedEmail is automatically encrypted
27 | * return this.userService.create({ ...userData, email: encryptedEmail });
28 | * }
29 | *
30 | * @Post()
31 | * async createUser(@Body() @EncryptModel('users') userData: CreateUserDto) {
32 | * // userData is automatically encrypted based on schema
33 | * return this.userService.create(userData);
34 | * }
35 | * ```
36 | */
37 | export const Encrypt = createParamDecorator(
38 | async (field: string, ctx: ExecutionContext) => {
39 | const request = ctx.switchToHttp().getRequest()
40 | const protectService = getProtectService(ctx)
41 |
42 | if (!protectService) {
43 | throw new Error(
44 | 'ProtectService not found. Make sure ProtectModule is imported.',
45 | )
46 | }
47 |
48 | const value = request.body?.[field]
49 | if (value === undefined || value === null) {
50 | return value
51 | }
52 |
53 | // Note: This is a simplified example. In practice, you'd need to pass actual table/column objects
54 | // from your schema definitions rather than creating them inline
55 | const result = await protectService.encrypt(value, {
56 | table: users,
57 | column: users.email_encrypted,
58 | })
59 |
60 | if (result.failure) {
61 | throw new Error(`Encryption failed: ${result.failure.message}`)
62 | }
63 |
64 | return result.data
65 | },
66 | )
67 |
68 | /**
69 | * Decorator to automatically encrypt an entire model based on schema
70 | */
71 | export const EncryptModel = createParamDecorator(
72 | async (tableName: string, ctx: ExecutionContext) => {
73 | const request = ctx.switchToHttp().getRequest()
74 | const protectService = getProtectService(ctx)
75 |
76 | if (!protectService) {
77 | throw new Error(
78 | 'ProtectService not found. Make sure ProtectModule is imported.',
79 | )
80 | }
81 |
82 | const model = request.body
83 | if (!model || typeof model !== 'object') {
84 | return model
85 | }
86 |
87 | // This would need to be enhanced to work with actual schema definitions
88 | // For now, it's a placeholder for the concept
89 | return model
90 | },
91 | )
92 |
--------------------------------------------------------------------------------
/examples/nest/src/protect/interceptors/encrypt.interceptor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type CallHandler,
3 | type ExecutionContext,
4 | Injectable,
5 | type NestInterceptor,
6 | } from '@nestjs/common'
7 | import type { Observable } from 'rxjs'
8 | import { map } from 'rxjs/operators'
9 | import type { ProtectService } from '../protect.service'
10 | import { getProtectService } from '../utils/get-protect-service.util'
11 |
12 | import type {
13 | ProtectColumn,
14 | ProtectTable,
15 | ProtectTableColumn,
16 | ProtectValue,
17 | } from '@cipherstash/protect'
18 |
19 | export interface EncryptInterceptorOptions {
20 | fields?: string[]
21 | table: ProtectTable
22 | column: ProtectColumn | ProtectValue
23 | lockContext?: unknown
24 | }
25 |
26 | /**
27 | * Interceptor to automatically encrypt response data
28 | *
29 | * @example
30 | * ```typescript
31 | * @UseInterceptors(new EncryptInterceptor({
32 | * fields: ['email', 'phone'],
33 | * table: 'users',
34 | * column: 'email'
35 | * }))
36 | * @Get()
37 | * async getUsers() {
38 | * return this.userService.findAll(); // Email and phone fields will be encrypted
39 | * }
40 | * ```
41 | */
42 | @Injectable()
43 | export class EncryptInterceptor implements NestInterceptor {
44 | constructor(private readonly options: EncryptInterceptorOptions) {}
45 |
46 | async intercept(
47 | context: ExecutionContext,
48 | next: CallHandler,
49 | ): Promise> {
50 | const protectService = getProtectService(context)
51 |
52 | if (!protectService) {
53 | throw new Error(
54 | 'ProtectService not found. Make sure ProtectModule is imported.',
55 | )
56 | }
57 |
58 | return next.handle().pipe(
59 | map(async (data: unknown) => {
60 | if (!data) return data
61 |
62 | if (Array.isArray(data)) {
63 | return Promise.all(
64 | data.map((item) => this.encryptItem(item, protectService)),
65 | )
66 | }
67 |
68 | return this.encryptItem(data, protectService)
69 | }),
70 | )
71 | }
72 |
73 | private async encryptItem(
74 | item: unknown,
75 | protectService: ProtectService,
76 | ): Promise {
77 | if (!item || typeof item !== 'object') {
78 | return item
79 | }
80 |
81 | const result = { ...item }
82 |
83 | if (this.options.fields) {
84 | for (const field of this.options.fields) {
85 | if (result[field] !== undefined && result[field] !== null) {
86 | const encryptResult = await protectService.encrypt(result[field], {
87 | table: this.options.table,
88 | column: this.options.column,
89 | })
90 |
91 | if (encryptResult.failure) {
92 | throw new Error(
93 | `Encryption failed for field ${field}: ${encryptResult.failure.message}`,
94 | )
95 | }
96 |
97 | result[field] = encryptResult.data
98 | }
99 | }
100 | }
101 |
102 | return result
103 | }
104 | }
105 |
--------------------------------------------------------------------------------