project-env.ts140 lines · main
| 1 | import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; |
| 2 | |
| 3 | import { newId, NotFoundError, ValidationError } from '@briven/shared'; |
| 4 | import { and, eq } from 'drizzle-orm'; |
| 5 | |
| 6 | import { getDb } from '../db/client.js'; |
| 7 | import { projectEnvVars } from '../db/schema.js'; |
| 8 | import { env } from '../env.js'; |
| 9 | |
| 10 | /** |
| 11 | * Per-project env vars. Values are encrypted at rest with AES-256-GCM. The |
| 12 | * KEK is `BRIVEN_ENCRYPTION_KEY` — rotating it means reading every ciphertext |
| 13 | * with the old key and re-writing with the new, handled by a Phase 3 rotate |
| 14 | * script (not yet). |
| 15 | * |
| 16 | * IV format: `<12-byte-iv><16-byte-tag><ciphertext>`, base64-encoded. |
| 17 | */ |
| 18 | |
| 19 | const KEY_RE = /^[A-Z_][A-Z0-9_]{0,63}$/; |
| 20 | |
| 21 | function key(): Buffer { |
| 22 | const raw = env.BRIVEN_ENCRYPTION_KEY; |
| 23 | if (!raw) { |
| 24 | throw new ValidationError('BRIVEN_ENCRYPTION_KEY is not configured'); |
| 25 | } |
| 26 | // Accept either a raw 32-byte hex string OR any-length secret (hash it). |
| 27 | if (/^[0-9a-f]{64}$/i.test(raw)) return Buffer.from(raw, 'hex'); |
| 28 | return createHash('sha256').update(raw).digest(); |
| 29 | } |
| 30 | |
| 31 | export function encryptValue(plaintext: string): string { |
| 32 | const iv = randomBytes(12); |
| 33 | const cipher = createCipheriv('aes-256-gcm', key(), iv); |
| 34 | const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); |
| 35 | const tag = cipher.getAuthTag(); |
| 36 | return Buffer.concat([iv, tag, enc]).toString('base64'); |
| 37 | } |
| 38 | |
| 39 | export function decryptValue(stored: string): string { |
| 40 | const buf = Buffer.from(stored, 'base64'); |
| 41 | const iv = buf.subarray(0, 12); |
| 42 | const tag = buf.subarray(12, 28); |
| 43 | const ciphertext = buf.subarray(28); |
| 44 | const decipher = createDecipheriv('aes-256-gcm', key(), iv); |
| 45 | decipher.setAuthTag(tag); |
| 46 | return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); |
| 47 | } |
| 48 | |
| 49 | export interface MaskedEnvVar { |
| 50 | id: string; |
| 51 | key: string; |
| 52 | lastFour: string; |
| 53 | createdAt: Date; |
| 54 | updatedAt: Date; |
| 55 | } |
| 56 | |
| 57 | function mask(plaintext: string): string { |
| 58 | return plaintext.length <= 4 ? plaintext : plaintext.slice(-4); |
| 59 | } |
| 60 | |
| 61 | export async function listEnvForProject(projectId: string): Promise<MaskedEnvVar[]> { |
| 62 | const db = getDb(); |
| 63 | const rows = await db |
| 64 | .select() |
| 65 | .from(projectEnvVars) |
| 66 | .where(eq(projectEnvVars.projectId, projectId)); |
| 67 | return rows.map((r) => ({ |
| 68 | id: r.id, |
| 69 | key: r.key, |
| 70 | lastFour: mask(decryptValue(r.encryptedValue)), |
| 71 | createdAt: r.createdAt, |
| 72 | updatedAt: r.updatedAt, |
| 73 | })); |
| 74 | } |
| 75 | |
| 76 | export async function getPlainEnvForProject(projectId: string): Promise<Record<string, string>> { |
| 77 | const db = getDb(); |
| 78 | const rows = await db |
| 79 | .select() |
| 80 | .from(projectEnvVars) |
| 81 | .where(eq(projectEnvVars.projectId, projectId)); |
| 82 | const out: Record<string, string> = {}; |
| 83 | for (const r of rows) out[r.key] = decryptValue(r.encryptedValue); |
| 84 | return out; |
| 85 | } |
| 86 | |
| 87 | export async function upsertEnvVar(input: { |
| 88 | projectId: string; |
| 89 | key: string; |
| 90 | value: string; |
| 91 | createdBy: string | null; |
| 92 | }): Promise<void> { |
| 93 | if (!KEY_RE.test(input.key)) { |
| 94 | throw new ValidationError( |
| 95 | 'env var key must be uppercase letters, digits and underscores, starting with a letter or underscore', |
| 96 | { key: input.key }, |
| 97 | ); |
| 98 | } |
| 99 | const db = getDb(); |
| 100 | const encryptedValue = encryptValue(input.value); |
| 101 | await db |
| 102 | .insert(projectEnvVars) |
| 103 | .values({ |
| 104 | id: newId('ev'), |
| 105 | projectId: input.projectId, |
| 106 | key: input.key, |
| 107 | encryptedValue, |
| 108 | createdBy: input.createdBy, |
| 109 | }) |
| 110 | .onConflictDoUpdate({ |
| 111 | target: [projectEnvVars.projectId, projectEnvVars.key], |
| 112 | set: { encryptedValue, updatedAt: new Date() }, |
| 113 | }); |
| 114 | } |
| 115 | |
| 116 | export async function deleteEnvVar(projectId: string, envVarId: string): Promise<void> { |
| 117 | const db = getDb(); |
| 118 | const [row] = await db |
| 119 | .select() |
| 120 | .from(projectEnvVars) |
| 121 | .where(and(eq(projectEnvVars.id, envVarId), eq(projectEnvVars.projectId, projectId))) |
| 122 | .limit(1); |
| 123 | if (!row) throw new NotFoundError('env_var', envVarId); |
| 124 | await db.delete(projectEnvVars).where(eq(projectEnvVars.id, envVarId)); |
| 125 | } |
| 126 | |
| 127 | export async function deleteEnvVarByKey( |
| 128 | projectId: string, |
| 129 | key: string, |
| 130 | ): Promise<{ id: string; key: string }> { |
| 131 | const db = getDb(); |
| 132 | const [row] = await db |
| 133 | .select() |
| 134 | .from(projectEnvVars) |
| 135 | .where(and(eq(projectEnvVars.key, key), eq(projectEnvVars.projectId, projectId))) |
| 136 | .limit(1); |
| 137 | if (!row) throw new NotFoundError('env_var', key); |
| 138 | await db.delete(projectEnvVars).where(eq(projectEnvVars.id, row.id)); |
| 139 | return { id: row.id, key: row.key }; |
| 140 | } |