diff --git a/.changeset/config.json b/.changeset/config.json index edef2ebc..9d6ca60c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,8 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/curvy-bushes-mix.md b/.changeset/curvy-bushes-mix.md new file mode 100644 index 00000000..8b0b0803 --- /dev/null +++ b/.changeset/curvy-bushes-mix.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/stack": minor +--- + +Exposed a public method on the Encryption client to expose the build Encryption schema. diff --git a/.changeset/soft-times-tease.md b/.changeset/soft-times-tease.md new file mode 100644 index 00000000..f3a2c40a --- /dev/null +++ b/.changeset/soft-times-tease.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/stack-forge": minor +--- + +Initial release of the `stash-forge` CLI utility. diff --git a/examples/basic/encrypt.ts b/examples/basic/encrypt.ts index 4963872d..140e2260 100644 --- a/examples/basic/encrypt.ts +++ b/examples/basic/encrypt.ts @@ -1,4 +1,5 @@ import 'dotenv/config' + import { Encryption, encryptedColumn, encryptedTable } from '@cipherstash/stack' export const users = encryptedTable('users', { diff --git a/examples/basic/package.json b/examples/basic/package.json index 76b968eb..c1b73f3b 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -12,9 +12,11 @@ "description": "", "dependencies": { "@cipherstash/stack": "workspace:*", - "dotenv": "^16.4.7" + "dotenv": "^16.6.1", + "pg": "8.13.1" }, "devDependencies": { + "@cipherstash/stack-forge": "workspace:*", "tsx": "catalog:repo", "typescript": "catalog:repo" } diff --git a/examples/basic/stash.config.ts b/examples/basic/stash.config.ts new file mode 100644 index 00000000..e5950e5d --- /dev/null +++ b/examples/basic/stash.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@cipherstash/stack-forge' + +export default defineConfig({ + databaseUrl: process.env.DATABASE_URL!, + client: './encrypt.ts', +}) diff --git a/packages/stack-forge/README.md b/packages/stack-forge/README.md new file mode 100644 index 00000000..7d3e4db3 --- /dev/null +++ b/packages/stack-forge/README.md @@ -0,0 +1,289 @@ +# @cipherstash/stack-forge + +Dev-time CLI and library for managing [CipherStash EQL](https://github.com/cipherstash/encrypt-query-language) (Encrypted Query Language) in your PostgreSQL database. + +[![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) +[![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) +[![TypeScript](https://img.shields.io/badge/TypeScript-first-blue?style=for-the-badge&labelColor=000000)](https://www.typescriptlang.org/) + +--- + +## Why stack-forge? + +`@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. + +Think of it like Prisma or Drizzle Kit — a companion CLI that sets up the database while the main SDK handles runtime operations. + +## Install + +```bash +npm install -D @cipherstash/stack-forge +``` + +Or with your preferred package manager: + +```bash +pnpm add -D @cipherstash/stack-forge +yarn add -D @cipherstash/stack-forge +bun add -D @cipherstash/stack-forge +``` + +## Quick Start + +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. + +### 1. Create a config file + +Create `stash.config.ts` in your project root: + +```typescript +import { defineConfig } from '@cipherstash/stack-forge' + +export default defineConfig({ + databaseUrl: process.env.DATABASE_URL!, +}) +``` + +### 2. Add a `.env` file + +```env +DATABASE_URL=postgresql://user:password@localhost:5432/mydb +``` + +### 3. Install EQL + +```bash +npx stash-forge install +``` + +That's it. EQL is now installed in your database. + +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`. + +**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. + +--- + +## Configuration + +The `stash.config.ts` file is the single source of truth for stack-forge. It uses the `defineConfig` helper for type safety. + +```typescript +import { defineConfig } from '@cipherstash/stack-forge' + +export default defineConfig({ + // Required: PostgreSQL connection string + databaseUrl: process.env.DATABASE_URL!, + + // Optional: path to your encryption client (default: './src/encryption/index.ts') + // Used by `stash-forge push` to load the encryption schema + client: './src/encryption/index.ts', + + // Optional: CipherStash workspace and credentials (for future schema sync) + workspaceId: process.env.CS_WORKSPACE_ID, + clientAccessKey: process.env.CS_CLIENT_ACCESS_KEY, +}) +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `databaseUrl` | Yes | PostgreSQL connection string | +| `client` | No | Path to encryption client file (default: `'./src/encryption/index.ts'`). Used by `push` to load the encryption schema. | +| `workspaceId` | No | CipherStash workspace ID | +| `clientAccessKey` | No | CipherStash client access key | + +The CLI automatically loads `.env` files before evaluating the config, so `process.env` references work out of the box. + +The config file is resolved by walking up from the current working directory, similar to how `tsconfig.json` resolution works. + +--- + +## CLI Reference + +``` +stash-forge [options] +``` + +### `install` + +Install the CipherStash EQL extensions into your database. + +```bash +npx stash-forge install [options] +``` + +| Option | Description | +|--------|-------------| +| `--dry-run` | Show what would happen without making changes | +| `--force` | Reinstall even if EQL is already installed | +| `--supabase` | Use Supabase-compatible install (excludes operator families + grants Supabase roles) | +| `--exclude-operator-family` | Skip operator family creation (for non-superuser database roles) | +| `--drizzle` | Generate a Drizzle migration instead of direct install | +| `--name ` | Migration name when using `--drizzle` (default: `install-eql`) | +| `--out ` | Drizzle output directory when using `--drizzle` (default: `drizzle`) | + +**Standard install:** + +```bash +npx stash-forge install +``` + +**Supabase install:** + +```bash +npx stash-forge install --supabase +``` + +The `--supabase` flag: +- Downloads the Supabase-specific SQL variant (no `CREATE OPERATOR FAMILY`) +- Grants `USAGE`, table, routine, and sequence permissions on the `eql_v2` schema to `anon`, `authenticated`, and `service_role` + +**Preview changes first:** + +```bash +npx stash-forge install --dry-run +``` + +#### `install --drizzle` + +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. + +```bash +npx stash-forge install --drizzle +npx drizzle-kit migrate +``` + +**How it works:** + +1. Runs `drizzle-kit generate --custom --name=` to create an empty migration. +2. Downloads the EQL install script from the [EQL GitHub releases](https://github.com/cipherstash/encrypt-query-language/releases/latest). +3. Writes the EQL SQL into the generated migration file. + +With a custom migration name or output directory: + +```bash +npx stash-forge install --drizzle --name setup-eql --out ./migrations +npx drizzle-kit migrate +``` + +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`). + +### `push` + +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`). + +```bash +npx stash-forge push [options] +``` + +| Option | Description | +|--------|-------------| +| `--dry-run` | Load and validate the schema, then print it as JSON. No database changes. | + +**Push schema to the database:** + +```bash +npx stash-forge push +``` + +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. + +**Preview your encryption schema without writing to the database:** + +```bash +npx stash-forge push --dry-run +``` + +### Permission Pre-checks (install) + +Before installing, `stash-forge` verifies that the connected database role has the required permissions: + +- `CREATE` on the database (for `CREATE SCHEMA` and `CREATE EXTENSION`) +- `CREATE` on the `public` schema (for `CREATE TYPE public.eql_v2_encrypted`) +- `SUPERUSER` or extension owner (for `CREATE EXTENSION pgcrypto`, if not already installed) + +If permissions are insufficient, the CLI exits with a clear message listing what's missing. + +### Planned Commands + +The following commands are defined but not yet implemented: + +| Command | Description | +|---------|-------------| +| `init` | Initialize CipherStash Forge in your project | +| `migrate` | Run pending encrypt config migrations | +| `status` | Show EQL installation status | + +--- + +## Programmatic API + +You can also use stack-forge as a library: + +```typescript +import { EQLInstaller, loadStashConfig } from '@cipherstash/stack-forge' + +// Load config from stash.config.ts +const config = await loadStashConfig() + +// Create an installer +const installer = new EQLInstaller({ + databaseUrl: config.databaseUrl, +}) + +// Check permissions before installing +const permissions = await installer.checkPermissions() +if (!permissions.ok) { + console.error('Missing permissions:', permissions.missing) + process.exit(1) +} + +// Check if already installed +if (await installer.isInstalled()) { + console.log('EQL is already installed') +} else { + await installer.install() +} +``` + +### `EQLInstaller` + +| Method | Returns | Description | +|--------|---------|-------------| +| `checkPermissions()` | `Promise` | Check if the database role has required permissions | +| `isInstalled()` | `Promise` | Check if the `eql_v2` schema exists | +| `getInstalledVersion()` | `Promise` | Get the installed EQL version (or `null`) | +| `install(options?)` | `Promise` | Download and execute the EQL install SQL in a transaction | + +#### Install Options + +```typescript +await installer.install({ + excludeOperatorFamily: true, // Skip CREATE OPERATOR FAMILY + supabase: true, // Supabase mode (implies excludeOperatorFamily + grants roles) +}) +``` + +### `defineConfig` + +Type-safe identity function for `stash.config.ts`: + +```typescript +import { defineConfig } from '@cipherstash/stack-forge' + +export default defineConfig({ + databaseUrl: process.env.DATABASE_URL!, +}) +``` + +### `loadStashConfig` + +Finds and loads the nearest `stash.config.ts`, validates it with Zod, applies defaults (e.g. `client`), and returns the typed config: + +```typescript +import { loadStashConfig } from '@cipherstash/stack-forge' + +const config = await loadStashConfig() +// config.databaseUrl — guaranteed to be a non-empty string +// config.client — path to encryption client (default: './src/encryption/index.ts') +``` diff --git a/packages/stack-forge/package.json b/packages/stack-forge/package.json new file mode 100644 index 00000000..17b57b78 --- /dev/null +++ b/packages/stack-forge/package.json @@ -0,0 +1,62 @@ +{ + "name": "@cipherstash/stack-forge", + "version": "0.0.0", + "description": "CipherStash Stack Forge", + "license": "MIT", + "author": "CipherStash ", + "files": [ + "dist", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "type": "module", + "bin": { + "stash-forge": "./dist/bin/stash-forge.js" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "sideEffects": false, + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsup", + "postbuild": "chmod +x ./dist/bin/stash-forge.js", + "dev": "tsup --watch", + "test": "vitest run", + "lint": "biome check ." + }, + "dependencies": { + "@clack/prompts": "0.10.1", + "dotenv": "16.4.7", + "jiti": "2.6.1", + "pg": "8.13.1", + "zod": "3.24.2" + }, + "devDependencies": { + "@cipherstash/stack": "workspace:*", + "@types/pg": "^8.11.11", + "tsup": "catalog:repo", + "tsx": "catalog:repo", + "typescript": "catalog:repo", + "vitest": "catalog:repo" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/stack-forge/src/__tests__/config.test.ts b/packages/stack-forge/src/__tests__/config.test.ts new file mode 100644 index 00000000..5048519b --- /dev/null +++ b/packages/stack-forge/src/__tests__/config.test.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('jiti', () => ({ + createJiti: vi.fn(), +})) + +describe('loadStashConfig', () => { + let tmpDir: string + let originalCwd: () => string + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-forge-config-test-')) + originalCwd = process.cwd + }) + + afterEach(() => { + process.cwd = originalCwd + vi.restoreAllMocks() + + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('throws when stash.config.ts is missing', async () => { + process.cwd = () => tmpDir + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const { loadStashConfig } = await import('@/config/index.ts') + + await expect(loadStashConfig()).rejects.toThrow('process.exit') + }) + + it('validates required fields', async () => { + // Write a config file that exists but exports an empty object + fs.writeFileSync(path.join(tmpDir, 'stash.config.ts'), 'export default {}') + + process.cwd = () => tmpDir + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const { createJiti } = await import('jiti') + const mockJiti = { + import: vi.fn().mockResolvedValue({}), + } + vi.mocked(createJiti).mockReturnValue(mockJiti as never) + + const { loadStashConfig } = await import('@/config/index.ts') + + await expect(loadStashConfig()).rejects.toThrow('process.exit') + }) + + it('succeeds with valid config', async () => { + const validConfig = { databaseUrl: 'postgresql://localhost:5432/test' } + + fs.writeFileSync( + path.join(tmpDir, 'stash.config.ts'), + `export default { databaseUrl: 'postgresql://localhost:5432/test' }`, + ) + + process.cwd = () => tmpDir + + const { createJiti } = await import('jiti') + const mockJiti = { + import: vi.fn().mockResolvedValue(validConfig), + } + vi.mocked(createJiti).mockReturnValue(mockJiti as never) + + const { loadStashConfig } = await import('@/config/index.ts') + + const config = await loadStashConfig() + expect(config).toEqual({ + ...validConfig, + client: './src/encryption/index.ts', + }) + }) +}) diff --git a/packages/stack-forge/src/__tests__/installer.test.ts b/packages/stack-forge/src/__tests__/installer.test.ts new file mode 100644 index 00000000..6c8321ac --- /dev/null +++ b/packages/stack-forge/src/__tests__/installer.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mockConnect = vi.fn() +const mockQuery = vi.fn() +const mockEnd = vi.fn() + +vi.mock('pg', () => { + const Client = vi.fn(() => ({ + connect: mockConnect, + query: mockQuery, + end: mockEnd, + })) + return { default: { Client } } +}) + +describe('EQLInstaller', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('checkPermissions', () => { + it('returns ok when role is superuser', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ + rows: [{ rolsuper: true, rolcreatedb: true }], + rowCount: 1, + }) + mockEnd.mockResolvedValue(undefined) + + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ + databaseUrl: 'postgresql://localhost:5432/test', + }) + + const result = await installer.checkPermissions() + expect(result.ok).toBe(true) + expect(result.missing).toEqual([]) + }) + + it('returns missing permissions when role lacks privileges', async () => { + mockConnect.mockResolvedValue(undefined) + mockEnd.mockResolvedValue(undefined) + + let queryCall = 0 + mockQuery.mockImplementation(() => { + queryCall++ + switch (queryCall) { + // pg_roles query — not superuser + case 1: + return { rows: [{ rolsuper: false, rolcreatedb: false }], rowCount: 1 } + // has_database_privilege — no CREATE + case 2: + return { rows: [{ has_create: false }], rowCount: 1 } + // has_schema_privilege — no CREATE on public + case 3: + return { rows: [{ has_create: false }], rowCount: 1 } + // pgcrypto check — not installed + case 4: + return { rows: [], rowCount: 0 } + default: + return { rows: [], rowCount: 0 } + } + }) + + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ + databaseUrl: 'postgresql://localhost:5432/test', + }) + + const result = await installer.checkPermissions() + expect(result.ok).toBe(false) + expect(result.missing).toHaveLength(3) + }) + }) + + describe('isInstalled', () => { + it('returns false when schema does not exist', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ rows: [], rowCount: 0 }) + mockEnd.mockResolvedValue(undefined) + + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ + databaseUrl: 'postgresql://localhost:5432/test', + }) + + const result = await installer.isInstalled() + expect(result).toBe(false) + }) + + it('returns true when schema exists', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ + rows: [{ schema_name: 'eql_v2' }], + rowCount: 1, + }) + mockEnd.mockResolvedValue(undefined) + + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ + databaseUrl: 'postgresql://localhost:5432/test', + }) + + const result = await installer.isInstalled() + expect(result).toBe(true) + }) + }) + + describe('install', () => { + it('fetches and executes SQL in a transaction', async () => { + const installSql = 'CREATE SCHEMA eql_v2;' + + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ rows: [], rowCount: 0 }) + mockEnd.mockResolvedValue(undefined) + + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(installSql, { status: 200 })) + + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ + databaseUrl: 'postgresql://localhost:5432/test', + }) + + await installer.install() + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('cipherstash-encrypt.sql'), + ) + expect(mockQuery).toHaveBeenCalledWith('BEGIN') + expect(mockQuery).toHaveBeenCalledWith(installSql) + expect(mockQuery).toHaveBeenCalledWith('COMMIT') + }) + + it('rolls back on SQL execution failure', async () => { + const installSql = 'CREATE SCHEMA eql_v2;' + + mockConnect.mockResolvedValue(undefined) + mockEnd.mockResolvedValue(undefined) + + let queryCallCount = 0 + mockQuery.mockImplementation((sql: string) => { + queryCallCount++ + // BEGIN succeeds, the install SQL fails + if (sql === installSql) { + return Promise.reject(new Error('permission denied')) + } + return Promise.resolve({ rows: [], rowCount: 0 }) + }) + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(installSql, { status: 200 }), + ) + + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ + databaseUrl: 'postgresql://localhost:5432/test', + }) + + await expect(installer.install()).rejects.toThrow('Failed to install EQL') + expect(mockQuery).toHaveBeenCalledWith('ROLLBACK') + }) + }) +}) diff --git a/packages/stack-forge/src/bin/stash-forge.ts b/packages/stack-forge/src/bin/stash-forge.ts new file mode 100644 index 00000000..82a4b95b --- /dev/null +++ b/packages/stack-forge/src/bin/stash-forge.ts @@ -0,0 +1,106 @@ +import { config } from 'dotenv' +config() + +import * as p from '@clack/prompts' +import { installCommand, pushCommand } from '../commands/index.js' + +const HELP = ` +CipherStash Forge +Usage: stash-forge [options] + +Commands: + install Install EQL extensions into your database + init Initialize CipherStash Forge in your project + push Push encryption schema to database + migrate Run pending encrypt config migrations + status Show EQL installation status + +Options: + --help, -h Show help + --version, -v Show version + --force (install) Reinstall even if already installed + --dry-run (install, push) Show what would happen without making changes + --supabase (install) Use Supabase-compatible install and grant role permissions + --drizzle (install) Generate a Drizzle migration instead of direct install + --exclude-operator-family (install) Skip operator family creation (for non-superuser roles) +`.trim() + +interface ParsedArgs { + command: string | undefined + flags: Record + values: Record +} + +function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2) + const command = args[0] + const flags: Record = {} + const values: Record = {} + + const rest = args.slice(1) + for (let i = 0; i < rest.length; i++) { + const arg = rest[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const nextArg = rest[i + 1] + + // If the next argument exists and is not a flag, treat it as a value + if (nextArg !== undefined && !nextArg.startsWith('--')) { + values[key] = nextArg + i++ // Skip the value argument + } else { + flags[key] = true + } + } + } + + return { command, flags, values } +} + +async function main() { + const { command, flags, values } = parseArgs(process.argv) + + if (!command || flags.help || command === '--help' || command === '-h') { + console.log(HELP) + return + } + + if (flags.version || command === '--version' || command === '-v') { + console.log('0.1.0') + return + } + + switch (command) { + case 'install': + await installCommand({ + force: flags.force, + dryRun: flags['dry-run'], + supabase: flags.supabase, + excludeOperatorFamily: flags['exclude-operator-family'], + drizzle: flags.drizzle, + name: values.name, + out: values.out, + }) + break + case 'push': + await pushCommand({ dryRun: flags['dry-run'] }) + break + case 'init': + case 'migrate': + case 'status': + p.log.warn(`"stash-forge ${command}" is not yet implemented.`) + break + default: + p.log.error(`Unknown command: ${command}`) + console.log() + console.log(HELP) + process.exit(1) + } +} + +main().catch((error) => { + p.log.error( + error instanceof Error ? error.message : 'An unexpected error occurred', + ) + process.exit(1) +}) diff --git a/packages/stack-forge/src/commands/index.ts b/packages/stack-forge/src/commands/index.ts new file mode 100644 index 00000000..7389931f --- /dev/null +++ b/packages/stack-forge/src/commands/index.ts @@ -0,0 +1,4 @@ +export { initCommand } from './init.js' +export { installCommand } from './install.js' +export { pushCommand } from './push.js' +export { statusCommand } from './status.js' diff --git a/packages/stack-forge/src/commands/init.ts b/packages/stack-forge/src/commands/init.ts new file mode 100644 index 00000000..68119ef6 --- /dev/null +++ b/packages/stack-forge/src/commands/init.ts @@ -0,0 +1,5 @@ +import * as p from '@clack/prompts' + +export async function initCommand() { + p.log.warn('"stash-forge init" is not yet implemented.') +} diff --git a/packages/stack-forge/src/commands/install.ts b/packages/stack-forge/src/commands/install.ts new file mode 100644 index 00000000..5a846235 --- /dev/null +++ b/packages/stack-forge/src/commands/install.ts @@ -0,0 +1,279 @@ +import { execSync } from 'node:child_process' +import { existsSync, unlinkSync, writeFileSync } from 'node:fs' +import { readdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { loadStashConfig } from '@/config/index.js' +import { EQLInstaller } from '@/installer/index.js' +import * as p from '@clack/prompts' + +const DEFAULT_MIGRATION_NAME = 'install-eql' +const DEFAULT_DRIZZLE_OUT = 'drizzle' + +export async function installCommand(options: { + force?: boolean + dryRun?: boolean + excludeOperatorFamily?: boolean + supabase?: boolean + drizzle?: boolean + name?: string + out?: string +}) { + p.intro('stash-forge install') + + const s = p.spinner() + + s.start('Loading stash.config.ts...') + const config = await loadStashConfig() + s.stop('Configuration loaded.') + + if (options.drizzle) { + await generateDrizzleMigration(s, { + name: options.name, + out: options.out, + dryRun: options.dryRun, + }) + return + } + + if (options.dryRun) { + p.log.info('Dry run — no changes will be made.') + p.note( + 'Would download EQL install script from GitHub\nWould execute the SQL against the database', + 'Dry Run', + ) + p.outro('Dry run complete.') + return + } + + const installer = new EQLInstaller({ + databaseUrl: config.databaseUrl, + }) + + s.start('Checking database permissions...') + const permissions = await installer.checkPermissions() + + if (!permissions.ok) { + s.stop('Insufficient database permissions.') + p.log.error('The connected database role is missing required permissions:') + for (const missing of permissions.missing) { + p.log.warn(` - ${missing}`) + } + p.note( + 'EQL installation requires a role with CREATE SCHEMA,\nCREATE TYPE, and CREATE EXTENSION privileges.\n\nConnect with a superuser or admin role, or ask your\ndatabase administrator to grant the required permissions.', + 'Required Permissions', + ) + p.outro('Installation aborted.') + process.exit(1) + } + s.stop('Database permissions verified.') + + if (!options.force) { + s.start('Checking if EQL is already installed...') + const installed = await installer.isInstalled() + s.stop(installed ? 'EQL is already installed.' : 'EQL is not installed.') + + if (installed) { + p.log.info('Use --force to re-run the install script.') + p.outro('Nothing to do.') + return + } + } + + s.start('Installing EQL extensions...') + await installer.install({ + excludeOperatorFamily: options.excludeOperatorFamily, + supabase: options.supabase, + }) + s.stop('EQL extensions installed.') + + if (options.supabase) { + p.log.success('Supabase role permissions granted.') + } + + p.outro('Done!') +} + +/** + * Generate a Drizzle migration that installs CipherStash EQL. + * + * Uses `drizzle-kit generate --custom` to scaffold an empty migration, + * downloads the EQL install SQL from GitHub, and writes it into the file. + */ +async function generateDrizzleMigration( + s: ReturnType, + options: { name?: string; out?: string; dryRun?: boolean }, +) { + const migrationName = options.name ?? DEFAULT_MIGRATION_NAME + const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT) + + if (options.dryRun) { + p.log.info('Dry run — no changes will be made.') + p.note( + `Would run: npx drizzle-kit generate --custom --name=${migrationName}\nWould download EQL install SQL from GitHub\nWould write SQL to migration file in ${outDir}`, + 'Dry Run', + ) + p.outro('Dry run complete.') + return + } + + let generatedMigrationPath: string | undefined + + // Step 1: Generate a custom Drizzle migration + s.start('Generating custom Drizzle migration...') + + try { + execSync(`npx drizzle-kit generate --custom --name=${migrationName}`, { + stdio: 'pipe', + encoding: 'utf-8', + }) + s.stop('Custom Drizzle migration generated.') + } catch (error) { + s.stop('Failed to generate migration.') + const stderr = + error !== null && + typeof error === 'object' && + 'stderr' in error && + typeof error.stderr === 'string' + ? error.stderr.trim() + : undefined + if (stderr) { + p.log.error(stderr) + } else { + p.log.error( + error instanceof Error ? error.message : 'Unknown error occurred.', + ) + } + p.log.info('Make sure drizzle-kit is installed: npm install -D drizzle-kit') + p.outro('Migration aborted.') + process.exit(1) + } + + // Step 2: Find the generated migration file + s.start('Locating generated migration file...') + + try { + generatedMigrationPath = await findGeneratedMigration(outDir, migrationName) + s.stop(`Found migration: ${generatedMigrationPath}`) + } catch (error) { + s.stop('Failed to locate migration file.') + p.log.error( + error instanceof Error ? error.message : String(error), + ) + p.outro('Migration aborted.') + process.exit(1) + } + + // Step 3: Download the EQL SQL + s.start('Downloading EQL install script...') + + let eqlSql: string + + try { + eqlSql = await downloadEqlSql() + s.stop('EQL install script downloaded.') + } catch (error) { + s.stop('Failed to download EQL install script.') + p.log.error( + error instanceof Error ? error.message : String(error), + ) + cleanupMigrationFile(generatedMigrationPath) + p.outro('Migration aborted.') + process.exit(1) + } + + // Step 4: Write the EQL SQL into the migration file + s.start('Writing EQL SQL into migration file...') + + try { + writeFileSync(generatedMigrationPath, eqlSql, 'utf-8') + s.stop('EQL SQL written to migration file.') + } catch (error) { + s.stop('Failed to write migration file.') + p.log.error( + error instanceof Error ? error.message : String(error), + ) + cleanupMigrationFile(generatedMigrationPath) + p.outro('Migration aborted.') + process.exit(1) + } + + p.log.success(`Migration created: ${generatedMigrationPath}`) + p.note( + 'Run your Drizzle migrations to install EQL:\n\n npx drizzle-kit migrate', + 'Next Steps', + ) + p.outro('Done!') +} + +/** + * Find the most recently generated migration file matching the given name. + * Drizzle-kit generates flat SQL files like `0000_install-eql.sql`. + */ +async function findGeneratedMigration( + outDir: string, + migrationName: string, +): Promise { + if (!existsSync(outDir)) { + throw new Error( + `Drizzle output directory not found: ${outDir}\nMake sure drizzle-kit is configured correctly.`, + ) + } + + const entries = await readdir(outDir) + + const matchingFiles = entries + .filter( + (entry) => entry.endsWith('.sql') && entry.includes(migrationName), + ) + .sort() + + if (matchingFiles.length === 0) { + throw new Error( + `Could not find a migration matching "${migrationName}" in ${outDir}`, + ) + } + + return join(outDir, matchingFiles[matchingFiles.length - 1]) +} + +/** + * Download the EQL install SQL from GitHub releases. + */ +async function downloadEqlSql(): Promise { + const EQL_INSTALL_URL = + 'https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql' + + let response: Response + + try { + response = await fetch(EQL_INSTALL_URL) + } catch (error) { + throw new Error('Failed to download EQL install script from GitHub.', { + cause: error, + }) + } + + if (!response.ok) { + throw new Error( + `Failed to download EQL install script. HTTP ${response.status}: ${response.statusText}`, + ) + } + + return response.text() +} + +/** + * Attempt to clean up a generated migration file on failure. + */ +function cleanupMigrationFile(filePath: string | undefined): void { + if (!filePath) return + + try { + if (existsSync(filePath)) { + unlinkSync(filePath) + p.log.info(`Cleaned up migration file: ${filePath}`) + } + } catch { + p.log.warn(`Could not clean up migration file: ${filePath}`) + } +} diff --git a/packages/stack-forge/src/commands/push.ts b/packages/stack-forge/src/commands/push.ts new file mode 100644 index 00000000..9faf2fc9 --- /dev/null +++ b/packages/stack-forge/src/commands/push.ts @@ -0,0 +1,55 @@ +import { loadEncryptConfig, loadStashConfig } from '@/config/index.js' +import * as p from '@clack/prompts' +import pg from 'pg' + +export async function pushCommand(options: { dryRun?: boolean }) { + p.intro('stash-forge push') + + const s = p.spinner() + + s.start('Loading stash.config.ts...') + const config = await loadStashConfig() + s.stop('Configuration loaded.') + + s.start(`Loading encrypt client from ${config.client}...`) + const encryptConfig = await loadEncryptConfig(config.client) + s.stop('Encrypt client loaded and validated.') + + if (options.dryRun) { + p.log.info('Dry run — no changes will be pushed.') + p.note(JSON.stringify(encryptConfig, null, 2), 'Encryption Schema') + p.outro('Dry run complete.') + return + } + + const client = new pg.Client({ connectionString: config.databaseUrl }) + + try { + s.start('Connecting to Postgres...') + await client.connect() + s.stop('Connected to Postgres.') + + s.start('Updating eql_v2_configuration...') + await client.query(` + UPDATE eql_v2_configuration SET state = 'inactive' + `) + + await client.query( + ` + INSERT INTO eql_v2_configuration (state, data) VALUES ('active', $1) + `, + [encryptConfig], + ) + s.stop('Updated eql_v2_configuration.') + + p.outro('Push complete.') + } catch (error) { + s.stop('Failed.') + p.log.error( + error instanceof Error ? error.message : 'Failed to push configuration.', + ) + process.exit(1) + } finally { + await client.end() + } +} diff --git a/packages/stack-forge/src/commands/status.ts b/packages/stack-forge/src/commands/status.ts new file mode 100644 index 00000000..528cc29e --- /dev/null +++ b/packages/stack-forge/src/commands/status.ts @@ -0,0 +1,5 @@ +import * as p from '@clack/prompts' + +export async function statusCommand() { + p.log.warn('"stash-forge status" is not yet implemented.') +} diff --git a/packages/stack-forge/src/config/index.ts b/packages/stack-forge/src/config/index.ts new file mode 100644 index 00000000..9b7139ee --- /dev/null +++ b/packages/stack-forge/src/config/index.ts @@ -0,0 +1,194 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { EncryptionClient } from '@cipherstash/stack/encryption' +import type { EncryptConfig } from '@cipherstash/stack/schema' +import { z } from 'zod' + +export interface StashConfig { + /** PostgreSQL connection string */ + databaseUrl: string + /** Optional: CipherStash workspace ID */ + workspaceId?: string + /** Optional: CipherStash client access key */ + clientAccessKey?: string + /** Path to encryption client file. Defaults to `'./src/encryption/index.ts'`. */ + client?: string +} + +/** The config shape after Zod validation, with all defaults applied. */ +export type ResolvedStashConfig = Required> & + Omit + +/** + * Define a stash config with type checking. + * Use this as the default export in your `stash.config.ts`. + * + * @example + * ```ts + * import { defineConfig } from '@cipherstash/stack-forge' + * + * export default defineConfig({ + * databaseUrl: process.env.DATABASE_URL!, + * client: './src/encryption/index.ts', + * }) + * ``` + */ +export function defineConfig(config: StashConfig): StashConfig { + return config +} + +const CONFIG_FILENAME = 'stash.config.ts' + +const DEFAULT_ENCRYPT_CLIENT_PATH = './src/encryption/index.ts' + +const stashConfigSchema = z.object({ + databaseUrl: z + .string({ required_error: 'databaseUrl is required' }) + .min(1, 'databaseUrl must not be empty'), + client: z.string().default(DEFAULT_ENCRYPT_CLIENT_PATH), +}) + +/** + * Search for `stash.config.ts` starting from `startDir` and walking up + * parent directories until the filesystem root is reached. + * + * Returns the absolute path if found, or `undefined` if not. + */ +function findConfigFile(startDir: string): string | undefined { + let dir = path.resolve(startDir) + + while (true) { + const candidate = path.join(dir, CONFIG_FILENAME) + + if (fs.existsSync(candidate)) { + return candidate + } + + const parent = path.dirname(dir) + + // Reached filesystem root + if (parent === dir) { + return undefined + } + + dir = parent + } +} + +/** + * Load and validate the `stash.config.ts` from the user's project. + * + * Searches from `process.cwd()` upward. Uses `jiti` to evaluate the + * TypeScript config file at runtime without a separate compile step. + * + * Exits with code 1 if the config file is not found or fails validation. + */ +export async function loadStashConfig(): Promise { + const configPath = findConfigFile(process.cwd()) + + if (!configPath) { + console.error(`Error: Could not find ${CONFIG_FILENAME} + +Create a ${CONFIG_FILENAME} file in your project root: + + import { defineConfig } from '@cipherstash/stack-forge' + + export default defineConfig({ + databaseUrl: process.env.DATABASE_URL!, + }) +`) + process.exit(1) + } + + const { createJiti } = await import('jiti') + const jiti = createJiti(configPath, { + interopDefault: true, + }) + + let rawConfig: unknown + try { + rawConfig = await jiti.import(configPath) + } catch (error) { + console.error(`Error: Failed to load ${CONFIG_FILENAME} at ${configPath}\n`) + console.error(error) + process.exit(1) + } + + const result = stashConfigSchema.safeParse(rawConfig) + + if (!result.success) { + console.error(`Error: Invalid ${CONFIG_FILENAME}\n`) + + for (const issue of result.error.issues) { + console.error(` - ${issue.path.join('.')}: ${issue.message}`) + } + + console.error() + process.exit(1) + } + + return result.data +} + +/** + * Load the encryption schema file referenced by the stash config. + * + * Resolves the schema path relative to `process.cwd()`, loads the file via + * `jiti`, collects all exported `EncryptedTable` instances, and builds the + * encrypt config via `buildEncryptConfig`. + * + * Exits with code 1 if the file cannot be loaded or contains no tables. + */ +export async function loadEncryptConfig( + encryptClientPath: string, +): Promise { + const resolvedPath = path.resolve(process.cwd(), encryptClientPath) + + if (!fs.existsSync(resolvedPath)) { + console.error( + `Error: Encrypt client file not found at ${resolvedPath}\n\nCheck the "encryptClient" path in your ${CONFIG_FILENAME}.`, + ) + process.exit(1) + } + + const { createJiti } = await import('jiti') + const jiti = createJiti(resolvedPath, { + interopDefault: true, + }) + + let moduleExports: Record + try { + moduleExports = (await jiti.import(resolvedPath)) as Record + } catch (error) { + console.error( + `Error: Failed to load encrypt client file at ${resolvedPath}\n`, + ) + console.error(error) + process.exit(1) + } + + const encryptClient = Object.values(moduleExports).find( + (value): value is EncryptionClient => + !!value && + typeof value === 'object' && + 'getEncryptConfig' in value && + typeof (value as { getEncryptConfig?: unknown }).getEncryptConfig === + 'function', + ) + + if (!encryptClient) { + console.error( + `Error: No EncryptionClient export found in ${encryptClientPath}.`, + ) + process.exit(1) + } + + const config = encryptClient.getEncryptConfig() + if (!config) { + console.error( + `Error: Encryption client in ${encryptClientPath} has no initialized encrypt config.`, + ) + process.exit(1) + } + return config +} diff --git a/packages/stack-forge/src/index.ts b/packages/stack-forge/src/index.ts new file mode 100644 index 00000000..4aa86eae --- /dev/null +++ b/packages/stack-forge/src/index.ts @@ -0,0 +1,7 @@ +// @cipherstash/stack-forge +// Public API exports + +export { defineConfig, loadStashConfig } from './config/index.ts' +export type { StashConfig } from './config/index.ts' +export { EQLInstaller } from './installer/index.ts' +export type { PermissionCheckResult } from './installer/index.ts' diff --git a/packages/stack-forge/src/installer/index.ts b/packages/stack-forge/src/installer/index.ts new file mode 100644 index 00000000..9e6e8847 --- /dev/null +++ b/packages/stack-forge/src/installer/index.ts @@ -0,0 +1,269 @@ +import pg from 'pg' + +const EQL_INSTALL_URL = + 'https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql' +const EQL_INSTALL_NO_OPERATOR_FAMILY_URL = + 'https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt-supabase.sql' +const EQL_SCHEMA_NAME = 'eql_v2' + +export interface PermissionCheckResult { + ok: boolean + missing: string[] +} + +export class EQLInstaller { + private readonly databaseUrl: string + + constructor(options: { databaseUrl: string }) { + this.databaseUrl = options.databaseUrl + } + + /** + * Check whether the connected database role has the permissions required + * to install EQL. + * + * EQL installation requires: + * - SUPERUSER or CREATEDB — for `CREATE EXTENSION IF NOT EXISTS pgcrypto` + * - CREATE on the current database — for `CREATE SCHEMA eql_v2` + * - CREATE on the public schema — for `CREATE TYPE public.eql_v2_encrypted` + */ + async checkPermissions(): Promise { + const client = new pg.Client({ connectionString: this.databaseUrl }) + + try { + await client.connect() + + const missing: string[] = [] + + // Check if the role is a superuser (can do everything) + const roleResult = await client.query(` + SELECT + rolsuper, + rolcreatedb + FROM pg_roles + WHERE rolname = current_user + `) + + const role = roleResult.rows[0] + const isSuperuser = role?.rolsuper === true + + if (isSuperuser) { + return { ok: true, missing: [] } + } + + // Not a superuser — check individual permissions + + // CREATE on the current database (needed for CREATE SCHEMA, CREATE EXTENSION) + const dbCreateResult = await client.query(` + SELECT has_database_privilege(current_user, current_database(), 'CREATE') AS has_create + `) + if (!dbCreateResult.rows[0]?.has_create) { + missing.push('CREATE on database (required for CREATE SCHEMA and CREATE EXTENSION)') + } + + // CREATE on the public schema (needed for CREATE TYPE public.eql_v2_encrypted) + const schemaCreateResult = await client.query(` + SELECT has_schema_privilege(current_user, 'public', 'CREATE') AS has_create + `) + if (!schemaCreateResult.rows[0]?.has_create) { + missing.push('CREATE on public schema (required for CREATE TYPE public.eql_v2_encrypted)') + } + + // Check if pgcrypto is already installed — if not, we need CREATE EXTENSION privilege + const pgcryptoResult = await client.query(` + SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto' + `) + if (pgcryptoResult.rowCount === 0 || pgcryptoResult.rowCount === null) { + // pgcrypto not installed — need to be able to create extensions + // This typically requires superuser or the role must be the extension owner + if (!role?.rolcreatedb) { + missing.push('SUPERUSER or extension owner (required for CREATE EXTENSION pgcrypto)') + } + } + + return { ok: missing.length === 0, missing } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to connect to database: ${detail}`, + { cause: error }, + ) + } finally { + await client.end() + } + } + + /** + * Check whether the EQL extension is installed by looking for the `eql_v2` schema. + */ + async isInstalled(): Promise { + const client = new pg.Client({ connectionString: this.databaseUrl }) + + try { + await client.connect() + + const result = await client.query( + 'SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1', + [EQL_SCHEMA_NAME], + ) + + return result.rowCount !== null && result.rowCount > 0 + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to connect to database: ${detail}`, + { cause: error }, + ) + } finally { + await client.end() + } + } + + /** + * Return the installed EQL version, or `null` if EQL is not installed. + * + * This is best-effort: if the schema exists but no version metadata is + * available, `'unknown'` is returned. + */ + async getInstalledVersion(): Promise { + const client = new pg.Client({ connectionString: this.databaseUrl }) + + try { + await client.connect() + + const schemaResult = await client.query( + 'SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1', + [EQL_SCHEMA_NAME], + ) + + if (schemaResult.rowCount === null || schemaResult.rowCount === 0) { + return null + } + + // Attempt to read a version from the schema — the EQL extension may + // expose a `version()` function or a `version` table. If neither exists + // we fall back to 'unknown'. + try { + const versionResult = await client.query( + `SELECT ${EQL_SCHEMA_NAME}.version() AS version`, + ) + + if (versionResult.rows.length > 0 && versionResult.rows[0].version) { + return String(versionResult.rows[0].version) + } + } catch { + // version() function does not exist — that's fine + } + + return 'unknown' + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to connect to database: ${detail}`, + { cause: error }, + ) + } finally { + await client.end() + } + } + + /** + * Install the CipherStash EQL PostgreSQL extension. + * + * Downloads the SQL install script from GitHub and executes it against the + * target database inside a transaction. The script is idempotent and safe to + * re-run. + * + * This method is intentionally "silent" — it does not produce any console + * output. The calling CLI command is responsible for all user-facing output. + */ + async install(options?: { + excludeOperatorFamily?: boolean + supabase?: boolean + }): Promise { + const { supabase = false } = options ?? {} + const excludeOperatorFamily = options?.excludeOperatorFamily || supabase + const sql = await this.downloadInstallScript(excludeOperatorFamily) + + const client = new pg.Client({ connectionString: this.databaseUrl }) + + try { + await client.connect() + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to connect to database: ${detail}`, + { cause: error }, + ) + } + + try { + await client.query('BEGIN') + await client.query(sql) + + if (supabase) { + await this.grantSupabasePermissions(client) + } + + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => { + // Swallow rollback errors — the original error is more important. + }) + + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to install EQL: ${detail}`, { + cause: error, + }) + } finally { + await client.end() + } + } + + /** + * Grant Supabase roles access to the eql_v2 schema. + * + * Supabase uses dedicated roles (anon, authenticated, service_role) that + * don't own the schema, so explicit grants are required. + */ + private async grantSupabasePermissions(client: pg.Client): Promise { + const roles = 'anon, authenticated, service_role' + + await client.query(`GRANT USAGE ON SCHEMA ${EQL_SCHEMA_NAME} TO ${roles}`) + await client.query(`GRANT ALL ON ALL TABLES IN SCHEMA ${EQL_SCHEMA_NAME} TO ${roles}`) + await client.query(`GRANT ALL ON ALL ROUTINES IN SCHEMA ${EQL_SCHEMA_NAME} TO ${roles}`) + await client.query(`GRANT ALL ON ALL SEQUENCES IN SCHEMA ${EQL_SCHEMA_NAME} TO ${roles}`) + await client.query(`ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${EQL_SCHEMA_NAME} GRANT ALL ON TABLES TO ${roles}`) + await client.query(`ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${EQL_SCHEMA_NAME} GRANT ALL ON ROUTINES TO ${roles}`) + await client.query(`ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${EQL_SCHEMA_NAME} GRANT ALL ON SEQUENCES TO ${roles}`) + } + + /** + * Download the EQL SQL install script from GitHub. + */ + private async downloadInstallScript( + excludeOperatorFamily: boolean, + ): Promise { + const url = excludeOperatorFamily + ? EQL_INSTALL_NO_OPERATOR_FAMILY_URL + : EQL_INSTALL_URL + + let response: Response + + try { + response = await fetch(url) + } catch (error) { + throw new Error('Failed to download EQL install script from GitHub.', { + cause: error, + }) + } + + if (!response.ok) { + throw new Error( + `Failed to download EQL install script from GitHub. HTTP ${response.status}: ${response.statusText}`, + ) + } + + return response.text() + } +} diff --git a/packages/stack-forge/tsconfig.json b/packages/stack-forge/tsconfig.json new file mode 100644 index 00000000..56cc3d2f --- /dev/null +++ b/packages/stack-forge/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "esModuleInterop": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/packages/stack-forge/tsup.config.ts b/packages/stack-forge/tsup.config.ts new file mode 100644 index 00000000..51122e96 --- /dev/null +++ b/packages/stack-forge/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'tsup' + +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + sourcemap: true, + dts: true, + clean: true, + target: 'es2022', + tsconfig: './tsconfig.json', + external: ['pg'], + }, + { + entry: ['src/bin/stash-forge.ts'], + outDir: 'dist/bin', + format: ['esm'], + platform: 'node', + target: 'es2022', + banner: { + js: `#!/usr/bin/env node +import { createRequire as __createRequire } from 'module'; +var require = __createRequire(import.meta.url);`, + }, + dts: false, + sourcemap: true, + external: [], + noExternal: ['dotenv', '@clack/prompts'], + }, +]) diff --git a/packages/stack-forge/vitest.config.ts b/packages/stack-forge/vitest.config.ts new file mode 100644 index 00000000..6136f50a --- /dev/null +++ b/packages/stack-forge/vitest.config.ts @@ -0,0 +1,13 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, + resolve: { + alias: { + '@/': `${resolve(__dirname, './src')}/`, + }, + }, +}) diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 635a0d0b..447cef1e 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -590,6 +590,15 @@ export class EncryptionClient { workspaceId: this.workspaceId, } } + + /** + * Get the encrypt config object. + * + * @returns The encrypt config object. + */ + getEncryptConfig(): EncryptConfig | undefined { + return this.encryptConfig + } } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 567e0d13..eecc1eec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,9 +68,15 @@ importers: specifier: workspace:* version: link:../../packages/stack dotenv: - specifier: ^16.4.7 + specifier: ^16.6.1 version: 16.6.1 + pg: + specifier: ^8.16.3 + version: 8.16.3 devDependencies: + '@cipherstash/stack-forge': + specifier: workspace:* + version: link:../../packages/stack-forge tsx: specifier: catalog:repo version: 4.19.3 @@ -293,6 +299,43 @@ importers: specifier: catalog:repo version: 3.1.3(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.19.3) + packages/stack-forge: + dependencies: + '@clack/prompts': + specifier: 0.10.1 + version: 0.10.1 + dotenv: + specifier: 16.4.7 + version: 16.4.7 + jiti: + specifier: 2.6.1 + version: 2.6.1 + pg: + specifier: ^8.16.3 + version: 8.16.3 + zod: + specifier: 3.24.2 + version: 3.24.2 + devDependencies: + '@cipherstash/stack': + specifier: workspace:* + version: link:../stack + '@types/pg': + specifier: ^8.11.11 + version: 8.16.0 + tsup: + specifier: catalog:repo + version: 8.4.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.6.3) + tsx: + specifier: catalog:repo + version: 4.19.3 + typescript: + specifier: catalog:repo + version: 5.6.3 + vitest: + specifier: catalog:repo + version: 3.1.3(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.19.3) + packages: '@apidevtools/json-schema-ref-parser@11.9.3': @@ -493,6 +536,7 @@ packages: '@clerk/clerk-react@5.59.1': resolution: {integrity: sha512-qJMRbOy0Y+0ocGxlvIYI2yQkXcLu6DIGttZ/QM8MFMqoL/K9png4rWNN/zci676ZKZZTD3I+HGD0P11MyZk7cA==} engines: {node: '>=18.17.0'} + deprecated: 'This package is no longer supported. Please use @clerk/react instead. See the upgrade guide for more info: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3' peerDependencies: react: ^18.0.0 || ^19.0.0 || ^19.0.0-0 react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-0 @@ -520,6 +564,7 @@ packages: '@clerk/types@4.101.8': resolution: {integrity: sha512-hJobP3FvvuMS5EoaQZmupmLDf5bxOv9ZDXiJ2heSoqUWZaQxLFOfzaOXTKNV9oF5yJwvYijh1cW0LxXZP2O9aA==} engines: {node: '>=18.17.0'} + deprecated: 'This package is no longer supported. Please import types from @clerk/shared/types instead. See the upgrade guide for more info: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3' '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -3558,8 +3603,7 @@ snapshots: isexe@3.1.1: {} - jiti@2.6.1: - optional: true + jiti@2.6.1: {} jose@5.10.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9ed8a08e..c978e7eb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,15 +1,13 @@ packages: - - "packages/*" - - "examples/*" + - packages/* + - examples/* catalogs: - # Can be referened as catalog:repo repo: tsup: 8.4.0 - typescript: 5.6.3 tsx: 4.19.3 + typescript: 5.6.3 vitest: 3.1.3 - security: '@clerk/nextjs': 6.31.2 next: 15.5.10 diff --git a/skills/stash-forge/SKILL.md b/skills/stash-forge/SKILL.md new file mode 100644 index 00000000..48c4fa1f --- /dev/null +++ b/skills/stash-forge/SKILL.md @@ -0,0 +1,235 @@ +# @cipherstash/stack-forge + +Configure and use `@cipherstash/stack-forge` for EQL database setup, encryption schema management, and Supabase integration. + +## Trigger + +Use this skill when: +- The user asks about setting up CipherStash EQL in a database +- Code imports `@cipherstash/stack-forge` or references `stash-forge` +- A `stash.config.ts` file exists or needs to be created +- The user wants to install, configure, or manage the EQL extension in PostgreSQL +- The user mentions "stack-forge", "stash-forge", "EQL install", or "encryption schema" + +Do NOT trigger when: +- The user is working with `@cipherstash/stack` (the runtime SDK) without needing database setup +- General PostgreSQL questions unrelated to CipherStash + +## What is @cipherstash/stack-forge? + +`@cipherstash/stack-forge` is a **dev-time CLI and TypeScript library** for managing CipherStash EQL (Encrypted Query Language) in PostgreSQL databases. It is a companion to the `@cipherstash/stack` runtime SDK — it handles database setup during development while `@cipherstash/stack` handles runtime encryption/decryption operations. + +Think of it like Prisma Migrate or Drizzle Kit: a dev-time tool that manages your database schema. + +## Configuration + +### 1. Create `stash.config.ts` in the project root + +```typescript +import { defineConfig } from '@cipherstash/stack-forge' + +export default defineConfig({ + databaseUrl: process.env.DATABASE_URL!, +}) +``` + +### Config options + +```typescript +type StashConfig = { + databaseUrl: string // Required: PostgreSQL connection string + client?: string // Optional: path to encryption client (default: './src/encryption/index.ts') + workspaceId?: string // Optional: CipherStash workspace ID + clientAccessKey?: string // Optional: CipherStash client access key +} +``` + +- `defineConfig()` provides TypeScript type-checking for the config file. +- `client` points to the encryption client file used by `stash-forge push` to load the encryption schema. +- Config is loaded automatically from `stash.config.ts` by walking up from `process.cwd()` (like `tsconfig.json` resolution). +- `.env` files are loaded automatically via `dotenv` before config evaluation. + +## CLI Usage + +The primary interface is the `stash-forge` CLI, run via `npx`: + +```bash +npx stash-forge [options] +``` + +### `install` — Install EQL extension to the database + +```bash +# Standard install +npx stash-forge install + +# Reinstall even if already installed +npx stash-forge install --force + +# Preview SQL without applying +npx stash-forge install --dry-run + +# Supabase-compatible install (grants anon, authenticated, service_role) +npx stash-forge install --supabase + +# Skip operator family (for non-superuser database roles) +npx stash-forge install --exclude-operator-family + +# Generate a Drizzle migration instead of direct install +npx stash-forge install --drizzle + +# Drizzle migration with custom name and output directory +npx stash-forge install --drizzle --name setup-eql --out ./migrations + +# Combine flags +npx stash-forge install --dry-run --supabase +``` + +**Flags:** +| Flag | Description | +|------|-------------| +| `--force` | Reinstall even if EQL is already installed | +| `--dry-run` | Print the SQL that would be executed without applying it | +| `--supabase` | Use Supabase-compatible install (no operator family + grants to Supabase roles) | +| `--exclude-operator-family` | Skip operator family creation (useful for non-superuser roles) | +| `--drizzle` | Generate a Drizzle migration instead of direct install | +| `--name ` | Migration name when using `--drizzle` (default: `install-eql`) | +| `--out ` | Drizzle output directory when using `--drizzle` (default: `drizzle`) | + +#### `install --drizzle` + +When `--drizzle` is passed, instead of connecting to the database directly, `stash-forge`: +1. Runs `drizzle-kit generate --custom --name=` to scaffold an empty migration +2. Downloads the EQL install SQL from GitHub releases +3. Writes the SQL into the generated migration file + +You then run `npx drizzle-kit migrate` to apply it. Requires `drizzle-kit` as a dev dependency. + +### `push` — Push encryption schema to database + +Load your encryption schema from the file specified by `client` in `stash.config.ts` and apply it to the database. + +```bash +# Push schema to the database +npx stash-forge push + +# Preview the schema as JSON without writing to the database +npx stash-forge push --dry-run +``` + +**Flags:** +| Flag | Description | +|------|-------------| +| `--dry-run` | Load and validate the schema, then print it as JSON. No database changes. | + +When pushing, stash-forge: +1. Loads the encryption client from the path in `stash.config.ts` +2. Builds the encrypt config from the client +3. Connects to Postgres and marks existing `eql_v2_configuration` rows as `inactive` +4. Inserts the new config as an `active` row + +### Other commands (planned, not yet implemented) + +- `init` — Initialize CipherStash Forge in your project +- `migrate` — Run pending encrypt config migrations +- `status` — Show EQL installation status + +## Programmatic API + +### `defineConfig(config: StashConfig): StashConfig` + +Identity function that provides type-safe configuration for `stash.config.ts`. + +### `loadStashConfig(): Promise` + +Finds and loads `stash.config.ts` from the current directory or any parent. Validates with Zod. Applies defaults (e.g. `client` defaults to `'./src/encryption/index.ts'`). Exits with code 1 if config is missing or invalid. + +### `loadEncryptConfig(clientPath: string): Promise` + +Loads the encryption client file, extracts the encrypt config, and returns it. Used by `push` to build the schema JSON. + +### `EQLInstaller` + +```typescript +import { EQLInstaller } from '@cipherstash/stack-forge' + +const installer = new EQLInstaller({ databaseUrl: 'postgresql://...' }) +``` + +#### `installer.checkPermissions(): Promise` + +Checks that the database role has the required permissions to install EQL. + +```typescript +type PermissionCheckResult = { + ok: boolean // true if all permissions are present + missing: string[] // list of missing permissions (empty if ok) +} +``` + +Required permissions (one of): +- `SUPERUSER` role (sufficient for everything), OR +- `CREATE` privilege on database + `CREATE` privilege on public schema +- If `pgcrypto` is not installed: also needs `SUPERUSER` or `CREATEDB` + +#### `installer.isInstalled(): Promise` + +Returns `true` if the `eql_v2` schema exists in the database. + +#### `installer.getInstalledVersion(): Promise` + +Returns the installed EQL version string, `'unknown'` if schema exists but no version metadata, or `null` if not installed. + +#### `installer.install(options?): Promise` + +Downloads and executes the EQL install SQL in a transaction. + +```typescript +await installer.install({ + excludeOperatorFamily?: boolean // Skip operator family creation + supabase?: boolean // Use Supabase-compatible install + grant roles +}) +``` + +## Full programmatic example + +```typescript +import { EQLInstaller, loadStashConfig } from '@cipherstash/stack-forge' + +const config = await loadStashConfig() +const installer = new EQLInstaller({ databaseUrl: config.databaseUrl }) + +// Check permissions first +const permissions = await installer.checkPermissions() +if (!permissions.ok) { + console.error('Missing permissions:', permissions.missing) + process.exit(1) +} + +// Install if not already present +if (await installer.isInstalled()) { + const version = await installer.getInstalledVersion() + console.log(`EQL already installed (version: ${version})`) +} else { + await installer.install() + console.log('EQL installed successfully') +} +``` + +## Requirements + +- Node.js >= 22 +- PostgreSQL database with sufficient permissions (see `checkPermissions()`) +- A `stash.config.ts` file with a valid `databaseUrl` +- Peer dependency: `@cipherstash/stack` >= 0.6.0 + +## Common issues + +### Permission errors during install +The database role needs `CREATE` privileges on the database and public schema, or `SUPERUSER`. Run `checkPermissions()` or check the CLI output for details on what's missing. + +### Config not found +`stash.config.ts` must be in the project root or a parent directory. The file must `export default defineConfig(...)`. + +### Supabase environments +Always use `--supabase` (or `supabase: true` programmatically) when targeting Supabase. This uses a compatible install script and grants permissions to `anon`, `authenticated`, and `service_role` roles.