Skip to content

Commit fb8541a

Browse files
authored
Merge pull request #314 from cipherstash/stash-forge
feat: add stash forge package
2 parents d3bc40e + bcfeeb8 commit fb8541a

File tree

26 files changed

+1911
-10
lines changed

26 files changed

+1911
-10
lines changed

.changeset/config.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"access": "restricted",
88
"baseBranch": "main",
99
"updateInternalDependencies": "patch",
10-
"ignore": []
10+
"ignore": [],
11+
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
12+
"onlyUpdatePeerDependentsWhenOutOfRange": true
13+
}
1114
}

.changeset/curvy-bushes-mix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cipherstash/stack": minor
3+
---
4+
5+
Exposed a public method on the Encryption client to expose the build Encryption schema.

.changeset/soft-times-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cipherstash/stack-forge": minor
3+
---
4+
5+
Initial release of the `stash-forge` CLI utility.

examples/basic/encrypt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dotenv/config'
2+
23
import { Encryption, encryptedColumn, encryptedTable } from '@cipherstash/stack'
34

45
export const users = encryptedTable('users', {

examples/basic/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
"description": "",
1313
"dependencies": {
1414
"@cipherstash/stack": "workspace:*",
15-
"dotenv": "^16.4.7"
15+
"dotenv": "^16.6.1",
16+
"pg": "8.13.1"
1617
},
1718
"devDependencies": {
19+
"@cipherstash/stack-forge": "workspace:*",
1820
"tsx": "catalog:repo",
1921
"typescript": "catalog:repo"
2022
}

examples/basic/stash.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineConfig } from '@cipherstash/stack-forge'
2+
3+
export default defineConfig({
4+
databaseUrl: process.env.DATABASE_URL!,
5+
client: './encrypt.ts',
6+
})

packages/stack-forge/README.md

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
# @cipherstash/stack-forge
2+
3+
Dev-time CLI and library for managing [CipherStash EQL](https://github.com/cipherstash/encrypt-query-language) (Encrypted Query Language) in your PostgreSQL database.
4+
5+
[![npm version](https://img.shields.io/npm/v/@cipherstash/stack-forge.svg?style=for-the-badge&labelColor=000000)](https://www.npmjs.com/package/@cipherstash/stack-forge)
6+
[![License: MIT](https://img.shields.io/npm/l/@cipherstash/stack-forge.svg?style=for-the-badge&labelColor=000000)](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md)
7+
[![TypeScript](https://img.shields.io/badge/TypeScript-first-blue?style=for-the-badge&labelColor=000000)](https://www.typescriptlang.org/)
8+
9+
---
10+
11+
## Why stack-forge?
12+
13+
`@cipherstash/stack` is the runtime encryption SDK — it should stay lean and free of heavy dependencies like `pg`. `@cipherstash/stack-forge` is a **devDependency** that handles database tooling: installing EQL extensions, checking permissions, and managing schema lifecycle.
14+
15+
Think of it like Prisma or Drizzle Kit — a companion CLI that sets up the database while the main SDK handles runtime operations.
16+
17+
## Install
18+
19+
```bash
20+
npm install -D @cipherstash/stack-forge
21+
```
22+
23+
Or with your preferred package manager:
24+
25+
```bash
26+
pnpm add -D @cipherstash/stack-forge
27+
yarn add -D @cipherstash/stack-forge
28+
bun add -D @cipherstash/stack-forge
29+
```
30+
31+
## Quick Start
32+
33+
You can install EQL in two ways: **direct install** (connects to the DB and runs the SQL) or **Drizzle migration** (generates a migration file; you run `drizzle-kit migrate` yourself). The steps below use the direct install path.
34+
35+
### 1. Create a config file
36+
37+
Create `stash.config.ts` in your project root:
38+
39+
```typescript
40+
import { defineConfig } from '@cipherstash/stack-forge'
41+
42+
export default defineConfig({
43+
databaseUrl: process.env.DATABASE_URL!,
44+
})
45+
```
46+
47+
### 2. Add a `.env` file
48+
49+
```env
50+
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
51+
```
52+
53+
### 3. Install EQL
54+
55+
```bash
56+
npx stash-forge install
57+
```
58+
59+
That's it. EQL is now installed in your database.
60+
61+
If your encryption client lives elsewhere, set `client` in `stash.config.ts` (e.g. `client: './lib/encryption.ts'`). That path is used by `stash-forge push`.
62+
63+
**Using Drizzle?** To install EQL via your migration pipeline instead, run `npx stash-forge install --drizzle`, then `npx drizzle-kit migrate`. See [install --drizzle](#install---drizzle) below.
64+
65+
---
66+
67+
## Configuration
68+
69+
The `stash.config.ts` file is the single source of truth for stack-forge. It uses the `defineConfig` helper for type safety.
70+
71+
```typescript
72+
import { defineConfig } from '@cipherstash/stack-forge'
73+
74+
export default defineConfig({
75+
// Required: PostgreSQL connection string
76+
databaseUrl: process.env.DATABASE_URL!,
77+
78+
// Optional: path to your encryption client (default: './src/encryption/index.ts')
79+
// Used by `stash-forge push` to load the encryption schema
80+
client: './src/encryption/index.ts',
81+
82+
// Optional: CipherStash workspace and credentials (for future schema sync)
83+
workspaceId: process.env.CS_WORKSPACE_ID,
84+
clientAccessKey: process.env.CS_CLIENT_ACCESS_KEY,
85+
})
86+
```
87+
88+
| Option | Required | Description |
89+
|--------|----------|-------------|
90+
| `databaseUrl` | Yes | PostgreSQL connection string |
91+
| `client` | No | Path to encryption client file (default: `'./src/encryption/index.ts'`). Used by `push` to load the encryption schema. |
92+
| `workspaceId` | No | CipherStash workspace ID |
93+
| `clientAccessKey` | No | CipherStash client access key |
94+
95+
The CLI automatically loads `.env` files before evaluating the config, so `process.env` references work out of the box.
96+
97+
The config file is resolved by walking up from the current working directory, similar to how `tsconfig.json` resolution works.
98+
99+
---
100+
101+
## CLI Reference
102+
103+
```
104+
stash-forge <command> [options]
105+
```
106+
107+
### `install`
108+
109+
Install the CipherStash EQL extensions into your database.
110+
111+
```bash
112+
npx stash-forge install [options]
113+
```
114+
115+
| Option | Description |
116+
|--------|-------------|
117+
| `--dry-run` | Show what would happen without making changes |
118+
| `--force` | Reinstall even if EQL is already installed |
119+
| `--supabase` | Use Supabase-compatible install (excludes operator families + grants Supabase roles) |
120+
| `--exclude-operator-family` | Skip operator family creation (for non-superuser database roles) |
121+
| `--drizzle` | Generate a Drizzle migration instead of direct install |
122+
| `--name <value>` | Migration name when using `--drizzle` (default: `install-eql`) |
123+
| `--out <value>` | Drizzle output directory when using `--drizzle` (default: `drizzle`) |
124+
125+
**Standard install:**
126+
127+
```bash
128+
npx stash-forge install
129+
```
130+
131+
**Supabase install:**
132+
133+
```bash
134+
npx stash-forge install --supabase
135+
```
136+
137+
The `--supabase` flag:
138+
- Downloads the Supabase-specific SQL variant (no `CREATE OPERATOR FAMILY`)
139+
- Grants `USAGE`, table, routine, and sequence permissions on the `eql_v2` schema to `anon`, `authenticated`, and `service_role`
140+
141+
**Preview changes first:**
142+
143+
```bash
144+
npx stash-forge install --dry-run
145+
```
146+
147+
#### `install --drizzle`
148+
149+
If you use [Drizzle ORM](https://orm.drizzle.team/) and want EQL installation as part of your migration history, use the `--drizzle` flag. It creates a Drizzle migration file containing the EQL install SQL, then you run your normal Drizzle migrations to apply it.
150+
151+
```bash
152+
npx stash-forge install --drizzle
153+
npx drizzle-kit migrate
154+
```
155+
156+
**How it works:**
157+
158+
1. Runs `drizzle-kit generate --custom --name=<name>` to create an empty migration.
159+
2. Downloads the EQL install script from the [EQL GitHub releases](https://github.com/cipherstash/encrypt-query-language/releases/latest).
160+
3. Writes the EQL SQL into the generated migration file.
161+
162+
With a custom migration name or output directory:
163+
164+
```bash
165+
npx stash-forge install --drizzle --name setup-eql --out ./migrations
166+
npx drizzle-kit migrate
167+
```
168+
169+
You need `drizzle-kit` installed in your project (`npm install -D drizzle-kit`). The `--out` directory must match your Drizzle config (e.g. `drizzle.config.ts`).
170+
171+
### `push`
172+
173+
Load your encryption schema from the file specified by `client` in `stash.config.ts` and apply it to the database (or preview with `--dry-run`).
174+
175+
```bash
176+
npx stash-forge push [options]
177+
```
178+
179+
| Option | Description |
180+
|--------|-------------|
181+
| `--dry-run` | Load and validate the schema, then print it as JSON. No database changes. |
182+
183+
**Push schema to the database:**
184+
185+
```bash
186+
npx stash-forge push
187+
```
188+
189+
This connects to Postgres, marks any existing rows in `eql_v2_configuration` as `inactive`, and inserts the current encrypt config as a new row with state `active`. Your runtime encryption (e.g. `@cipherstash/stack`) reads the active configuration from this table.
190+
191+
**Preview your encryption schema without writing to the database:**
192+
193+
```bash
194+
npx stash-forge push --dry-run
195+
```
196+
197+
### Permission Pre-checks (install)
198+
199+
Before installing, `stash-forge` verifies that the connected database role has the required permissions:
200+
201+
- `CREATE` on the database (for `CREATE SCHEMA` and `CREATE EXTENSION`)
202+
- `CREATE` on the `public` schema (for `CREATE TYPE public.eql_v2_encrypted`)
203+
- `SUPERUSER` or extension owner (for `CREATE EXTENSION pgcrypto`, if not already installed)
204+
205+
If permissions are insufficient, the CLI exits with a clear message listing what's missing.
206+
207+
### Planned Commands
208+
209+
The following commands are defined but not yet implemented:
210+
211+
| Command | Description |
212+
|---------|-------------|
213+
| `init` | Initialize CipherStash Forge in your project |
214+
| `migrate` | Run pending encrypt config migrations |
215+
| `status` | Show EQL installation status |
216+
217+
---
218+
219+
## Programmatic API
220+
221+
You can also use stack-forge as a library:
222+
223+
```typescript
224+
import { EQLInstaller, loadStashConfig } from '@cipherstash/stack-forge'
225+
226+
// Load config from stash.config.ts
227+
const config = await loadStashConfig()
228+
229+
// Create an installer
230+
const installer = new EQLInstaller({
231+
databaseUrl: config.databaseUrl,
232+
})
233+
234+
// Check permissions before installing
235+
const permissions = await installer.checkPermissions()
236+
if (!permissions.ok) {
237+
console.error('Missing permissions:', permissions.missing)
238+
process.exit(1)
239+
}
240+
241+
// Check if already installed
242+
if (await installer.isInstalled()) {
243+
console.log('EQL is already installed')
244+
} else {
245+
await installer.install()
246+
}
247+
```
248+
249+
### `EQLInstaller`
250+
251+
| Method | Returns | Description |
252+
|--------|---------|-------------|
253+
| `checkPermissions()` | `Promise<PermissionCheckResult>` | Check if the database role has required permissions |
254+
| `isInstalled()` | `Promise<boolean>` | Check if the `eql_v2` schema exists |
255+
| `getInstalledVersion()` | `Promise<string \| null>` | Get the installed EQL version (or `null`) |
256+
| `install(options?)` | `Promise<void>` | Download and execute the EQL install SQL in a transaction |
257+
258+
#### Install Options
259+
260+
```typescript
261+
await installer.install({
262+
excludeOperatorFamily: true, // Skip CREATE OPERATOR FAMILY
263+
supabase: true, // Supabase mode (implies excludeOperatorFamily + grants roles)
264+
})
265+
```
266+
267+
### `defineConfig`
268+
269+
Type-safe identity function for `stash.config.ts`:
270+
271+
```typescript
272+
import { defineConfig } from '@cipherstash/stack-forge'
273+
274+
export default defineConfig({
275+
databaseUrl: process.env.DATABASE_URL!,
276+
})
277+
```
278+
279+
### `loadStashConfig`
280+
281+
Finds and loads the nearest `stash.config.ts`, validates it with Zod, applies defaults (e.g. `client`), and returns the typed config:
282+
283+
```typescript
284+
import { loadStashConfig } from '@cipherstash/stack-forge'
285+
286+
const config = await loadStashConfig()
287+
// config.databaseUrl — guaranteed to be a non-empty string
288+
// config.client — path to encryption client (default: './src/encryption/index.ts')
289+
```

0 commit comments

Comments
 (0)