mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
feat(starter): sync encrypted envvars to db on-save in settings modal, fetch during scheduled execution & decrypt
This commit is contained in:
@@ -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<string, any>
|
||||
Promise.resolve({} as Record<string, any>)
|
||||
)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Record<string, any>>
|
||||
Promise.resolve({} as Record<string, Record<string, any>>)
|
||||
)
|
||||
|
||||
// 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<string, any>
|
||||
Promise.resolve({} as Record<string, any>)
|
||||
)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Record<string, any>>
|
||||
Promise.resolve({} as Record<string, Record<string, any>>)
|
||||
)
|
||||
|
||||
// Serialize and execute the workflow
|
||||
|
||||
@@ -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<string, EnvironmentVariable>
|
||||
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<string, string> = {}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
1
db/migrations/0007_mute_stepford_cuckoos.sql
Normal file
1
db/migrations/0007_mute_stepford_cuckoos.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow_schedule" ADD CONSTRAINT "workflow_schedule_workflow_id_unique" UNIQUE("workflow_id");
|
||||
684
db/migrations/meta/0007_snapshot.json
Normal file
684
db/migrations/meta/0007_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
66
lib/utils.ts
66
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(
|
||||
|
||||
@@ -34,6 +34,32 @@ export const useEnvironmentStore = create<EnvironmentStore>()(
|
||||
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',
|
||||
|
||||
@@ -13,4 +13,5 @@ export interface EnvironmentStore extends EnvironmentState {
|
||||
clearVariables: () => void
|
||||
getVariable: (key: string) => string | undefined
|
||||
getAllVariables: () => Record<string, EnvironmentVariable>
|
||||
syncWithDatabase: () => Promise<void>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user