diff --git a/.changeset/wide-pets-brush.md b/.changeset/wide-pets-brush.md new file mode 100644 index 00000000..410e4047 --- /dev/null +++ b/.changeset/wide-pets-brush.md @@ -0,0 +1,6 @@ +--- +"@cipherstash/stack": minor +"@cipherstash/cli": minor +--- + +Mark secrets as a coming soon feature and remove existing SDK integration. diff --git a/README.md b/README.md index 3dbfc55d..7e2333bd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ ## What is the stack? - [Encryption](https://cipherstash.com/docs/stack/cipherstash/encryption): Field-level encryption for TypeScript apps with searchable encrypted queries, zero-knowledge key management, and first-class ORM support. -- [Secrets](https://cipherstash.com/docs/stack/cipherstash/secrets): Zero-trust secrets management with end-to-end encryption. Plaintext never leaves your application. ## Quick look at the stack in action @@ -48,21 +47,6 @@ if (decryptResult.failure) { // decryptResult.data => "secret@example.com" ``` -**Secrets** - -```typescript -import { Secrets } from "@cipherstash/stack"; - -// 1. Initialize the secrets client -const secrets = new Secrets({ environment: "production" }); - -// 2. Set a secret with the SDK or the CLI -await secrets.set("DATABASE_URL", "postgres://user:pass@host:5432/db"); - -// 3. Consume the secret in your application -const secret = await secrets.get("DATABASE_URL"); -``` - ## Install ```bash @@ -86,7 +70,6 @@ bun add @cipherstash/stack - **[Type-safe schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema)**: define encrypted tables and columns with `encryptedTable` / `encryptedColumn` - **[Model & bulk operations](https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt#model-operations)**: encrypt and decrypt entire objects or batches with `encryptModel` / `bulkEncryptModels`. - **[Identity-aware encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/identity)**: bind encryption to user identity with lock contexts for policy-based access control. -- **[Secrets management](https://cipherstash.com/docs/stack/cipherstash/secrets)**: store and retrieve encrypted secrets via the Secrets SDK and CLI. ## Integrations @@ -97,14 +80,12 @@ bun add @cipherstash/stack ## Use cases - **Trusted data access**: ensure only your end-users can access their sensitive data using identity-bound encryption -- **Sensitive config management**: store API keys and database credentials with zero-trust encryption and full audit trails - **Reduce breach impact**: limit the blast radius of exploited vulnerabilities to only the data the affected user can decrypt ## Documentation - [Documentation](https://cipherstash.com/docs) -- [Encryption getting started guide](https://cipherstash.com/docs/stack/quickstart) -- [Secrets getting started guide](https://cipherstash.com/docs/stack/cipherstash/secrets/getting-started) +- [Quickstart](https://cipherstash.com/docs/stack/quickstart) - [SDK and API reference](https://cipherstash.com/docs/stack/reference) ## Contributing diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index 3f280088..c1b598c8 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -53,7 +53,6 @@ Usage: npx @cipherstash/cli [options] Commands: init Initialize CipherStash for your project auth Authenticate with CipherStash - secrets Manage encrypted secrets wizard AI-powered encryption setup (reads your codebase) db install Install EQL extensions into your database @@ -91,7 +90,6 @@ Examples: npx @cipherstash/cli db setup npx @cipherstash/cli db push npx @cipherstash/cli schema build - npx @cipherstash/cli secrets set -n DATABASE_URL -V "postgres://..." -e production `.trim() interface ParsedArgs { @@ -241,14 +239,6 @@ async function main() { await authCommand(authArgs, flags) break } - case 'secrets': { - const { secretsCommand } = await requireStack(() => import('../commands/secrets/index.js')) - const secretsArgs = subcommand - ? [subcommand, ...commandArgs] - : commandArgs - await secretsCommand(secretsArgs) - break - } case 'wizard': { // Lazy-load the wizard so the agent SDK is only imported when needed. const { run } = await import('../commands/wizard/run.js') diff --git a/packages/cli/src/commands/secrets/delete.ts b/packages/cli/src/commands/secrets/delete.ts deleted file mode 100644 index 6c218ffc..00000000 --- a/packages/cli/src/commands/secrets/delete.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as p from '@clack/prompts' -import { createStash, style } from './helpers.js' - -export async function deleteSecret(flags: { - name: string - environment: string - yes?: boolean -}) { - const { name, environment, yes } = flags - const stash = createStash(environment) - - if (!yes) { - const confirmed = await p.confirm({ - message: `Are you sure you want to delete secret "${name}" from environment "${environment}"? This action cannot be undone.`, - }) - - if (p.isCancel(confirmed) || !confirmed) { - console.log(style.info('Deletion cancelled.')) - return - } - } - - console.log( - `${style.info(`Deleting secret "${name}" from environment "${environment}"...`)}`, - ) - - const result = await stash.delete(name) - if (result.failure) { - console.error( - style.error(`Failed to delete secret: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log( - style.success( - `Secret "${name}" deleted successfully from environment "${environment}"`, - ), - ) -} diff --git a/packages/cli/src/commands/secrets/get-many.ts b/packages/cli/src/commands/secrets/get-many.ts deleted file mode 100644 index 17f35dce..00000000 --- a/packages/cli/src/commands/secrets/get-many.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createStash, style } from './helpers.js' - -export async function getManySecrets(flags: { - names: string[] - environment: string -}) { - const { names, environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Retrieving ${names.length} secrets from environment "${environment}"...`)}`, - ) - - const result = await stash.getMany(names) - if (result.failure) { - console.error( - style.error(`Failed to get secrets: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log(`\n${style.title('Secrets:')}\n`) - for (const [name, value] of Object.entries(result.data)) { - console.log(`${style.label(`${name}:`)}`) - console.log(`${style.value(value)}\n`) - } -} diff --git a/packages/cli/src/commands/secrets/get.ts b/packages/cli/src/commands/secrets/get.ts deleted file mode 100644 index 723efff0..00000000 --- a/packages/cli/src/commands/secrets/get.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createStash, style } from './helpers.js' - -export async function getSecret(flags: { - name: string - environment: string -}) { - const { name, environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Retrieving secret "${name}" from environment "${environment}"...`)}`, - ) - - const result = await stash.get(name) - if (result.failure) { - console.error( - style.error(`Failed to get secret: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log(`\n${style.title('Secret Value:')}`) - console.log(style.value(result.data)) -} diff --git a/packages/cli/src/commands/secrets/helpers.ts b/packages/cli/src/commands/secrets/helpers.ts deleted file mode 100644 index 08153838..00000000 --- a/packages/cli/src/commands/secrets/helpers.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { config } from 'dotenv' -config() - -import { Secrets, type SecretsConfig } from '@cipherstash/stack/secrets' - -export const colors = { - reset: '\x1b[0m', - bold: '\x1b[1m', - dim: '\x1b[2m', - green: '\x1b[32m', - red: '\x1b[31m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', - magenta: '\x1b[35m', -} - -export const style = { - success: (text: string) => - `${colors.green}${colors.bold}✓${colors.reset} ${colors.green}${text}${colors.reset}`, - error: (text: string) => - `${colors.red}${colors.bold}✗${colors.reset} ${colors.red}${text}${colors.reset}`, - info: (text: string) => - `${colors.blue}${colors.bold}ℹ${colors.reset} ${colors.blue}${text}${colors.reset}`, - warning: (text: string) => - `${colors.yellow}${colors.bold}⚠${colors.reset} ${colors.yellow}${text}${colors.reset}`, - title: (text: string) => `${colors.bold}${colors.cyan}${text}${colors.reset}`, - label: (text: string) => `${colors.dim}${text}${colors.reset}`, - value: (text: string) => `${colors.bold}${text}${colors.reset}`, - bullet: () => `${colors.green}•${colors.reset}`, -} - -export function getConfig(environment: string): SecretsConfig { - const workspaceCRN = process.env.CS_WORKSPACE_CRN - const clientId = process.env.CS_CLIENT_ID - const clientKey = process.env.CS_CLIENT_KEY - const accessKey = process.env.CS_ACCESS_KEY - - const missing: string[] = [] - if (!workspaceCRN) missing.push('CS_WORKSPACE_CRN') - if (!clientId) missing.push('CS_CLIENT_ID') - if (!clientKey) missing.push('CS_CLIENT_KEY') - - if (missing.length > 0) { - console.error( - style.error( - `Missing required environment variables: ${missing.join(', ')}`, - ), - ) - console.error( - `\n${style.info('Please set the following environment variables:')}`, - ) - for (const varName of missing) { - console.error(` ${style.bullet()} ${varName}`) - } - process.exit(1) - } - - if (!workspaceCRN || !clientId || !clientKey || !accessKey) { - throw new Error('Missing required configuration') - } - - return { - workspaceCRN, - clientId, - clientKey, - accessKey, - environment, - } -} - -export function createStash(environment: string): Secrets { - const cfg = getConfig(environment) - return new Secrets(cfg) -} diff --git a/packages/cli/src/commands/secrets/index.ts b/packages/cli/src/commands/secrets/index.ts deleted file mode 100644 index 6418ef9a..00000000 --- a/packages/cli/src/commands/secrets/index.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { deleteSecret } from './delete.js' -import { getManySecrets } from './get-many.js' -import { getSecret } from './get.js' -import { style } from './helpers.js' -import { listSecrets } from './list.js' -import { setSecret } from './set.js' - -function parseFlags(args: string[]): Record { - const flags: Record = {} - for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (arg === '--yes' || arg === '-y') { - flags.yes = true - } else if (arg.startsWith('--')) { - const key = arg.slice(2) - const next = args[i + 1] - if (next && !next.startsWith('-')) { - flags[key] = next - i++ - } - } else if (arg.startsWith('-') && arg.length === 2) { - const alias: Record = { - n: 'name', - V: 'value', - e: 'environment', - } - const key = alias[arg[1]] || arg[1] - const next = args[i + 1] - if (next && !next.startsWith('-')) { - flags[key] = next - i++ - } - } - } - return flags -} - -function requireFlag( - flags: Record, - name: string, -): string { - const val = flags[name] - if (!val || typeof val !== 'string') { - console.error(style.error(`Missing required flag: --${name}`)) - process.exit(1) - } - return val -} - -const HELP = ` -${style.title('Usage:')} npx @cipherstash/cli secrets [options] - -${style.title('Commands:')} - set Store an encrypted secret - get Retrieve and decrypt a secret - get-many Retrieve and decrypt multiple secrets (min 2, max 100) - list List all secrets in an environment - delete Delete a secret - -${style.title('Options:')} - -n, --name Secret name (comma-separated for get-many) - -V, --value Secret value (set only) - -e, --environment Environment name - -y, --yes Skip confirmation (delete only) - -${style.title('Examples:')} - npx @cipherstash/cli secrets set -n DATABASE_URL -V "postgres://..." -e production - npx @cipherstash/cli secrets get -n DATABASE_URL -e production - npx @cipherstash/cli secrets get-many -n DATABASE_URL,API_KEY -e production - npx @cipherstash/cli secrets list -e production - npx @cipherstash/cli secrets delete -n DATABASE_URL -e production -y -`.trim() - -export async function secretsCommand(args: string[]) { - const subcommand = args[0] - const rest = args.slice(1) - - if (!subcommand || subcommand === '--help' || subcommand === '-h') { - console.log(HELP) - return - } - - const flags = parseFlags(rest) - - switch (subcommand) { - case 'set': { - const name = requireFlag(flags, 'name') - const value = requireFlag(flags, 'value') - const environment = requireFlag(flags, 'environment') - await setSecret({ name, value, environment }) - break - } - case 'get': { - const name = requireFlag(flags, 'name') - const environment = requireFlag(flags, 'environment') - await getSecret({ name, environment }) - break - } - case 'get-many': { - const namesStr = requireFlag(flags, 'name') - const names = namesStr - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - if (names.length < 2) { - console.error( - style.error( - 'get-many requires at least 2 secret names (comma-separated)', - ), - ) - process.exit(1) - } - if (names.length > 100) { - console.error(style.error('get-many supports maximum 100 secret names')) - process.exit(1) - } - const environment = requireFlag(flags, 'environment') - await getManySecrets({ names, environment }) - break - } - case 'list': { - const environment = requireFlag(flags, 'environment') - await listSecrets({ environment }) - break - } - case 'delete': { - const name = requireFlag(flags, 'name') - const environment = requireFlag(flags, 'environment') - await deleteSecret({ name, environment, yes: flags.yes === true }) - break - } - default: - console.error(style.error(`Unknown secrets command: ${subcommand}`)) - console.log(HELP) - process.exit(1) - } -} diff --git a/packages/cli/src/commands/secrets/list.ts b/packages/cli/src/commands/secrets/list.ts deleted file mode 100644 index eb7ecfe1..00000000 --- a/packages/cli/src/commands/secrets/list.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { colors, createStash, style } from './helpers.js' - -export async function listSecrets(flags: { environment: string }) { - const { environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Listing secrets in environment "${environment}"...`)}`, - ) - - const result = await stash.list() - if (result.failure) { - console.error( - style.error(`Failed to list secrets: ${result.failure.message}`), - ) - process.exit(1) - } - - if (result.data.length === 0) { - console.log( - `\n${style.warning(`No secrets found in environment "${environment}"`)}`, - ) - return - } - - console.log(`\n${style.title(`Secrets in environment "${environment}":`)}\n`) - - for (const secret of result.data) { - const name = style.value(secret.name) - const metadata: string[] = [] - if (secret.createdAt) { - metadata.push( - `${style.label('created:')} ${new Date(secret.createdAt).toLocaleString()}`, - ) - } - if (secret.updatedAt) { - metadata.push( - `${style.label('updated:')} ${new Date(secret.updatedAt).toLocaleString()}`, - ) - } - - const metaStr = - metadata.length > 0 - ? ` ${colors.dim}(${metadata.join(', ')})${colors.reset}` - : '' - console.log(` ${style.bullet()} ${name}${metaStr}`) - } - - console.log( - `\n${style.label(`Total: ${result.data.length} secret${result.data.length === 1 ? '' : 's'}`)}`, - ) -} diff --git a/packages/cli/src/commands/secrets/set.ts b/packages/cli/src/commands/secrets/set.ts deleted file mode 100644 index 838467d3..00000000 --- a/packages/cli/src/commands/secrets/set.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createStash, style } from './helpers.js' - -export async function setSecret(flags: { - name: string - value: string - environment: string -}) { - const { name, value, environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Encrypting and storing secret "${name}" in environment "${environment}"...`)}`, - ) - - const result = await stash.set(name, value) - if (result.failure) { - console.error( - style.error(`Failed to set secret: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log( - style.success( - `Secret "${name}" stored successfully in environment "${environment}"`, - ), - ) -} diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 4a6f47fa..75891cfa 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -1,7 +1,6 @@ // Re-export main stack components for convenience export { encryptedTable, encryptedColumn, encryptedField } from '@/schema' export { Encryption } from '@/encryption' -export { Secrets } from '@/secrets' // Re-export encryption helpers for convenience export { diff --git a/packages/stack/src/secrets/index.ts b/packages/stack/src/secrets/index.ts deleted file mode 100644 index 4effe63a..00000000 --- a/packages/stack/src/secrets/index.ts +++ /dev/null @@ -1,593 +0,0 @@ -/** - * Placeholder: Corrected Secrets client interface - * - * This file reflects the actual dashboard API endpoints as implemented in: - * apps/dashboard/src/app/api/secrets/{get,set,list,get-many,delete}/route.ts - * - * Key corrections from the original interface: - * 1. get, list, get-many are GET endpoints (not POST) with query params - * 2. get-many takes a comma-separated `names` string (not a JSON array) - * 3. set and delete return { success, message } (not void) - * 4. SecretMetadata fields (id, createdAt, updatedAt) are non-optional - * 5. GetSecretResponse fields (createdAt, updatedAt) are non-optional - * 6. get-many enforces min 2 names (comma required) and max 100 names - */ - -import { encryptedToPgComposite } from '@/encryption/helpers' -import type { EncryptionClient } from '@/encryption/index.js' -import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' -import type { Encrypted } from '@/types' -import { logger } from '@/utils/logger' -import type { Result } from '@byteslice/result' -import { extractWorkspaceIdFromCrn } from '../utils/config/index.js' - -export type SecretName = string -export type SecretValue = string - -/** - * Discriminated error type for secrets operations. - */ -export type SecretsErrorType = - | 'ApiError' - | 'NetworkError' - | 'ClientError' - | 'EncryptionError' - | 'DecryptionError' - -/** - * Error returned by secrets operations. - */ -export interface SecretsError { - type: SecretsErrorType - message: string -} - -/** - * Configuration options for initializing the Stash client - */ -export interface SecretsConfig { - environment: string - workspaceCRN?: string - clientId?: string - clientKey?: string - accessKey?: string -} - -/** - * Secret metadata returned from the API (list endpoint). - * All fields are always present in API responses. - */ -export interface SecretMetadata { - id: string - name: string - environment: string - createdAt: string - updatedAt: string -} - -/** - * API response for listing secrets. - * GET /api/secrets/list?workspaceId=...&environment=... - */ -export interface ListSecretsResponse { - environment: string - secrets: SecretMetadata[] -} - -/** - * API response for getting a single secret. - * GET /api/secrets/get?workspaceId=...&environment=...&name=... - * - * The `encryptedValue` is the raw value stored in the vault's `value` column, - * which is the `{ data: Encrypted }` object that was passed to the set endpoint. - */ -export interface GetSecretResponse { - name: string - environment: string - encryptedValue: { - data: Encrypted - } - createdAt: string - updatedAt: string -} - -/** - * API response for getting multiple secrets. - * GET /api/secrets/get-many?workspaceId=...&environment=...&names=name1,name2,... - * - * Returns an array of GetSecretResponse objects. - * Constraints: - * - `names` must be comma-separated (minimum 2 names) - * - Maximum 100 names per request - */ -export type GetManySecretsResponse = GetSecretResponse[] - -/** - * API response for setting a secret. - * POST /api/secrets/set - */ -export interface SetSecretResponse { - success: true - message: string -} - -/** - * API request body for setting a secret. - * POST /api/secrets/set - */ -export interface SetSecretRequest { - workspaceId: string - environment: string - name: string - encryptedValue: { - data: Encrypted - } -} - -/** - * API response for deleting a secret. - * POST /api/secrets/delete - */ -export interface DeleteSecretResponse { - success: true - message: string -} - -/** - * API request body for deleting a secret. - * POST /api/secrets/delete - */ -export interface DeleteSecretRequest { - workspaceId: string - environment: string - name: string -} - -/** - * API error response for plan limit violations (403). - * Returned by POST /api/secrets/set when the workspace has reached its secret limit. - */ -export interface PlanLimitError { - error: string - code: 'PLAN_LIMIT_REACHED' -} - -export interface DecryptedSecretResponse { - name: string - environment: string - value: string - createdAt: string - updatedAt: string -} - -/** - * The Secrets client provides a high-level API for managing encrypted secrets - * stored in CipherStash. Secrets are encrypted locally before being sent to - * the API, ensuring end-to-end encryption. - */ -export class Secrets { - private encryptionClient: EncryptionClient | null = null - private config: Required - private readonly apiBaseUrl = - process.env.STASH_API_URL || 'https://dashboard.cipherstash.com/api/secrets' - private readonly secretsSchema = encryptedTable('secrets', { - value: encryptedColumn('value'), - }) - - constructor(config: SecretsConfig) { - const workspaceCRN = config.workspaceCRN ?? process.env.CS_WORKSPACE_CRN - const clientId = config.clientId ?? process.env.CS_CLIENT_ID - const clientKey = config.clientKey ?? process.env.CS_CLIENT_KEY - const accessKey = config.accessKey ?? process.env.CS_CLIENT_ACCESS_KEY - - if (!workspaceCRN || !clientId || !clientKey || !accessKey) { - throw new Error( - 'Missing required configuration or environment variables.', - ) - } - - this.config = { - environment: config.environment, - workspaceCRN, - clientId, - clientKey, - accessKey, - } - } - - private initPromise: Promise | null = null - - /** - * Initialize the Secrets client and underlying Encryption client - */ - private async ensureInitialized(): Promise { - if (!this.initPromise) { - this.initPromise = this._doInit() - } - return this.initPromise - } - - private async _doInit(): Promise { - logger.debug('Initializing the Secrets client.') - - this.encryptionClient = await Encryption({ - schemas: [this.secretsSchema], - config: { - workspaceCrn: this.config.workspaceCRN, - clientId: this.config.clientId, - clientKey: this.config.clientKey, - accessKey: this.config.accessKey, - keyset: { name: this.config.environment }, - }, - }) - - logger.debug('Successfully initialized the Secrets client.') - } - - /** - * Get the authorization header for API requests - */ - private getAuthHeader(): string { - return `Bearer ${this.config.accessKey}` - } - - /** - * Make an API request with error handling. - * - * For GET requests, `params` are appended as URL query parameters. - * For POST requests, `body` is sent as JSON in the request body. - */ - private async apiRequest( - method: 'GET' | 'POST', - path: string, - options?: { - body?: unknown - params?: Record - }, - ): Promise> { - try { - let url = `${this.apiBaseUrl}${path}` - - if (options?.params) { - const searchParams = new URLSearchParams(options.params) - url = `${url}?${searchParams.toString()}` - } - - logger.debug(`Secrets API request: ${method} ${path}`) - - const headers: Record = { - 'Content-Type': 'application/json', - Authorization: this.getAuthHeader(), - } - - const response = await fetch(url, { - method, - headers, - body: options?.body ? JSON.stringify(options.body) : undefined, - }) - - if (!response.ok) { - const errorText = await response.text() - let errorMessage = `API request failed with status ${response.status}` - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.message || errorJson.error || errorMessage - } catch { - errorMessage = errorText || errorMessage - } - - logger.error(`Secrets API error on ${method} ${path}: ${errorMessage}`) - - return { - failure: { - type: 'ApiError', - message: errorMessage, - }, - } - } - - logger.debug(`Secrets API request successful: ${method} ${path}`) - - const data = await response.json() - return { data } - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Unknown network error occurred' - - logger.error(`Secrets network error on ${method} ${path}: ${message}`) - - return { - failure: { - type: 'NetworkError', - message, - }, - } - } - } - - /** - * Store an encrypted secret in the vault. - * The value is encrypted locally before being sent to the API. - * - * API: POST /api/secrets/set - * - * @param name - The name of the secret - * @param value - The plaintext value to encrypt and store - * @returns A Result containing the API response or an error - */ - async set( - name: SecretName, - value: SecretValue, - ): Promise> { - logger.debug('Setting secret') - - await this.ensureInitialized() - - if (!this.encryptionClient) { - return { - failure: { - type: 'ClientError', - message: 'Failed to initialize Encryption client', - }, - } - } - - // Encrypt the value locally - const encryptResult = await this.encryptionClient.encrypt(value, { - column: this.secretsSchema.value, - table: this.secretsSchema, - }) - - if (encryptResult.failure) { - logger.error('Failed to encrypt secret') - return { - failure: { - type: 'EncryptionError', - message: encryptResult.failure.message, - }, - } - } - - // Extract workspaceId from CRN - const workspaceId = extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - // Send encrypted value to API - return await this.apiRequest('POST', '/set', { - body: { - workspaceId, - environment: this.config.environment, - name, - encryptedValue: encryptedToPgComposite(encryptResult.data), - }, - }) - } - - /** - * Retrieve and decrypt a secret from the vault. - * The secret is decrypted locally after retrieval. - * - * API: GET /api/secrets/get?workspaceId=...&environment=...&name=... - * - * @param name - The name of the secret to retrieve - * @returns A Result containing the decrypted value or an error - */ - async get(name: SecretName): Promise> { - logger.debug('Getting secret') - - await this.ensureInitialized() - - if (!this.encryptionClient) { - return { - failure: { - type: 'ClientError', - message: 'Failed to initialize Encryption client', - }, - } - } - - // Extract workspaceId from CRN - const workspaceId = extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - // Fetch encrypted value from API via GET with query params - const apiResult = await this.apiRequest('GET', '/get', { - params: { - workspaceId, - environment: this.config.environment, - name, - }, - }) - - if (apiResult.failure) { - return apiResult - } - - // Decrypt the value locally - const decryptResult = await this.encryptionClient.decrypt( - apiResult.data.encryptedValue.data, - ) - - if (decryptResult.failure) { - logger.error('Failed to decrypt secret') - return { - failure: { - type: 'DecryptionError', - message: decryptResult.failure.message, - }, - } - } - - if (typeof decryptResult.data !== 'string') { - logger.error('Decrypted secret value is not a string') - return { - failure: { - type: 'DecryptionError', - message: 'Decrypted value is not a string', - }, - } - } - - return { data: decryptResult.data } - } - - /** - * Retrieve and decrypt many secrets from the vault. - * The secrets are decrypted locally after retrieval. - * This method only triggers a single network request to the ZeroKMS. - * - * API: GET /api/secrets/get-many?workspaceId=...&environment=...&names=name1,name2,... - * - * Constraints: - * - Minimum 2 secret names required - * - Maximum 100 secret names per request - * - * @param names - The names of the secrets to retrieve (min 2, max 100) - * @returns A Result containing an object mapping secret names to their decrypted values - */ - async getMany( - names: SecretName[], - ): Promise, SecretsError>> { - logger.debug(`Getting ${names.length} secrets.`) - - await this.ensureInitialized() - - if (!this.encryptionClient) { - return { - failure: { - type: 'ClientError', - message: 'Failed to initialize Encryption client', - }, - } - } - - if (names.length < 2) { - return { - failure: { - type: 'ClientError', - message: 'At least 2 secret names are required for getMany', - }, - } - } - - if (names.length > 100) { - return { - failure: { - type: 'ClientError', - message: 'Maximum 100 secret names per request', - }, - } - } - - // Extract workspaceId from CRN - const workspaceId = extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - // Fetch encrypted values from API via GET with comma-separated names - const apiResult = await this.apiRequest( - 'GET', - '/get-many', - { - params: { - workspaceId, - environment: this.config.environment, - names: names.join(','), - }, - }, - ) - - if (apiResult.failure) { - return apiResult - } - - const dataToDecrypt = apiResult.data.map((item) => ({ - name: item.name, - value: item.encryptedValue.data, - })) - - const decryptResult = - await this.encryptionClient.bulkDecryptModels(dataToDecrypt) - - if (decryptResult.failure) { - logger.error( - `Failed to decrypt secrets: ${decryptResult.failure.message}`, - ) - return { - failure: { - type: 'DecryptionError', - message: decryptResult.failure.message, - }, - } - } - - // Transform array of decrypted secrets into an object keyed by secret name - const decryptedSecrets = - decryptResult.data as unknown as DecryptedSecretResponse[] - const secretsMap: Record = {} - - for (const secret of decryptedSecrets) { - if (secret.name && secret.value) { - secretsMap[secret.name] = secret.value - } - } - - return { data: secretsMap } - } - - /** - * List all secrets in the environment. - * Only names and metadata are returned; values remain encrypted. - * - * API: GET /api/secrets/list?workspaceId=...&environment=... - * - * @returns A Result containing the list of secrets or an error - */ - async list(): Promise> { - logger.debug('Listing secrets.') - - // Extract workspaceId from CRN - const workspaceId = extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - const apiResult = await this.apiRequest( - 'GET', - '/list', - { - params: { - workspaceId, - environment: this.config.environment, - }, - }, - ) - - if (apiResult.failure) { - return apiResult - } - - return { data: apiResult.data.secrets } - } - - /** - * Delete a secret from the vault. - * - * API: POST /api/secrets/delete - * - * @param name - The name of the secret to delete - * @returns A Result containing the API response or an error - */ - async delete( - name: SecretName, - ): Promise> { - logger.debug('Deleting secret') - - // Extract workspaceId from CRN - const workspaceId = extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - return await this.apiRequest('POST', '/delete', { - body: { - workspaceId, - environment: this.config.environment, - name, - }, - }) - } -}