From d5f102d41929e5c39bbad9a501579bebb74f5ccf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 19 Feb 2025 12:35:30 -0800 Subject: [PATCH] feat(starter): sync encrypted envvars to db on-save in settings modal, fetch during scheduled execution & decrypt --- app/api/scheduled/execute/route.ts | 111 ++- app/api/settings/environment/route.ts | 61 +- .../components/environment/environment.tsx | 30 +- db/migrations/0007_mute_stepford_cuckoos.sql | 1 + db/migrations/meta/0007_snapshot.json | 684 ++++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema.ts | 3 +- lib/utils.ts | 66 +- stores/settings/environment/store.ts | 26 + stores/settings/environment/types.ts | 1 + 10 files changed, 921 insertions(+), 69 deletions(-) create mode 100644 db/migrations/0007_mute_stepford_cuckoos.sql create mode 100644 db/migrations/meta/0007_snapshot.json diff --git a/app/api/scheduled/execute/route.ts b/app/api/scheduled/execute/route.ts index 5cc24071f..7213a8edb 100644 --- a/app/api/scheduled/execute/route.ts +++ b/app/api/scheduled/execute/route.ts @@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from 'next/server' import { Cron } from 'croner' import { eq, lte } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' import { persistLog } from '@/lib/logging' +import { decryptSecret } from '@/lib/utils' import { BlockState, WorkflowState } from '@/stores/workflow/types' import { mergeSubblockState } from '@/stores/workflow/utils' import { db } from '@/db' -import { workflow, workflowSchedule } from '@/db/schema' +import { userEnvironment, workflow, workflowSchedule } from '@/db/schema' import { Executor } from '@/executor' import { Serializer } from '@/serializer' @@ -114,6 +116,9 @@ function calculateNextRunTime( } } +// Define the schema for environment variables +const EnvVarsSchema = z.record(z.string()) + export const config = { runtime: 'edge', schedule: '*/1 * * * *', @@ -161,18 +166,56 @@ export async function POST(req: NextRequest) { // Use the same execution flow as in use-workflow-execution.ts const mergedStates = mergeSubblockState(blocks) - const currentBlockStates = Object.entries(mergedStates).reduce( - (acc, [id, block]) => { - acc[id] = Object.entries(block.subBlocks).reduce( - (subAcc, [key, subBlock]) => { - subAcc[key] = subBlock.value + + // Retrieve environment variables for this user + const [userEnv] = await db + .select() + .from(userEnvironment) + .where(eq(userEnvironment.userId, workflowRecord.userId)) + .limit(1) + + if (!userEnv) { + throw new Error('No environment variables found for this user') + } + + // Parse and validate environment variables + const variables = EnvVarsSchema.parse(userEnv.variables) + + // Replace environment variables in the block states + const currentBlockStates = await Object.entries(mergedStates).reduce( + async (accPromise, [id, block]) => { + const acc = await accPromise + acc[id] = await Object.entries(block.subBlocks).reduce( + async (subAccPromise, [key, subBlock]) => { + const subAcc = await subAccPromise + let value = subBlock.value + + // If the value is a string and contains environment variable syntax + if (typeof value === 'string' && value.includes('{{') && value.includes('}}')) { + const matches = value.match(/{{([^}]+)}}/g) + if (matches) { + // Process all matches sequentially + for (const match of matches) { + const varName = match.slice(2, -2) // Remove {{ and }} + const encryptedValue = variables[varName] + if (!encryptedValue) { + throw new Error(`Environment variable "${varName}" was not found`) + } + // Decrypt the value + const { decrypted } = await decryptSecret(encryptedValue) + value = (value as string).replace(match, decrypted) + } + } + } + + subAcc[key] = value return subAcc }, - {} as Record + Promise.resolve({} as Record) ) return acc }, - {} as Record> + Promise.resolve({} as Record>) ) // Serialize and execute the workflow @@ -265,18 +308,56 @@ export async function GET(req: NextRequest) { // Use the same execution flow as in use-workflow-execution.ts const mergedStates = mergeSubblockState(blocks) - const currentBlockStates = Object.entries(mergedStates).reduce( - (acc, [id, block]) => { - acc[id] = Object.entries(block.subBlocks).reduce( - (subAcc, [key, subBlock]) => { - subAcc[key] = subBlock.value + + // Retrieve environment variables for this user + const [userEnv] = await db + .select() + .from(userEnvironment) + .where(eq(userEnvironment.userId, workflowRecord.userId)) + .limit(1) + + if (!userEnv) { + throw new Error('No environment variables found for this user') + } + + // Parse and validate environment variables + const variables = EnvVarsSchema.parse(userEnv.variables) + + // Replace environment variables in the block states + const currentBlockStates = await Object.entries(mergedStates).reduce( + async (accPromise, [id, block]) => { + const acc = await accPromise + acc[id] = await Object.entries(block.subBlocks).reduce( + async (subAccPromise, [key, subBlock]) => { + const subAcc = await subAccPromise + let value = subBlock.value + + // If the value is a string and contains environment variable syntax + if (typeof value === 'string' && value.includes('{{') && value.includes('}}')) { + const matches = value.match(/{{([^}]+)}}/g) + if (matches) { + // Process all matches sequentially + for (const match of matches) { + const varName = match.slice(2, -2) // Remove {{ and }} + const encryptedValue = variables[varName] + if (!encryptedValue) { + throw new Error(`Environment variable "${varName}" was not found`) + } + // Decrypt the value + const { decrypted } = await decryptSecret(encryptedValue) + value = (value as string).replace(match, decrypted) + } + } + } + + subAcc[key] = value return subAcc }, - {} as Record + Promise.resolve({} as Record) ) return acc }, - {} as Record> + Promise.resolve({} as Record>) ) // Serialize and execute the workflow diff --git a/app/api/settings/environment/route.ts b/app/api/settings/environment/route.ts index a0a7d54d7..3d94eb9f7 100644 --- a/app/api/settings/environment/route.ts +++ b/app/api/settings/environment/route.ts @@ -1,54 +1,61 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { eq } from 'drizzle-orm' -import { nanoid } from 'nanoid' import { z } from 'zod' -import { hashSecret } from '@/lib/utils' +import { getSession } from '@/lib/auth' +import { encryptSecret } from '@/lib/utils' import { EnvironmentVariable } from '@/stores/settings/environment/types' import { db } from '@/db' import { userEnvironment } from '@/db/schema' -const EnvironmentSchema = z.object({ - userId: z.string(), - data: z.string(), // A JSON stringified object of envvars +// Schema for environment variable updates +const EnvVarSchema = z.object({ + variables: z.record(z.string()), }) -export async function POST(request: Request) { +export async function POST(req: NextRequest) { try { - const body = await request.json() - const { userId, data } = EnvironmentSchema.parse(body) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Parse the incoming JSON string - const parsedData = JSON.parse(data) as Record + const body = await req.json() + const { variables } = EnvVarSchema.parse(body) - // Hash all environment variables with unique salts - const securedData = await Promise.all( - Object.entries(parsedData).map(async ([key, value]) => { - const { hash, salt } = await hashSecret(value.value) - return [key, { key, value: hash, salt }] - }) - ) + // Encrypt each environment variable value + const encryptedVariables: Record = {} + for (const [key, value] of Object.entries(variables)) { + const { encrypted } = await encryptSecret(value) + encryptedVariables[key] = encrypted + } - // Store the hashed values + // Upsert the environment variables await db .insert(userEnvironment) .values({ - id: nanoid(), - userId, - variables: Object.fromEntries(securedData), + id: crypto.randomUUID(), + userId: session.user.id, + variables: encryptedVariables, updatedAt: new Date(), }) .onConflictDoUpdate({ target: [userEnvironment.userId], set: { - variables: Object.fromEntries(securedData), + variables: encryptedVariables, updatedAt: new Date(), }, }) - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error: any) { - console.error('Environment update error:', error) - return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error updating environment variables:', error) + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + return NextResponse.json({ error: 'Failed to update environment variables' }, { status: 500 }) } } diff --git a/app/w/components/sidebar/components/settings-modal/components/environment/environment.tsx b/app/w/components/sidebar/components/settings-modal/components/environment/environment.tsx index 31ab6fe5e..06dda8479 100644 --- a/app/w/components/sidebar/components/settings-modal/components/environment/environment.tsx +++ b/app/w/components/sidebar/components/settings-modal/components/environment/environment.tsx @@ -169,19 +169,27 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps setEnvVars(newEnvVars.length ? newEnvVars : [INITIAL_ENV_VAR]) } - const handleSave = () => { - const validVars = envVars.filter((v) => v.key && v.value) - validVars.forEach((v) => setVariable(v.key, v.value)) + const handleSave = async () => { + try { + const validVars = envVars.filter((v) => v.key && v.value) + validVars.forEach((v) => setVariable(v.key, v.value)) - const currentKeys = new Set(validVars.map((v) => v.key)) - Object.keys(variables).forEach((key) => { - if (!currentKeys.has(key)) { - removeVariable(key) - } - }) + const currentKeys = new Set(validVars.map((v) => v.key)) + Object.keys(variables).forEach((key) => { + if (!currentKeys.has(key)) { + removeVariable(key) + } + }) - setShowUnsavedChanges(false) - onOpenChange(false) + // Sync with database + await useEnvironmentStore.getState().syncWithDatabase() + + setShowUnsavedChanges(false) + onOpenChange(false) + } catch (error) { + console.error('Failed to save environment variables:', error) + // You might want to show an error notification here + } } const renderEnvVarRow = (envVar: UIEnvironmentVariable, index: number) => ( diff --git a/db/migrations/0007_mute_stepford_cuckoos.sql b/db/migrations/0007_mute_stepford_cuckoos.sql new file mode 100644 index 000000000..52e4ab47a --- /dev/null +++ b/db/migrations/0007_mute_stepford_cuckoos.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow_schedule" ADD CONSTRAINT "workflow_schedule_workflow_id_unique" UNIQUE("workflow_id"); \ No newline at end of file diff --git a/db/migrations/meta/0007_snapshot.json b/db/migrations/meta/0007_snapshot.json new file mode 100644 index 000000000..dbae297c2 --- /dev/null +++ b/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,684 @@ +{ + "id": "84e3d05a-f1f6-4d4f-9176-eb629f21837c", + "prevId": "6ee96eef-e39f-4051-83dc-74ec54eebc20", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_logs": { + "name": "workflow_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_logs_workflow_id_workflow_id_fk": { + "name": "workflow_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "general": { + "name": "general", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_schedule_workflow_id_unique": { + "name": "workflow_schedule_workflow_id_unique", + "nullsNotDistinct": false, + "columns": ["workflow_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 7790d9d48..e79f2c2d5 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1739939661615, "tag": "0006_plain_zzzax", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1739995526085, + "tag": "0007_mute_stepford_cuckoos", + "breakpoints": true } ] } diff --git a/db/schema.ts b/db/schema.ts index acc5ef7cc..6e634e405 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -106,7 +106,8 @@ export const workflowSchedule = pgTable('workflow_schedule', { id: text('id').primaryKey(), workflowId: text('workflow_id') .notNull() - .references(() => workflow.id, { onDelete: 'cascade' }), + .references(() => workflow.id, { onDelete: 'cascade' }) + .unique(), cronExpression: text('cron_expression'), nextRunAt: timestamp('next_run_at'), lastRanAt: timestamp('last_ran_at'), diff --git a/lib/utils.ts b/lib/utils.ts index 933fa577b..1173902d0 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,27 +1,63 @@ import { type ClassValue, clsx } from 'clsx' -import { createHash } from 'crypto' +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } +function getEncryptionKey(): Buffer { + const key = process.env.ENCRYPTION_KEY + if (!key || key.length !== 64) { + throw new Error('ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes)') + } + return Buffer.from(key, 'hex') +} + /** - * Hashes a secret using SHA-256 with a salt - * @param secret - The secret to hash - * @param salt - Optional salt to use for hashing. If not provided, a random salt will be generated - * @returns A promise that resolves to an object containing the hashed secret and salt + * Encrypts a secret using AES-256-GCM + * @param secret - The secret to encrypt + * @returns A promise that resolves to an object containing the encrypted secret and IV */ -export async function hashSecret( - secret: string, - salt?: string -): Promise<{ hash: string; salt: string }> { - const useSalt = - salt || createHash('sha256').update(crypto.randomUUID()).digest('hex').slice(0, 16) - const hash = createHash('sha256') - .update(secret + useSalt) - .digest('hex') - return { hash, salt: useSalt } +export async function encryptSecret(secret: string): Promise<{ encrypted: string; iv: string }> { + const iv = randomBytes(16) + const key = getEncryptionKey() + + const cipher = createCipheriv('aes-256-gcm', key, iv) + let encrypted = cipher.update(secret, 'utf8', 'hex') + encrypted += cipher.final('hex') + + const authTag = cipher.getAuthTag() + + // Format: iv:encrypted:authTag + return { + encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`, + iv: iv.toString('hex'), + } +} + +/** + * Decrypts an encrypted secret + * @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" + * @returns A promise that resolves to an object containing the decrypted secret + */ +export async function decryptSecret(encryptedValue: string): Promise<{ decrypted: string }> { + const [ivHex, encrypted, authTagHex] = encryptedValue.split(':') + if (!ivHex || !encrypted || !authTagHex) { + throw new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"') + } + + const key = getEncryptionKey() + const iv = Buffer.from(ivHex, 'hex') + const authTag = Buffer.from(authTagHex, 'hex') + + const decipher = createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(authTag) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return { decrypted } } export function convertScheduleOptionsToCron( diff --git a/stores/settings/environment/store.ts b/stores/settings/environment/store.ts index 05cb22751..36b0a11ba 100644 --- a/stores/settings/environment/store.ts +++ b/stores/settings/environment/store.ts @@ -34,6 +34,32 @@ export const useEnvironmentStore = create()( getAllVariables: () => { return get().variables }, + + syncWithDatabase: async () => { + const variables = get().variables + const variableValues = Object.entries(variables).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.value, + }), + {} + ) + + try { + const response = await fetch('/api/settings/environment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ variables: variableValues }), + }) + + if (!response.ok) { + throw new Error('Failed to sync environment variables') + } + } catch (error) { + console.error('Error syncing environment variables:', error) + throw error + } + }, }), { name: 'environment-store', diff --git a/stores/settings/environment/types.ts b/stores/settings/environment/types.ts index dc199be91..f608131e6 100644 --- a/stores/settings/environment/types.ts +++ b/stores/settings/environment/types.ts @@ -13,4 +13,5 @@ export interface EnvironmentStore extends EnvironmentState { clearVariables: () => void getVariable: (key: string) => string | undefined getAllVariables: () => Record + syncWithDatabase: () => Promise }