87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]',
92 | className,
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = 'TableCell'
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = 'TableCaption'
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useToast } from '@/hooks/use-toast'
4 | import {
5 | Toast,
6 | ToastClose,
7 | ToastDescription,
8 | ToastProvider,
9 | ToastTitle,
10 | ToastViewport,
11 | } from '@/components/ui/toast'
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(({ id, title, description, action, ...props }) => (
19 |
20 |
21 | {title && {title}}
22 | {description && {description}}
23 |
24 | {action}
25 |
26 |
27 | ))}
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
19 |
28 |
29 | ))
30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
31 |
32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
33 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/core/db/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from 'drizzle-orm/postgres-js'
2 | import postgres from 'postgres'
3 |
4 | if (!process.env.POSTGRES_URL) {
5 | throw new Error(
6 | "[ Server ] Error: Drizzle ORM - You did not supply 'POSTGRES_URL' env var.",
7 | )
8 | }
9 |
10 | const connectionString = process.env.POSTGRES_URL
11 | const client = postgres(connectionString)
12 | export const db = drizzle(client)
13 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/core/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { pgTable, serial, jsonb, varchar } from 'drizzle-orm/pg-core'
2 |
3 | // Data that is encrypted using protectjs is stored as jsonb in postgres
4 | // ---
5 | // This example does not include any searchable encrypted fields
6 | // If you want to search on encrypted fields, you will need to install EQL.
7 | // The EQL library ships with custom types that are used to define encrypted fields.
8 | // See https://github.com/cipherstash/encrypted-query-language
9 | // ---
10 |
11 | export const users = pgTable('users', {
12 | id: serial('id').primaryKey(),
13 | name: varchar('name').notNull(),
14 | email: jsonb('email').notNull(),
15 | role: varchar('role').notNull(),
16 | })
17 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/core/protect/index.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import {
3 | protect,
4 | LockContext,
5 | type CtsToken,
6 | csColumn,
7 | csTable,
8 | type ProtectClientConfig,
9 | } from '@cipherstash/protect'
10 |
11 | export const users = csTable('users', {
12 | email: csColumn('email'),
13 | })
14 |
15 | const config: ProtectClientConfig = {
16 | schemas: [users],
17 | }
18 |
19 | export const protectClient = await protect(config)
20 |
21 | export const getLockContext = (cts_token?: CtsToken) => {
22 | if (!cts_token) {
23 | throw new Error(
24 | '[protect] A CTS token is required in order to get a lock context.',
25 | )
26 | }
27 |
28 | const lockContext = new LockContext({
29 | ctsToken: cts_token,
30 | })
31 |
32 | return lockContext
33 | }
34 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/lib/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { users } from '@/core/db/schema'
4 | import { db } from '@/core/db'
5 | import { protectClient, users as protectUsers } from '@/core/protect'
6 | import { getLockContext } from '@/core/protect'
7 | import { getCtsToken } from '@cipherstash/nextjs'
8 | import { revalidatePath } from 'next/cache'
9 | import { auth } from '@clerk/nextjs/server'
10 |
11 | export async function addUser(formData: FormData) {
12 | const { userId } = await auth()
13 |
14 | if (!userId) {
15 | return { error: 'You must be signed in to add a user.' }
16 | }
17 |
18 | const name = formData.get('name') as string
19 | const email = formData.get('email') as string
20 | const role = formData.get('role') as string
21 |
22 | if (!name || !email || !role) {
23 | return { error: 'All fields are required' }
24 | }
25 |
26 | const ctsToken = await getCtsToken()
27 |
28 | if (!ctsToken.success) {
29 | return { error: 'There was an error getting your session token.' }
30 | }
31 |
32 | const lockContext = getLockContext(ctsToken.ctsToken)
33 | const encryptedResult = await protectClient
34 | .encrypt(email, {
35 | column: protectUsers.email,
36 | table: protectUsers,
37 | })
38 | .withLockContext(lockContext)
39 |
40 | if (encryptedResult.failure) {
41 | return {
42 | error: 'Failed to add the user. There was an error encrypting the email.',
43 | }
44 | }
45 |
46 | const encryptedEmail = encryptedResult.data
47 |
48 | try {
49 | await db.insert(users).values({ name, email: encryptedEmail, role })
50 | revalidatePath('/')
51 | return { success: true }
52 | } catch (error) {
53 | console.error('Failed to add user:', error)
54 | return { error: 'Failed to add user' }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware } from '@clerk/nextjs/server'
2 | import { protectClerkMiddleware } from '@cipherstash/nextjs/clerk'
3 |
4 | export default clerkMiddleware(async (auth, req) => {
5 | return protectClerkMiddleware(auth, req)
6 | })
7 |
8 | export const config = {
9 | matcher: [
10 | // Skip Next.js internals and all static files, unless found in search params
11 | '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
12 | // Always run for API routes
13 | '/(api|trpc)(.*)',
14 | ],
15 | }
16 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | darkMode: ["class"],
5 | content: [
6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | background: "hsl(var(--background))",
14 | foreground: "hsl(var(--foreground))",
15 | card: {
16 | DEFAULT: "hsl(var(--card))",
17 | foreground: "hsl(var(--card-foreground))",
18 | },
19 | popover: {
20 | DEFAULT: "hsl(var(--popover))",
21 | foreground: "hsl(var(--popover-foreground))",
22 | },
23 | primary: {
24 | DEFAULT: "hsl(var(--primary))",
25 | foreground: "hsl(var(--primary-foreground))",
26 | },
27 | secondary: {
28 | DEFAULT: "hsl(var(--secondary))",
29 | foreground: "hsl(var(--secondary-foreground))",
30 | },
31 | muted: {
32 | DEFAULT: "hsl(var(--muted))",
33 | foreground: "hsl(var(--muted-foreground))",
34 | },
35 | accent: {
36 | DEFAULT: "hsl(var(--accent))",
37 | foreground: "hsl(var(--accent-foreground))",
38 | },
39 | destructive: {
40 | DEFAULT: "hsl(var(--destructive))",
41 | foreground: "hsl(var(--destructive-foreground))",
42 | },
43 | border: "hsl(var(--border))",
44 | input: "hsl(var(--input))",
45 | ring: "hsl(var(--ring))",
46 | chart: {
47 | "1": "hsl(var(--chart-1))",
48 | "2": "hsl(var(--chart-2))",
49 | "3": "hsl(var(--chart-3))",
50 | "4": "hsl(var(--chart-4))",
51 | "5": "hsl(var(--chart-5))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | },
60 | },
61 | plugins: [require("tailwindcss-animate")],
62 | } satisfies Config;
63 |
--------------------------------------------------------------------------------
/examples/nextjs-clerk/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": [
26 | "examples/web/next-env.d.ts",
27 | "**/*.ts",
28 | "**/*.tsx",
29 | ".next/types/**/*.ts",
30 | "next.config.ts"
31 | ],
32 | "exclude": ["node_modules"]
33 | }
34 |
--------------------------------------------------------------------------------
/mise.toml:
--------------------------------------------------------------------------------
1 | [tools]
2 | node = "22.11.0"
3 | pnpm = "9.15.3"
4 |
5 | [env]
6 | CS_EQL_VERSION="eql-1.0.0"
7 |
8 | [tasks."postgres:eql:download"]
9 | alias = 'e'
10 | description = "Download latest EQL release"
11 | outputs = [
12 | "{{config_root}}/sql/cipherstash-encrypt.sql",
13 | "{{config_root}}/sql/cipherstash-encrypt-uninstall.sql",
14 | ]
15 | run = """
16 | mkdir sql
17 |
18 | # install script
19 | if [ -z "$CS_EQL_PATH" ]; then
20 | curl -sLo sql/cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/download/${CS_EQL_VERSION}/cipherstash-encrypt.sql
21 | else
22 | echo "Using EQL: ${CS_EQL_PATH}"
23 | cp "$CS_EQL_PATH" sql/cipherstash-encrypt.sql
24 | fi
25 |
26 | # uninstall script
27 | if [ -z "$CS_EQL_UNINSTALL_PATH" ]; then
28 | curl -sLo sql/cipherstash-encrypt-uninstall.sql https://github.com/cipherstash/encrypt-query-language/releases/download/${CS_EQL_VERSION}/cipherstash-encrypt-uninstall.sql
29 | else
30 | echo "Using EQL: ${CS_EQL_PATH}"
31 | cp "$CS_EQL_UNINSTALL_PATH" sql/cipherstash-encrypt-uninstall.sql
32 | fi
33 | """
34 |
35 | [tasks."postgres:setup"]
36 | alias = 's'
37 | description = "Installs EQL and applies schema to database"
38 | run = """
39 | #!/bin/bash
40 |
41 | mise run postgres:eql:download
42 | cat sql/cipherstash-encrypt.sql | docker exec -i protectjs-postgres-1 psql postgresql://cipherstash:password@postgres:5432/cipherstash -f-
43 | """
44 |
45 | [tasks."postgres:psql"]
46 | description = "Run psql (interactively) against the Postgres instance; assumes Postgres is already up"
47 | run = """
48 | set -eu
49 | docker exec -it protectjs-postgres-1 psql "postgresql://cipherstash:password@postgres:5432/cipherstash"
50 | """
51 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cipherstash/protectjs",
3 | "description": "CipherStash Protect for JavaScript/TypeScript",
4 | "author": "CipherStash ",
5 | "keywords": [
6 | "encrypted",
7 | "query",
8 | "language",
9 | "typescript",
10 | "ts",
11 | "eql"
12 | ],
13 | "bugs": {
14 | "url": "https://github.com/cipherstash/protectjs/issues"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/cipherstash/protectjs.git"
19 | },
20 | "license": "MIT",
21 | "workspaces": [
22 | "examples/*",
23 | "packages/*"
24 | ],
25 | "scripts": {
26 | "build": "turbo build --filter './packages/*'",
27 | "build:js": "turbo build --filter './packages/protect' --filter './packages/nextjs'",
28 | "changeset": "changeset",
29 | "changeset:version": "changeset version",
30 | "changeset:publish": "changeset publish",
31 | "dev": "turbo dev --filter './packages/*'",
32 | "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules",
33 | "code:fix": "biome check --write",
34 | "release": "pnpm run build && changeset publish",
35 | "test": "turbo test --filter './packages/*'"
36 | },
37 | "devDependencies": {
38 | "@biomejs/biome": "^1.9.4",
39 | "@changesets/cli": "^2.29.3",
40 | "@types/node": "^22.15.12",
41 | "rimraf": "6.0.1",
42 | "turbo": "2.1.1"
43 | },
44 | "packageManager": "pnpm@9.15.3",
45 | "engines": {
46 | "node": ">=22"
47 | },
48 | "pnpm": {
49 | "overrides": {
50 | "@babel/runtime": "7.26.10",
51 | "vite": "catalog:security"
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/packages/jseql/README.md:
--------------------------------------------------------------------------------
1 | # @cipherstash/jseql
2 |
3 | The `@cipherstash/jseql` package has been deprecated in favor of the `@cipherstash/protect` package.
4 | Please refer to the [main README](https://github.com/cipherstash/protectjs) for more information.
--------------------------------------------------------------------------------
/packages/nextjs/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @cipherstash/nextjs
2 |
3 | ## 4.0.0
4 |
5 | ### Major Changes
6 |
7 | - 95c891d: Implemented CipherStash CRN in favor of workspace ID.
8 |
9 | - Replaces the environment variable `CS_WORKSPACE_ID` with `CS_WORKSPACE_CRN`
10 | - Replaces `workspace_id` with `workspace_crn` in the `cipherstash.toml` file
11 |
12 | ## 3.2.0
13 |
14 | ### Minor Changes
15 |
16 | - 9377b47: Updated versions to address Next.js CVE.
17 |
18 | ## 3.1.0
19 |
20 | ### Minor Changes
21 |
22 | - a564f21: Bumped versions of dependencies to address CWE-346.
23 |
24 | ## 3.0.0
25 |
26 | ### Major Changes
27 |
28 | - 02dc980: Support configuration from environment variables or toml config.
29 |
30 | ## 2.1.0
31 |
32 | ### Minor Changes
33 |
34 | - 5a34e76: Rebranded logging context and fixed tests.
35 |
36 | ## 2.0.0
37 |
38 | ### Major Changes
39 |
40 | - 76599e5: Rebrand jseql to protect.
41 |
42 | ## 1.2.0
43 |
44 | ### Minor Changes
45 |
46 | - 3cb97c2: Added an optional argument to getCtsToken to fetch a new CTS token.
47 |
48 | ## 1.1.0
49 |
50 | ### Minor Changes
51 |
52 | - d0f5dd9: Enforced a check for the subject claims before setting cts session.
53 |
54 | ## 1.0.0
55 |
56 | ### Major Changes
57 |
58 | - 24f0a72: Implemented better error handling for fetching CTS tokens and accessing them in the Next.js application.
59 |
60 | ## 0.12.0
61 |
62 | ### Minor Changes
63 |
64 | - 14c0279: Fixed optional response argument getting called in setCtsToken.
65 |
66 | ## 0.11.0
67 |
68 | ### Minor Changes
69 |
70 | - ebc23ba: Added support for optional next response in generic jseql middleware functions.
71 |
72 | ## 0.10.0
73 |
74 | ### Minor Changes
75 |
76 | - 7d0fac0: Implemented a generic Next.js jseql middleware.
77 |
78 | ## 0.9.0
79 |
80 | ### Minor Changes
81 |
82 | - e885975: Fixed improper use of throwing errors, and log with jseql logger.
83 |
84 | ## 0.8.0
85 |
86 | ### Minor Changes
87 |
88 | - eeaec18: Implemented typing and import synatx for es6.
89 |
90 | ## 0.7.0
91 |
92 | ### Minor Changes
93 |
94 | - 7b8ec52: Implement packageless logging framework.
95 |
96 | ## 0.6.0
97 |
98 | ### Minor Changes
99 |
100 | - 7480cfd: Fixed node:util package bundling.
101 |
102 | ## 0.5.0
103 |
104 | ### Minor Changes
105 |
106 | - c0123be: Replaced logtape with native node debuglog.
107 |
108 | ## 0.4.0
109 |
110 | ### Minor Changes
111 |
112 | - 3bb4a10: Cleared session cookies when a user has logged out.
113 |
114 | ## 0.3.0
115 |
116 | ### Minor Changes
117 |
118 | - 9a3132c: Fixed the logtape peer dependency version.
119 |
120 | ## 0.2.0
121 |
122 | ### Minor Changes
123 |
124 | - 80ee5af: Fixed bugs when implmenting the lock context with CTS v2 tokens.
125 |
126 | ## 0.1.0
127 |
128 | ### Minor Changes
129 |
130 | - fbb2bcb: Released jseql clerk middleware.
131 |
--------------------------------------------------------------------------------
/packages/nextjs/README.md:
--------------------------------------------------------------------------------
1 | # @cipherstash/protect
2 |
3 | This is the main package for the CipherStash Protect JavaScript Package.
4 | Please refer to the [main README](https://github.com/cipherstash/protectjs) for more information.
--------------------------------------------------------------------------------
/packages/nextjs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cipherstash/nextjs",
3 | "version": "4.0.0",
4 | "description": "Nextjs package for use with @cipherstash/protect",
5 | "keywords": [
6 | "encrypted",
7 | "typescript",
8 | "eql",
9 | "nextjs"
10 | ],
11 | "bugs": {
12 | "url": "https://github.com/cipherstash/protectjs/issues"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/cipherstash/protectjs.git"
17 | },
18 | "license": "MIT",
19 | "author": "CipherStash ",
20 | "type": "module",
21 | "exports": {
22 | ".": {
23 | "types": "./dist/index.d.ts",
24 | "import": "./dist/index.js",
25 | "require": "./dist/index.cjs"
26 | },
27 | "./clerk": {
28 | "types": "./dist/clerk/index.d.ts",
29 | "import": "./dist/clerk/index.js",
30 | "require": "./dist/clerk/index.cjs"
31 | }
32 | },
33 | "scripts": {
34 | "build": "tsup",
35 | "dev": "tsup --watch",
36 | "release": "tsup"
37 | },
38 | "devDependencies": {
39 | "@clerk/nextjs": "6.12.9",
40 | "dotenv": "^16.4.7",
41 | "tsup": "catalog:repo",
42 | "typescript": "catalog:repo",
43 | "vitest": "catalog:repo"
44 | },
45 | "peerDependencies": {
46 | "next": "^14 || ^15"
47 | },
48 | "publishConfig": {
49 | "access": "public"
50 | },
51 | "optionalDependencies": {
52 | "@rollup/rollup-linux-x64-gnu": "4.24.0"
53 | },
54 | "dependencies": {
55 | "jose": "^5.9.6"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/packages/nextjs/src/clerk/index.ts:
--------------------------------------------------------------------------------
1 | import type { ClerkMiddlewareAuth } from '@clerk/nextjs/server'
2 | import type { NextRequest } from 'next/server'
3 | import { NextResponse } from 'next/server'
4 | import { logger } from '../../../utils/logger'
5 | import { setCtsToken } from '../cts'
6 | import { CS_COOKIE_NAME, resetCtsToken } from '../index'
7 |
8 | export const protectClerkMiddleware = async (
9 | auth: ClerkMiddlewareAuth,
10 | req: NextRequest,
11 | ) => {
12 | const { userId, getToken } = await auth()
13 | const ctsSession = req.cookies.has(CS_COOKIE_NAME)
14 |
15 | if (userId && !ctsSession) {
16 | const oidcToken = await getToken()
17 |
18 | if (!oidcToken) {
19 | logger.debug(
20 | 'No Clerk token found in the request, so the CipherStash session was not set.',
21 | )
22 |
23 | return NextResponse.next()
24 | }
25 |
26 | return await setCtsToken(oidcToken)
27 | }
28 |
29 | if (!userId && ctsSession) {
30 | logger.debug(
31 | 'No Clerk token found in the request, so the CipherStash session was reset.',
32 | )
33 |
34 | return resetCtsToken()
35 | }
36 |
37 | logger.debug(
38 | 'No Clerk token found in the request, so the CipherStash session was not set.',
39 | )
40 |
41 | return NextResponse.next()
42 | }
43 |
--------------------------------------------------------------------------------
/packages/nextjs/src/cts/index.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 | import { logger } from '../../../utils/logger'
3 | import { loadWorkSpaceId } from '../../../utils/config'
4 | import {
5 | CS_COOKIE_NAME,
6 | type CtsToken,
7 | type GetCtsTokenResponse,
8 | } from '../index'
9 |
10 | // Can be used independently of the Next.js middleware
11 | export const fetchCtsToken = async (oidcToken: string): GetCtsTokenResponse => {
12 | const workspaceId = loadWorkSpaceId()
13 |
14 | if (!workspaceId) {
15 | logger.error(
16 | 'The "CS_WORKSPACE_ID" environment variable is not set, and is required by protectClerkMiddleware. No CipherStash session will be set.',
17 | )
18 |
19 | return {
20 | success: false,
21 | error: 'The "CS_WORKSPACE_ID" environment variable is not set.',
22 | }
23 | }
24 |
25 | const ctsEndoint =
26 | process.env.CS_CTS_ENDPOINT ||
27 | 'https://ap-southeast-2.aws.auth.viturhosted.net'
28 |
29 | const ctsResponse = await fetch(`${ctsEndoint}/api/authorize`, {
30 | method: 'POST',
31 | headers: {
32 | 'Content-Type': 'application/json',
33 | },
34 | body: JSON.stringify({
35 | workspaceId,
36 | oidcToken,
37 | }),
38 | })
39 |
40 | if (!ctsResponse.ok) {
41 | logger.debug(`Failed to fetch CTS token: ${ctsResponse.statusText}`)
42 |
43 | logger.error(
44 | 'There was an issue communicating with the CipherStash CTS API, the CipherStash session was not set. If the issue persists, please contact support.',
45 | )
46 |
47 | return {
48 | success: false,
49 | error: `Failed to fetch CTS token: ${ctsResponse.statusText}`,
50 | }
51 | }
52 |
53 | const cts_token = (await ctsResponse.json()) as CtsToken
54 |
55 | return {
56 | success: true,
57 | ctsToken: cts_token,
58 | }
59 | }
60 |
61 | // Used in the Next.js middleware
62 | export const setCtsToken = async (oidcToken: string, res?: NextResponse) => {
63 | const ctsResponse = await fetchCtsToken(oidcToken)
64 | const cts_token = ctsResponse.ctsToken
65 |
66 | if (!cts_token) {
67 | logger.debug(`Failed to fetch CTS token: ${ctsResponse.error}`)
68 |
69 | logger.error(
70 | 'There was an issue fetching the CipherStash session, the CipherStash session was not set. If the issue persists, please contact support.',
71 | )
72 |
73 | return res ?? NextResponse.next()
74 | }
75 |
76 | // Setting cookies on the request and response using the `ResponseCookies` API
77 | const response = res ?? NextResponse.next()
78 | response.cookies.set({
79 | name: CS_COOKIE_NAME,
80 | value: JSON.stringify(cts_token),
81 | expires: new Date(cts_token.expiry * 1000),
82 | sameSite: 'lax',
83 | path: '/',
84 | })
85 |
86 | return response
87 | }
88 |
--------------------------------------------------------------------------------
/packages/nextjs/src/index.ts:
--------------------------------------------------------------------------------
1 | import { decodeJwt } from 'jose'
2 | import { cookies } from 'next/headers'
3 | import type { NextRequest } from 'next/server'
4 | import { NextResponse } from 'next/server'
5 | import { logger } from '../../utils/logger'
6 | import { fetchCtsToken, setCtsToken } from './cts'
7 |
8 | function getSubjectFromToken(jwt: string): string | undefined {
9 | const payload = decodeJwt(jwt)
10 |
11 | // The CTS JWT payload has a sub field that starts with "CS|"
12 | if (typeof payload?.sub === 'string' && payload?.sub.startsWith('CS|')) {
13 | return payload.sub.slice(3)
14 | }
15 |
16 | return payload?.sub
17 | }
18 |
19 | export const CS_COOKIE_NAME = '__cipherstash_cts_session'
20 |
21 | export type CtsToken = {
22 | accessToken: string
23 | expiry: number
24 | }
25 |
26 | export type GetCtsTokenResponse = Promise<
27 | | {
28 | success: boolean
29 | error: string
30 | ctsToken?: never
31 | }
32 | | {
33 | success: boolean
34 | error?: never
35 | ctsToken: CtsToken
36 | }
37 | >
38 |
39 | export const getCtsToken = async (oidcToken?: string): GetCtsTokenResponse => {
40 | const cookieStore = await cookies()
41 | const cookieData = cookieStore.get(CS_COOKIE_NAME)?.value
42 |
43 | if (oidcToken && !cookieData) {
44 | logger.debug(
45 | 'The CipherStash session cookie was not found in the request, but a JWT token was provided. The JWT token will be used to fetch a new CipherStash session.',
46 | )
47 |
48 | return await fetchCtsToken(oidcToken)
49 | }
50 |
51 | if (!cookieData) {
52 | logger.debug('No CipherStash session cookie found in the request.')
53 | return {
54 | success: false,
55 | error: 'No CipherStash session cookie found in the request.',
56 | }
57 | }
58 |
59 | const cts_token = JSON.parse(cookieData) as CtsToken
60 | return {
61 | success: true,
62 | ctsToken: cts_token,
63 | }
64 | }
65 |
66 | export const resetCtsToken = (res?: NextResponse) => {
67 | if (res) {
68 | res.cookies.delete(CS_COOKIE_NAME)
69 | return res
70 | }
71 |
72 | const response = NextResponse.next()
73 | response.cookies.delete(CS_COOKIE_NAME)
74 | return response
75 | }
76 |
77 | export const protectMiddleware = async (
78 | oidcToken: string,
79 | req: NextRequest,
80 | res?: NextResponse,
81 | ) => {
82 | const ctsSession = req.cookies.get(CS_COOKIE_NAME)?.value
83 |
84 | if (oidcToken && ctsSession) {
85 | const ctsToken = JSON.parse(ctsSession) as CtsToken
86 | const ctsTokenSubject = getSubjectFromToken(ctsToken.accessToken)
87 | const oidcTokenSubject = getSubjectFromToken(oidcToken)
88 |
89 | if (ctsTokenSubject === oidcTokenSubject) {
90 | logger.debug(
91 | 'The JWT token and the CipherStash session are both valid for the same user.',
92 | )
93 |
94 | return res ?? NextResponse.next()
95 | }
96 |
97 | return await setCtsToken(oidcToken, res)
98 | }
99 |
100 | if (oidcToken && !ctsSession) {
101 | logger.debug(
102 | 'The JWT token was defined, so the CipherStash session will be set.',
103 | )
104 |
105 | return await setCtsToken(oidcToken, res)
106 | }
107 |
108 | if (!oidcToken && ctsSession) {
109 | logger.debug(
110 | 'The JWT token was undefined, so the CipherStash session was reset.',
111 | )
112 |
113 | return resetCtsToken(res)
114 | }
115 |
116 | logger.debug(
117 | 'The JWT token was undefined, so the CipherStash session was not set.',
118 | )
119 |
120 | if (res) {
121 | return res
122 | }
123 |
124 | return NextResponse.next()
125 | }
126 |
--------------------------------------------------------------------------------
/packages/nextjs/tsconfig.jest.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "esModuleInterop": true,
4 | "moduleResolution": "node10",
5 | "verbatimModuleSyntax": false
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/packages/nextjs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Enable latest features
4 | "lib": ["ESNext", "DOM"],
5 | "target": "ESNext",
6 | "module": "ESNext",
7 | "moduleDetection": "force",
8 | "jsx": "react-jsx",
9 | "allowJs": true,
10 | "esModuleInterop": true,
11 |
12 | // Bundler mode
13 | "moduleResolution": "bundler",
14 | "allowImportingTsExtensions": true,
15 | "verbatimModuleSyntax": true,
16 | "noEmit": true,
17 |
18 | // Best practices
19 | "strict": true,
20 | "skipLibCheck": true,
21 | "noFallthroughCasesInSwitch": true,
22 |
23 | // Some stricter flags (disabled by default)
24 | "noUnusedLocals": false,
25 | "noUnusedParameters": false,
26 | "noPropertyAccessFromIndexSignature": false
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/nextjs/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup'
2 |
3 | export default defineConfig({
4 | entry: ['src/index.ts', 'src/clerk/index.ts'],
5 | format: ['cjs', 'esm'],
6 | sourcemap: true,
7 | dts: true,
8 | })
9 |
--------------------------------------------------------------------------------
/packages/protect-dynamodb/.npmignore:
--------------------------------------------------------------------------------
1 | .env
2 | .turbo
3 | node_modules
4 | cipherstash.secret.toml
5 | cipherstash.toml
--------------------------------------------------------------------------------
/packages/protect-dynamodb/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @cipherstash/protect-dynamodb
2 |
3 | ## 0.2.0
4 |
5 | ### Minor Changes
6 |
7 | - 5fc0150: Fix build and publish.
8 |
9 | ## 1.0.0
10 |
11 | ### Minor Changes
12 |
13 | - c8468ee: Released initial version of the DynamoDB helper interface.
14 |
15 | ### Patch Changes
16 |
17 | - Updated dependencies [c8468ee]
18 | - @cipherstash/protect@9.1.0
19 |
--------------------------------------------------------------------------------
/packages/protect-dynamodb/__tests__/dynamodb.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 |
3 | describe('protect dynamodb helpers', () => {
4 | it('should say hello', () => {
5 | expect(true).toBe(true)
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/packages/protect-dynamodb/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cipherstash/protect-dynamodb",
3 | "version": "0.2.0",
4 | "description": "Protect.js DynamoDB Helpers",
5 | "keywords": [
6 | "dynamodb",
7 | "cipherstash",
8 | "protect",
9 | "encrypt",
10 | "decrypt",
11 | "security"
12 | ],
13 | "bugs": {
14 | "url": "https://github.com/cipherstash/protectjs/issues"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/cipherstash/protectjs.git"
19 | },
20 | "license": "MIT",
21 | "author": "CipherStash ",
22 | "type": "module",
23 | "exports": {
24 | ".": {
25 | "types": "./dist/index.d.ts",
26 | "import": "./dist/index.js",
27 | "require": "./dist/index.cjs"
28 | }
29 | },
30 | "scripts": {
31 | "build": "tsup",
32 | "dev": "tsup --watch",
33 | "test": "vitest run",
34 | "release": "tsup"
35 | },
36 | "devDependencies": {
37 | "@cipherstash/protect": "workspace:*",
38 | "dotenv": "^16.4.7",
39 | "tsup": "catalog:repo",
40 | "tsx": "catalog:repo",
41 | "typescript": "catalog:repo",
42 | "vitest": "catalog:repo"
43 | },
44 | "publishConfig": {
45 | "access": "public"
46 | },
47 | "dependencies": {
48 | "@byteslice/result": "^0.2.0"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/packages/protect-dynamodb/src/types.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | ProtectClient,
3 | Decrypted,
4 | ProtectTable,
5 | ProtectTableColumn,
6 | EncryptedPayload,
7 | SearchTerm,
8 | } from '@cipherstash/protect'
9 | import type { Result } from '@byteslice/result'
10 |
11 | export interface ProtectDynamoDBConfig {
12 | protectClient: ProtectClient
13 | options?: {
14 | logger?: {
15 | error: (message: string, error: Error) => void
16 | }
17 | errorHandler?: (error: ProtectDynamoDBError) => void
18 | }
19 | }
20 |
21 | export interface ProtectDynamoDBError extends Error {
22 | code: string
23 | details?: Record
24 | }
25 |
26 | export interface ProtectDynamoDBInstance {
27 | encryptModel>(
28 | item: T,
29 | protectTable: ProtectTable,
30 | ): Promise, ProtectDynamoDBError>>
31 |
32 | bulkEncryptModels>(
33 | items: T[],
34 | protectTable: ProtectTable,
35 | ): Promise[], ProtectDynamoDBError>>
36 |
37 | decryptModel>(
38 | item: Record,
39 | protectTable: ProtectTable,
40 | ): Promise, ProtectDynamoDBError>>
41 |
42 | bulkDecryptModels>(
43 | items: Record[],
44 | protectTable: ProtectTable,
45 | ): Promise[], ProtectDynamoDBError>>
46 |
47 | createSearchTerms(
48 | terms: SearchTerm[],
49 | ): Promise>
50 | }
51 |
--------------------------------------------------------------------------------
/packages/protect-dynamodb/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Enable latest features
4 | "lib": ["ESNext", "DOM"],
5 | "target": "ESNext",
6 | "module": "ESNext",
7 | "moduleDetection": "force",
8 | "jsx": "react-jsx",
9 | "allowJs": true,
10 | "esModuleInterop": true,
11 |
12 | // Bundler mode
13 | "moduleResolution": "bundler",
14 | "allowImportingTsExtensions": true,
15 | "verbatimModuleSyntax": true,
16 | "noEmit": true,
17 |
18 | // Best practices
19 | "strict": true,
20 | "skipLibCheck": true,
21 | "noFallthroughCasesInSwitch": true,
22 |
23 | // Some stricter flags (disabled by default)
24 | "noUnusedLocals": false,
25 | "noUnusedParameters": false,
26 | "noPropertyAccessFromIndexSignature": false
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/protect-dynamodb/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup'
2 |
3 | export default defineConfig({
4 | entry: ['src/index.ts'],
5 | format: ['cjs', 'esm'],
6 | sourcemap: true,
7 | dts: true,
8 | })
9 |
--------------------------------------------------------------------------------
/packages/protect/.npmignore:
--------------------------------------------------------------------------------
1 | .env
2 | .turbo
3 | node_modules
4 | cipherstash.secret.toml
5 | cipherstash.toml
--------------------------------------------------------------------------------
/packages/protect/__tests__/helpers.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | encryptedToPgComposite,
3 | modelToEncryptedPgComposites,
4 | bulkModelsToEncryptedPgComposites,
5 | isEncryptedPayload,
6 | } from '../src/helpers'
7 | import { describe, expect, it } from 'vitest'
8 |
9 | describe('helpers', () => {
10 | describe('encryptedToPgComposite', () => {
11 | it('should convert encrypted payload to pg composite', () => {
12 | const encrypted = {
13 | v: 1,
14 | c: 'ciphertext',
15 | i: {
16 | c: 'iv',
17 | t: 't',
18 | },
19 | k: 'k',
20 | ob: ['a', 'b'],
21 | bf: [1, 2, 3],
22 | hm: 'hm',
23 | }
24 |
25 | const pgComposite = encryptedToPgComposite(encrypted)
26 | expect(pgComposite).toEqual({
27 | data: encrypted,
28 | })
29 | })
30 | })
31 |
32 | describe('isEncryptedPayload', () => {
33 | it('should return true for valid encrypted payload', () => {
34 | const encrypted = {
35 | v: 1,
36 | c: 'ciphertext',
37 | i: { c: 'iv', t: 't' },
38 | }
39 | expect(isEncryptedPayload(encrypted)).toBe(true)
40 | })
41 |
42 | it('should return false for null', () => {
43 | expect(isEncryptedPayload(null)).toBe(false)
44 | })
45 |
46 | it('should return false for non-encrypted object', () => {
47 | expect(isEncryptedPayload({ foo: 'bar' })).toBe(false)
48 | })
49 | })
50 |
51 | describe('modelToEncryptedPgComposites', () => {
52 | it('should transform model with encrypted fields', () => {
53 | const model = {
54 | name: 'John',
55 | email: {
56 | v: 1,
57 | c: 'encrypted_email',
58 | i: { c: 'iv', t: 't' },
59 | },
60 | age: 30,
61 | }
62 |
63 | const result = modelToEncryptedPgComposites(model)
64 | expect(result).toEqual({
65 | name: 'John',
66 | email: {
67 | data: {
68 | v: 1,
69 | c: 'encrypted_email',
70 | i: { c: 'iv', t: 't' },
71 | },
72 | },
73 | age: 30,
74 | })
75 | })
76 | })
77 |
78 | describe('bulkModelsToEncryptedPgComposites', () => {
79 | it('should transform multiple models with encrypted fields', () => {
80 | const models = [
81 | {
82 | name: 'John',
83 | email: {
84 | v: 1,
85 | c: 'encrypted_email1',
86 | i: { c: 'iv', t: 't' },
87 | },
88 | },
89 | {
90 | name: 'Jane',
91 | email: {
92 | v: 1,
93 | c: 'encrypted_email2',
94 | i: { c: 'iv', t: 't' },
95 | },
96 | },
97 | ]
98 |
99 | const result = bulkModelsToEncryptedPgComposites(models)
100 | expect(result).toEqual([
101 | {
102 | name: 'John',
103 | email: {
104 | data: {
105 | v: 1,
106 | c: 'encrypted_email1',
107 | i: { c: 'iv', t: 't' },
108 | },
109 | },
110 | },
111 | {
112 | name: 'Jane',
113 | email: {
114 | data: {
115 | v: 1,
116 | c: 'encrypted_email2',
117 | i: { c: 'iv', t: 't' },
118 | },
119 | },
120 | },
121 | ])
122 | })
123 | })
124 | })
125 |
--------------------------------------------------------------------------------
/packages/protect/__tests__/search-terms.test.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import { describe, expect, it } from 'vitest'
3 |
4 | import { protect, csTable, csColumn, type SearchTerm } 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/protect/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cipherstash/protect",
3 | "version": "9.1.0",
4 | "description": "CipherStash Protect for JavaScript",
5 | "keywords": [
6 | "encrypted",
7 | "query",
8 | "language",
9 | "typescript",
10 | "ts",
11 | "protect"
12 | ],
13 | "bugs": {
14 | "url": "https://github.com/cipherstash/protectjs/issues"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/cipherstash/protectjs.git"
19 | },
20 | "license": "MIT",
21 | "author": "CipherStash ",
22 | "type": "module",
23 | "exports": {
24 | ".": {
25 | "types": "./dist/index.d.ts",
26 | "import": "./dist/index.js",
27 | "require": "./dist/index.cjs"
28 | },
29 | "./identify": {
30 | "types": "./dist/identify/index.d.ts",
31 | "import": "./dist/identify/index.js",
32 | "require": "./dist/identify/index.cjs"
33 | }
34 | },
35 | "scripts": {
36 | "build": "tsup",
37 | "dev": "tsup --watch",
38 | "test": "vitest run",
39 | "release": "tsup"
40 | },
41 | "devDependencies": {
42 | "@supabase/supabase-js": "^2.47.10",
43 | "dotenv": "^16.4.7",
44 | "execa": "^9.5.2",
45 | "json-schema-to-typescript": "^15.0.2",
46 | "tsup": "catalog:repo",
47 | "tsx": "catalog:repo",
48 | "typescript": "catalog:repo",
49 | "vitest": "catalog:repo"
50 | },
51 | "publishConfig": {
52 | "access": "public"
53 | },
54 | "dependencies": {
55 | "@byteslice/result": "^0.2.0",
56 | "@cipherstash/protect-ffi": "0.15.0",
57 | "zod": "^3.24.2"
58 | },
59 | "optionalDependencies": {
60 | "@rollup/rollup-linux-x64-gnu": "4.24.0"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/packages/protect/src/ffi/operations/bulk-decrypt-models.ts:
--------------------------------------------------------------------------------
1 | import { withResult, type Result } from '@byteslice/result'
2 | import { noClientError } from '../index'
3 | import { type ProtectError, ProtectErrorTypes } from '../..'
4 | import { logger } from '../../../../utils/logger'
5 | import type { LockContext } from '../../identify'
6 | import type { Client, Decrypted } from '../../types'
7 | import {
8 | bulkDecryptModels,
9 | bulkDecryptModelsWithLockContext,
10 | } from '../model-helpers'
11 |
12 | export class BulkDecryptModelsOperation>
13 | implements PromiseLike>, ProtectError>>
14 | {
15 | private client: Client
16 | private models: Array
17 |
18 | constructor(client: Client, models: Array) {
19 | this.client = client
20 | this.models = models
21 | }
22 |
23 | public withLockContext(
24 | lockContext: LockContext,
25 | ): BulkDecryptModelsOperationWithLockContext {
26 | return new BulkDecryptModelsOperationWithLockContext(this, lockContext)
27 | }
28 |
29 | public then<
30 | TResult1 = Result>, ProtectError>,
31 | TResult2 = never,
32 | >(
33 | onfulfilled?:
34 | | ((
35 | value: Result>, ProtectError>,
36 | ) => TResult1 | PromiseLike)
37 | | null,
38 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
39 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
40 | ): Promise {
41 | return this.execute().then(onfulfilled, onrejected)
42 | }
43 |
44 | private async execute(): Promise>, ProtectError>> {
45 | logger.debug('Bulk decrypting models WITHOUT a lock context')
46 |
47 | return await withResult(
48 | async () => {
49 | if (!this.client) {
50 | throw noClientError()
51 | }
52 |
53 | if (!this.models || this.models.length === 0) {
54 | return []
55 | }
56 |
57 | return await bulkDecryptModels(this.models, this.client)
58 | },
59 | (error) => ({
60 | type: ProtectErrorTypes.DecryptionError,
61 | message: error.message,
62 | }),
63 | )
64 | }
65 |
66 | public getOperation(): {
67 | client: Client
68 | models: Array
69 | } {
70 | return {
71 | client: this.client,
72 | models: this.models,
73 | }
74 | }
75 | }
76 |
77 | export class BulkDecryptModelsOperationWithLockContext<
78 | T extends Record,
79 | > implements PromiseLike>, ProtectError>>
80 | {
81 | private operation: BulkDecryptModelsOperation
82 | private lockContext: LockContext
83 |
84 | constructor(
85 | operation: BulkDecryptModelsOperation,
86 | lockContext: LockContext,
87 | ) {
88 | this.operation = operation
89 | this.lockContext = lockContext
90 | }
91 |
92 | public then<
93 | TResult1 = Result>, ProtectError>,
94 | TResult2 = never,
95 | >(
96 | onfulfilled?:
97 | | ((
98 | value: Result>, ProtectError>,
99 | ) => TResult1 | PromiseLike)
100 | | null,
101 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
102 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
103 | ): Promise {
104 | return this.execute().then(onfulfilled, onrejected)
105 | }
106 |
107 | private async execute(): Promise>, ProtectError>> {
108 | return await withResult(
109 | async () => {
110 | const { client, models } = this.operation.getOperation()
111 |
112 | logger.debug('Bulk decrypting models WITH a lock context')
113 |
114 | if (!client) {
115 | throw noClientError()
116 | }
117 |
118 | if (!models || models.length === 0) {
119 | return []
120 | }
121 |
122 | const context = await this.lockContext.getLockContext()
123 |
124 | if (context.failure) {
125 | throw new Error(`[protect]: ${context.failure.message}`)
126 | }
127 |
128 | return await bulkDecryptModelsWithLockContext(
129 | models,
130 | client,
131 | context.data,
132 | )
133 | },
134 | (error) => ({
135 | type: ProtectErrorTypes.DecryptionError,
136 | message: error.message,
137 | }),
138 | )
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/packages/protect/src/ffi/operations/bulk-encrypt-models.ts:
--------------------------------------------------------------------------------
1 | import { withResult, type Result } from '@byteslice/result'
2 | import { noClientError } from '../index'
3 | import { type ProtectError, ProtectErrorTypes } from '../..'
4 | import { logger } from '../../../../utils/logger'
5 | import type { LockContext } from '../../identify'
6 | import type { Client, Decrypted } from '../../types'
7 | import {
8 | bulkEncryptModels,
9 | bulkEncryptModelsWithLockContext,
10 | } from '../model-helpers'
11 | import type { ProtectTable, ProtectTableColumn } from '../../schema'
12 |
13 | export class BulkEncryptModelsOperation>
14 | implements PromiseLike, ProtectError>>
15 | {
16 | private client: Client
17 | private models: Array>
18 | private table: ProtectTable
19 |
20 | constructor(
21 | client: Client,
22 | models: Array>,
23 | table: ProtectTable,
24 | ) {
25 | this.client = client
26 | this.models = models
27 | this.table = table
28 | }
29 |
30 | public withLockContext(
31 | lockContext: LockContext,
32 | ): BulkEncryptModelsOperationWithLockContext {
33 | return new BulkEncryptModelsOperationWithLockContext(this, lockContext)
34 | }
35 |
36 | public then, ProtectError>, TResult2 = never>(
37 | onfulfilled?:
38 | | ((
39 | value: Result, ProtectError>,
40 | ) => TResult1 | PromiseLike)
41 | | null,
42 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
43 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
44 | ): Promise {
45 | return this.execute().then(onfulfilled, onrejected)
46 | }
47 |
48 | private async execute(): Promise, ProtectError>> {
49 | logger.debug('Bulk encrypting models WITHOUT a lock context', {
50 | table: this.table.tableName,
51 | })
52 |
53 | return await withResult(
54 | async () => {
55 | if (!this.client) {
56 | throw noClientError()
57 | }
58 |
59 | if (!this.models || this.models.length === 0) {
60 | return []
61 | }
62 |
63 | return await bulkEncryptModels(this.models, this.table, this.client)
64 | },
65 | (error) => ({
66 | type: ProtectErrorTypes.EncryptionError,
67 | message: error.message,
68 | }),
69 | )
70 | }
71 |
72 | public getOperation(): {
73 | client: Client
74 | models: Array>
75 | table: ProtectTable
76 | } {
77 | return {
78 | client: this.client,
79 | models: this.models,
80 | table: this.table,
81 | }
82 | }
83 | }
84 |
85 | export class BulkEncryptModelsOperationWithLockContext<
86 | T extends Record,
87 | > implements PromiseLike, ProtectError>>
88 | {
89 | private operation: BulkEncryptModelsOperation
90 | private lockContext: LockContext
91 |
92 | constructor(
93 | operation: BulkEncryptModelsOperation,
94 | lockContext: LockContext,
95 | ) {
96 | this.operation = operation
97 | this.lockContext = lockContext
98 | }
99 |
100 | public then, ProtectError>, TResult2 = never>(
101 | onfulfilled?:
102 | | ((
103 | value: Result, ProtectError>,
104 | ) => TResult1 | PromiseLike)
105 | | null,
106 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
107 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
108 | ): Promise {
109 | return this.execute().then(onfulfilled, onrejected)
110 | }
111 |
112 | private async execute(): Promise, ProtectError>> {
113 | return await withResult(
114 | async () => {
115 | const { client, models, table } = this.operation.getOperation()
116 |
117 | logger.debug('Bulk encrypting models WITH a lock context', {
118 | table: table.tableName,
119 | })
120 |
121 | if (!client) {
122 | throw noClientError()
123 | }
124 |
125 | if (!models || models.length === 0) {
126 | return []
127 | }
128 |
129 | const context = await this.lockContext.getLockContext()
130 |
131 | if (context.failure) {
132 | throw new Error(`[protect]: ${context.failure.message}`)
133 | }
134 |
135 | return await bulkEncryptModelsWithLockContext(
136 | models,
137 | table,
138 | client,
139 | context.data,
140 | )
141 | },
142 | (error) => ({
143 | type: ProtectErrorTypes.EncryptionError,
144 | message: error.message,
145 | }),
146 | )
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/packages/protect/src/ffi/operations/decrypt-model.ts:
--------------------------------------------------------------------------------
1 | import { withResult, type Result } from '@byteslice/result'
2 | import { noClientError } from '../index'
3 | import { type ProtectError, ProtectErrorTypes } from '../..'
4 | import { logger } from '../../../../utils/logger'
5 | import type { LockContext } from '../../identify'
6 | import type { Client, Decrypted } from '../../types'
7 | import {
8 | decryptModelFields,
9 | decryptModelFieldsWithLockContext,
10 | } from '../model-helpers'
11 |
12 | export class DecryptModelOperation>
13 | implements PromiseLike, ProtectError>>
14 | {
15 | private client: Client
16 | private model: T
17 |
18 | constructor(client: Client, model: T) {
19 | this.client = client
20 | this.model = model
21 | }
22 |
23 | public withLockContext(
24 | lockContext: LockContext,
25 | ): DecryptModelOperationWithLockContext {
26 | return new DecryptModelOperationWithLockContext(this, lockContext)
27 | }
28 |
29 | public then, ProtectError>, TResult2 = never>(
30 | onfulfilled?:
31 | | ((
32 | value: Result, ProtectError>,
33 | ) => TResult1 | PromiseLike)
34 | | null,
35 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
36 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
37 | ): Promise {
38 | return this.execute().then(onfulfilled, onrejected)
39 | }
40 |
41 | private async execute(): Promise, ProtectError>> {
42 | logger.debug('Decrypting model WITHOUT a lock context')
43 |
44 | return await withResult(
45 | async () => {
46 | if (!this.client) {
47 | throw noClientError()
48 | }
49 |
50 | return await decryptModelFields(this.model, this.client)
51 | },
52 | (error) => ({
53 | type: ProtectErrorTypes.DecryptionError,
54 | message: error.message,
55 | }),
56 | )
57 | }
58 |
59 | public getOperation(): {
60 | client: Client
61 | model: T
62 | } {
63 | return {
64 | client: this.client,
65 | model: this.model,
66 | }
67 | }
68 | }
69 |
70 | export class DecryptModelOperationWithLockContext<
71 | T extends Record,
72 | > implements PromiseLike, ProtectError>>
73 | {
74 | private operation: DecryptModelOperation
75 | private lockContext: LockContext
76 |
77 | constructor(operation: DecryptModelOperation, lockContext: LockContext) {
78 | this.operation = operation
79 | this.lockContext = lockContext
80 | }
81 |
82 | public then, ProtectError>, TResult2 = never>(
83 | onfulfilled?:
84 | | ((
85 | value: Result, ProtectError>,
86 | ) => TResult1 | PromiseLike)
87 | | null,
88 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
89 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
90 | ): Promise {
91 | return this.execute().then(onfulfilled, onrejected)
92 | }
93 |
94 | private async execute(): Promise, ProtectError>> {
95 | return await withResult(
96 | async () => {
97 | const { client, model } = this.operation.getOperation()
98 |
99 | logger.debug('Decrypting model WITH a lock context')
100 |
101 | if (!client) {
102 | throw noClientError()
103 | }
104 |
105 | const context = await this.lockContext.getLockContext()
106 |
107 | if (context.failure) {
108 | throw new Error(`[protect]: ${context.failure.message}`)
109 | }
110 |
111 | return await decryptModelFieldsWithLockContext(
112 | model,
113 | client,
114 | context.data,
115 | )
116 | },
117 | (error) => ({
118 | type: ProtectErrorTypes.DecryptionError,
119 | message: error.message,
120 | }),
121 | )
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/packages/protect/src/ffi/operations/decrypt.ts:
--------------------------------------------------------------------------------
1 | import { decrypt as ffiDecrypt } from '@cipherstash/protect-ffi'
2 | import { withResult, type Result } from '@byteslice/result'
3 | import { noClientError } from '../index'
4 | import { type ProtectError, ProtectErrorTypes } from '../..'
5 | import { logger } from '../../../../utils/logger'
6 | import type { LockContext } from '../../identify'
7 | import type { Client, EncryptedPayload } from '../../types'
8 |
9 | export class DecryptOperation
10 | implements PromiseLike>
11 | {
12 | private client: Client
13 | private encryptedData: EncryptedPayload
14 |
15 | constructor(client: Client, encryptedData: EncryptedPayload) {
16 | this.client = client
17 | this.encryptedData = encryptedData
18 | }
19 |
20 | public withLockContext(
21 | lockContext: LockContext,
22 | ): DecryptOperationWithLockContext {
23 | return new DecryptOperationWithLockContext(this, lockContext)
24 | }
25 |
26 | public then, TResult2 = never>(
27 | onfulfilled?:
28 | | ((
29 | value: Result,
30 | ) => TResult1 | PromiseLike)
31 | | null,
32 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
33 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
34 | ): Promise {
35 | return this.execute().then(onfulfilled, onrejected)
36 | }
37 |
38 | private async execute(): Promise> {
39 | return await withResult(
40 | async () => {
41 | if (!this.client) {
42 | throw noClientError()
43 | }
44 |
45 | if (this.encryptedData === null) {
46 | return null
47 | }
48 |
49 | logger.debug('Decrypting data WITHOUT a lock context')
50 | return await ffiDecrypt(this.client, this.encryptedData.c)
51 | },
52 | (error) => ({
53 | type: ProtectErrorTypes.DecryptionError,
54 | message: error.message,
55 | }),
56 | )
57 | }
58 |
59 | public getOperation(): {
60 | client: Client
61 | encryptedData: EncryptedPayload
62 | } {
63 | return {
64 | client: this.client,
65 | encryptedData: this.encryptedData,
66 | }
67 | }
68 | }
69 |
70 | export class DecryptOperationWithLockContext
71 | implements PromiseLike>
72 | {
73 | private operation: DecryptOperation
74 | private lockContext: LockContext
75 |
76 | constructor(operation: DecryptOperation, lockContext: LockContext) {
77 | this.operation = operation
78 | this.lockContext = lockContext
79 | }
80 |
81 | public then, TResult2 = never>(
82 | onfulfilled?:
83 | | ((
84 | value: Result,
85 | ) => TResult1 | PromiseLike)
86 | | null,
87 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
88 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
89 | ): Promise {
90 | return this.execute().then(onfulfilled, onrejected)
91 | }
92 |
93 | private async execute(): Promise> {
94 | return await withResult(
95 | async () => {
96 | const { client, encryptedData } = this.operation.getOperation()
97 |
98 | if (!client) {
99 | throw noClientError()
100 | }
101 |
102 | if (encryptedData === null) {
103 | return null
104 | }
105 |
106 | logger.debug('Decrypting data WITH a lock context')
107 |
108 | const context = await this.lockContext.getLockContext()
109 |
110 | if (context.failure) {
111 | throw new Error(`[protect]: ${context.failure.message}`)
112 | }
113 |
114 | return await ffiDecrypt(
115 | client,
116 | encryptedData.c,
117 | context.data.context,
118 | context.data.ctsToken,
119 | )
120 | },
121 | (error) => ({
122 | type: ProtectErrorTypes.DecryptionError,
123 | message: error.message,
124 | }),
125 | )
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/packages/protect/src/ffi/operations/encrypt-model.ts:
--------------------------------------------------------------------------------
1 | import { withResult, type Result } from '@byteslice/result'
2 | import { noClientError } from '../index'
3 | import { type ProtectError, ProtectErrorTypes } from '../..'
4 | import { logger } from '../../../../utils/logger'
5 | import type { LockContext } from '../../identify'
6 | import type { Client, Decrypted } from '../../types'
7 | import {
8 | encryptModelFields,
9 | encryptModelFieldsWithLockContext,
10 | } from '../model-helpers'
11 | import type { ProtectTable, ProtectTableColumn } from '../../schema'
12 |
13 | export class EncryptModelOperation>
14 | implements PromiseLike>
15 | {
16 | private client: Client
17 | private model: Decrypted
18 | private table: ProtectTable
19 |
20 | constructor(
21 | client: Client,
22 | model: Decrypted,
23 | table: ProtectTable,
24 | ) {
25 | this.client = client
26 | this.model = model
27 | this.table = table
28 | }
29 |
30 | public withLockContext(
31 | lockContext: LockContext,
32 | ): EncryptModelOperationWithLockContext {
33 | return new EncryptModelOperationWithLockContext(this, lockContext)
34 | }
35 |
36 | /** Implement the PromiseLike interface so `await` works. */
37 | public then, TResult2 = never>(
38 | onfulfilled?:
39 | | ((value: Result) => TResult1 | PromiseLike)
40 | | null,
41 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
42 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
43 | ): Promise {
44 | return this.execute().then(onfulfilled, onrejected)
45 | }
46 |
47 | /** Actual encryption logic, deferred until `then()` is called. */
48 | private async execute(): Promise> {
49 | logger.debug('Encrypting model WITHOUT a lock context', {
50 | table: this.table.tableName,
51 | })
52 |
53 | return await withResult(
54 | async () => {
55 | if (!this.client) {
56 | throw noClientError()
57 | }
58 |
59 | return await encryptModelFields(this.model, this.table, this.client)
60 | },
61 | (error) => ({
62 | type: ProtectErrorTypes.EncryptionError,
63 | message: error.message,
64 | }),
65 | )
66 | }
67 |
68 | public getOperation(): {
69 | client: Client
70 | model: Decrypted
71 | table: ProtectTable
72 | } {
73 | return {
74 | client: this.client,
75 | model: this.model,
76 | table: this.table,
77 | }
78 | }
79 | }
80 |
81 | export class EncryptModelOperationWithLockContext<
82 | T extends Record,
83 | > implements PromiseLike>
84 | {
85 | private operation: EncryptModelOperation
86 | private lockContext: LockContext
87 |
88 | constructor(operation: EncryptModelOperation, lockContext: LockContext) {
89 | this.operation = operation
90 | this.lockContext = lockContext
91 | }
92 |
93 | public then, TResult2 = never>(
94 | onfulfilled?:
95 | | ((value: Result) => TResult1 | PromiseLike)
96 | | null,
97 | // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
98 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null,
99 | ): Promise {
100 | return this.execute().then(onfulfilled, onrejected)
101 | }
102 |
103 | private async execute(): Promise> {
104 | return await withResult(
105 | async () => {
106 | const { client, model, table } = this.operation.getOperation()
107 |
108 | logger.debug('Encrypting model WITH a lock context', {
109 | table: table.tableName,
110 | })
111 |
112 | if (!client) {
113 | throw noClientError()
114 | }
115 |
116 | const context = await this.lockContext.getLockContext()
117 |
118 | if (context.failure) {
119 | throw new Error(`[protect]: ${context.failure.message}`)
120 | }
121 |
122 | return await encryptModelFieldsWithLockContext(
123 | model,
124 | table,
125 | client,
126 | context.data,
127 | )
128 | },
129 | (error) => ({
130 | type: ProtectErrorTypes.EncryptionError,
131 | message: error.message,
132 | }),
133 | )
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/packages/protect/src/ffi/operations/search-terms.ts:
--------------------------------------------------------------------------------
1 | import { encryptBulk } from '@cipherstash/protect-ffi'
2 | import { withResult, type Result } from '@byteslice/result'
3 | import { noClientError } from '../index'
4 | import { type ProtectError, ProtectErrorTypes } from '../..'
5 | import { logger } from '../../../../utils/logger'
6 | import type { Client, EncryptedSearchTerm, SearchTerm } from '../../types'
7 |
8 | export class SearchTermsOperation {
9 | private client: Client
10 | private terms: SearchTerm[]
11 |
12 | constructor(client: Client, terms: SearchTerm[]) {
13 | this.client = client
14 | this.terms = terms
15 | }
16 |
17 | public async execute(): Promise> {
18 | logger.debug('Creating search terms', {
19 | terms: this.terms,
20 | })
21 |
22 | return await withResult(
23 | async () => {
24 | if (!this.client) {
25 | throw noClientError()
26 | }
27 |
28 | const encryptedSearchTerms = await encryptBulk(
29 | this.client,
30 | this.terms.map((term) => ({
31 | plaintext: term.value,
32 | column: term.column.getName(),
33 | table: term.table.tableName,
34 | })),
35 | )
36 |
37 | return this.terms.map((term, index) => {
38 | if (term.returnType === 'composite-literal') {
39 | return `(${JSON.stringify(JSON.stringify(encryptedSearchTerms[index]))})`
40 | }
41 |
42 | if (term.returnType === 'escaped-composite-literal') {
43 | return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encryptedSearchTerms[index]))})`)}`
44 | }
45 |
46 | return encryptedSearchTerms[index]
47 | })
48 | },
49 | (error) => ({
50 | type: ProtectErrorTypes.EncryptionError,
51 | message: error.message,
52 | }),
53 | )
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/packages/protect/src/helpers/index.ts:
--------------------------------------------------------------------------------
1 | import type { Encrypted } from '@cipherstash/protect-ffi'
2 | import type { EncryptedPayload } from '../types'
3 |
4 | export type EncryptedPgComposite = {
5 | data: EncryptedPayload
6 | }
7 |
8 | /**
9 | * Helper function to transform an encrypted payload into a PostgreSQL composite type
10 | */
11 | export function encryptedToPgComposite(
12 | obj: EncryptedPayload,
13 | ): EncryptedPgComposite {
14 | return {
15 | data: obj,
16 | }
17 | }
18 |
19 | /**
20 | * Helper function to transform a model's encrypted fields into PostgreSQL composite types
21 | */
22 | export function modelToEncryptedPgComposites>(
23 | model: T,
24 | ): T {
25 | const result: Record = {}
26 |
27 | for (const [key, value] of Object.entries(model)) {
28 | if (isEncryptedPayload(value)) {
29 | result[key] = encryptedToPgComposite(value)
30 | } else {
31 | result[key] = value
32 | }
33 | }
34 |
35 | return result as T
36 | }
37 |
38 | /**
39 | * Helper function to transform multiple models' encrypted fields into PostgreSQL composite types
40 | */
41 | export function bulkModelsToEncryptedPgComposites<
42 | T extends Record,
43 | >(models: T[]): T[] {
44 | return models.map((model) => modelToEncryptedPgComposites(model))
45 | }
46 |
47 | /**
48 | * Helper function to check if a value is an encrypted payload
49 | */
50 | export function isEncryptedPayload(value: unknown): value is EncryptedPayload {
51 | if (value === null) return false
52 |
53 | // TODO: this can definitely be improved
54 | if (typeof value === 'object') {
55 | const obj = value as Encrypted
56 | return 'v' in obj && 'c' in obj && 'i' in obj
57 | }
58 |
59 | return false
60 | }
61 |
--------------------------------------------------------------------------------
/packages/protect/src/identify/index.ts:
--------------------------------------------------------------------------------
1 | import { withResult, type Result } from '@byteslice/result'
2 | import { type ProtectError, ProtectErrorTypes } from '..'
3 | import { logger } from '../../../utils/logger'
4 | import { loadWorkSpaceId } from '../../../utils/config'
5 |
6 | export type CtsRegions = 'ap-southeast-2'
7 |
8 | export type IdentifyOptions = {
9 | fetchFromCts?: boolean
10 | }
11 |
12 | export type CtsToken = {
13 | accessToken: string
14 | expiry: number
15 | }
16 |
17 | export type Context = {
18 | identityClaim: string[]
19 | }
20 |
21 | export type LockContextOptions = {
22 | context?: Context
23 | ctsToken?: CtsToken
24 | }
25 |
26 | export type GetLockContextResponse = {
27 | ctsToken: CtsToken
28 | context: Context
29 | }
30 |
31 | export class LockContext {
32 | private ctsToken: CtsToken | undefined
33 | private workspaceId: string
34 | private context: Context
35 |
36 | constructor({
37 | context = { identityClaim: ['sub'] },
38 | ctsToken,
39 | }: LockContextOptions = {}) {
40 | const workspaceId = loadWorkSpaceId()
41 |
42 | if (!workspaceId) {
43 | throw new Error(
44 | 'You have not defined a workspace ID in your config file, or the CS_WORKSPACE_ID environment variable.',
45 | )
46 | }
47 |
48 | if (ctsToken) {
49 | this.ctsToken = ctsToken
50 | }
51 |
52 | this.workspaceId = workspaceId
53 | this.context = context
54 | logger.debug('Successfully initialized the EQL lock context.')
55 | }
56 |
57 | async identify(jwtToken: string): Promise> {
58 | const workspaceId = this.workspaceId
59 |
60 | const ctsEndoint =
61 | process.env.CS_CTS_ENDPOINT ||
62 | 'https://ap-southeast-2.aws.auth.viturhosted.net'
63 |
64 | const ctsFetchResult = await withResult(
65 | () =>
66 | fetch(`${ctsEndoint}/api/authorize`, {
67 | method: 'POST',
68 | headers: {
69 | 'Content-Type': 'application/json',
70 | },
71 | body: JSON.stringify({
72 | workspaceId,
73 | oidcToken: jwtToken,
74 | }),
75 | }),
76 | (error) => ({
77 | type: ProtectErrorTypes.CtsTokenError,
78 | message: error.message,
79 | }),
80 | )
81 |
82 | if (ctsFetchResult.failure) {
83 | return ctsFetchResult
84 | }
85 |
86 | const identifiedLockContext = await withResult(
87 | async () => {
88 | const ctsToken = (await ctsFetchResult.data.json()) as CtsToken
89 |
90 | if (!ctsToken.accessToken) {
91 | throw new Error(
92 | 'The response from the CipherStash API did not contain an access token. Please contact support.',
93 | )
94 | }
95 |
96 | this.ctsToken = ctsToken
97 | return this
98 | },
99 | (error) => ({
100 | type: ProtectErrorTypes.CtsTokenError,
101 | message: error.message,
102 | }),
103 | )
104 |
105 | return identifiedLockContext
106 | }
107 |
108 | getLockContext(): Promise> {
109 | return withResult(
110 | () => {
111 | if (!this.ctsToken?.accessToken && !this.ctsToken?.expiry) {
112 | throw new Error(
113 | 'The CTS token is not set. Please call identify() with a users JWT token, or pass an existing CTS token to the LockContext constructor before calling getLockContext().',
114 | )
115 | }
116 |
117 | return {
118 | context: this.context,
119 | ctsToken: this.ctsToken,
120 | }
121 | },
122 | (error) => ({
123 | type: ProtectErrorTypes.CtsTokenError,
124 | message: error.message,
125 | }),
126 | )
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/packages/protect/src/index.ts:
--------------------------------------------------------------------------------
1 | import { ProtectClient } from './ffi'
2 | import type { ProtectTable, ProtectTableColumn } from './schema'
3 | import { buildEncryptConfig } from './schema'
4 |
5 | export const ProtectErrorTypes = {
6 | ClientInitError: 'ClientInitError',
7 | EncryptionError: 'EncryptionError',
8 | DecryptionError: 'DecryptionError',
9 | LockContextError: 'LockContextError',
10 | CtsTokenError: 'CtsTokenError',
11 | }
12 |
13 | export interface ProtectError {
14 | type: (typeof ProtectErrorTypes)[keyof typeof ProtectErrorTypes]
15 | message: string
16 | }
17 |
18 | type AtLeastOneCsTable = [T, ...T[]]
19 |
20 | export type ProtectClientConfig = {
21 | schemas: AtLeastOneCsTable>
22 | workspaceCrn?: string
23 | accessKey?: string
24 | clientId?: string
25 | clientKey?: string
26 | }
27 |
28 | export const protect = async (
29 | config: ProtectClientConfig,
30 | ): Promise => {
31 | const { schemas } = config
32 |
33 | if (!schemas.length) {
34 | throw new Error(
35 | '[protect]: At least one csTable must be provided to initialize the protect client',
36 | )
37 | }
38 |
39 | const clientConfig = {
40 | workspaceCrn: config.workspaceCrn,
41 | accessKey: config.accessKey,
42 | clientId: config.clientId,
43 | clientKey: config.clientKey,
44 | }
45 |
46 | const client = new ProtectClient(clientConfig.workspaceCrn)
47 | const encryptConfig = buildEncryptConfig(...schemas)
48 |
49 | const result = await client.init({
50 | encryptConfig,
51 | ...clientConfig,
52 | })
53 |
54 | if (result.failure) {
55 | throw new Error(`[protect]: ${result.failure.message}`)
56 | }
57 |
58 | return result.data
59 | }
60 |
61 | export type { Result } from '@byteslice/result'
62 | export type { ProtectClient } from './ffi'
63 | export { csTable, csColumn } from './schema'
64 | export type { ProtectColumn, ProtectTable, ProtectTableColumn } from './schema'
65 | export * from './helpers'
66 | export * from './identify'
67 | export * from './types'
68 |
--------------------------------------------------------------------------------
/packages/protect/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { newClient, Encrypted } from '@cipherstash/protect-ffi'
2 | import type { ProtectTableColumn } from './schema'
3 | import type { ProtectTable } from './schema'
4 | import type { ProtectColumn } from './schema'
5 |
6 | /**
7 | * Type to represent the client object
8 | */
9 | export type Client = Awaited> | undefined
10 |
11 | /**
12 | * Represents an encrypted payload in the database
13 | */
14 | export type EncryptedPayload = Encrypted | null
15 |
16 | /**
17 | * Represents a value that will be encrypted and used in a search
18 | */
19 | export type SearchTerm = {
20 | value: string
21 | column: ProtectColumn
22 | table: ProtectTable
23 | returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal'
24 | }
25 |
26 | /**
27 | * The return type of the search term based on the return type specified in the `SearchTerm` type
28 | * If the return type is `eql`, the return type is `EncryptedPayload`
29 | * If the return type is `composite-literal`, the return type is `string` where the value is a composite literal
30 | * If the return type is `escaped-composite-literal`, the return type is `string` where the value is an escaped composite literal
31 | */
32 | export type EncryptedSearchTerm = EncryptedPayload | string
33 |
34 | /**
35 | * Represents a payload to be encrypted using the `encrypt` function
36 | * We currently only support the encryption of strings
37 | */
38 | export type EncryptPayload = string | null
39 |
40 | /**
41 | * Represents the options for encrypting a payload using the `encrypt` function
42 | */
43 | export type EncryptOptions = {
44 | column: ProtectColumn
45 | table: ProtectTable
46 | }
47 |
48 | /**
49 | * Type to identify encrypted fields in a model
50 | */
51 | export type EncryptedFields = {
52 | [K in keyof T as T[K] extends EncryptedPayload ? K : never]: T[K]
53 | }
54 |
55 | /**
56 | * Type to identify non-encrypted fields in a model
57 | */
58 | export type OtherFields = {
59 | [K in keyof T as T[K] extends EncryptedPayload | null ? never : K]: T[K]
60 | }
61 |
62 | /**
63 | * Type to represent decrypted fields in a model
64 | */
65 | export type DecryptedFields = {
66 | [K in keyof T as T[K] extends EncryptedPayload | null ? K : never]: string
67 | }
68 |
69 | /**
70 | * Represents a model with plaintext (decrypted) values instead of the EQL/JSONB types
71 | */
72 | export type Decrypted = OtherFields & DecryptedFields
73 |
--------------------------------------------------------------------------------
/packages/protect/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Enable latest features
4 | "lib": ["ESNext", "DOM"],
5 | "target": "ESNext",
6 | "module": "ESNext",
7 | "moduleDetection": "force",
8 | "jsx": "react-jsx",
9 | "allowJs": true,
10 | "esModuleInterop": true,
11 |
12 | // Bundler mode
13 | "moduleResolution": "bundler",
14 | "allowImportingTsExtensions": true,
15 | "verbatimModuleSyntax": true,
16 | "noEmit": true,
17 |
18 | // Best practices
19 | "strict": true,
20 | "skipLibCheck": true,
21 | "noFallthroughCasesInSwitch": true,
22 |
23 | // Some stricter flags (disabled by default)
24 | "noUnusedLocals": false,
25 | "noUnusedParameters": false,
26 | "noPropertyAccessFromIndexSignature": false
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/protect/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup'
2 |
3 | export default defineConfig({
4 | entry: ['src/index.ts', 'src/identify/index.ts'],
5 | format: ['cjs', 'esm'],
6 | sourcemap: true,
7 | dts: true,
8 | })
9 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/packages/utils/logger/index.ts:
--------------------------------------------------------------------------------
1 | function getLevelValue(level: string): number {
2 | switch (level) {
3 | case 'debug':
4 | return 10
5 | case 'info':
6 | return 20
7 | case 'error':
8 | return 30
9 | default:
10 | return 30 // default to error level
11 | }
12 | }
13 |
14 | const envLogLevel = process.env.PROTECT_LOG_LEVEL || 'info'
15 | const currentLevel = getLevelValue(envLogLevel)
16 |
17 | function debug(...args: unknown[]): void {
18 | if (currentLevel <= getLevelValue('debug')) {
19 | console.debug('[protect] DEBUG', ...args)
20 | }
21 | }
22 |
23 | function info(...args: unknown[]): void {
24 | if (currentLevel <= getLevelValue('info')) {
25 | console.info('[protect] INFO', ...args)
26 | }
27 | }
28 |
29 | function error(...args: unknown[]): void {
30 | if (currentLevel <= getLevelValue('error')) {
31 | console.error('[protect] ERROR', ...args)
32 | }
33 | }
34 |
35 | export const logger = {
36 | debug,
37 | info,
38 | error,
39 | }
40 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "packages/*"
3 | - "examples/*"
4 |
5 | catalogs:
6 | # Can be referened as catalog:repo
7 | repo:
8 | tsup: 8.4.0
9 | typescript: 5.6.3
10 | tsx: 4.19.3
11 | vitest: 3.1.3
12 |
13 | security:
14 | vite: 6.3.5
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Enable latest features
4 | "lib": ["ESNext", "DOM"],
5 | "target": "ESNext",
6 | "module": "ESNext",
7 | "moduleDetection": "force",
8 | "jsx": "react-jsx",
9 | "allowJs": true,
10 |
11 | // Bundler mode
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "verbatimModuleSyntax": true,
15 | "noEmit": true,
16 |
17 | // Best practices
18 | "strict": true,
19 | "skipLibCheck": true,
20 | "noFallthroughCasesInSwitch": true,
21 |
22 | // Some stricter flags (disabled by default)
23 | "noUnusedLocals": false,
24 | "noUnusedParameters": false,
25 | "noPropertyAccessFromIndexSignature": false
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "tasks": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "inputs": ["$TURBO_DEFAULT$", ".env*"]
7 | },
8 | "release": {
9 | "dependsOn": ["^build"],
10 | "inputs": ["$TURBO_DEFAULT$", ".env*"]
11 | },
12 | "dev": {
13 | "cache": false,
14 | "persistent": true
15 | },
16 | "test": {
17 | "dependsOn": ["^build"],
18 | "inputs": ["$TURBO_DEFAULT$", ".env*"],
19 | "cache": false
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
|