diff --git a/.claude/rules/sim-testing.md b/.claude/rules/sim-testing.md index 4e281694493..850d0fc7643 100644 --- a/.claude/rules/sim-testing.md +++ b/.claude/rules/sim-testing.md @@ -144,7 +144,7 @@ vi.useFakeTimers() | `@/app/api/auth/oauth/utils` | `authOAuthUtilsMock`, `authOAuthUtilsMockFns` | `vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)` | | `@/app/api/knowledge/utils` | `knowledgeApiUtilsMock`, `knowledgeApiUtilsMockFns` | `vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)` | | `@/app/api/workflows/utils` | `workflowsApiUtilsMock`, `workflowsApiUtilsMockFns` | `vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)` | -| `@/lib/audit/log` | `auditMock`, `auditMockFns` | `vi.mock('@/lib/audit/log', () => auditMock)` | +| `@sim/audit` | `auditMock`, `auditMockFns` | `vi.mock('@sim/audit', () => auditMock)` | | `@/lib/auth` | `authMock`, `authMockFns` | `vi.mock('@/lib/auth', () => authMock)` | | `@/lib/auth/hybrid` | `hybridAuthMock`, `hybridAuthMockFns` | `vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)` | | `@/lib/copilot/request/http` | `copilotHttpMock`, `copilotHttpMockFns` | `vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)` | diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index 7e3e4806645..c5e6803e778 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -144,7 +144,7 @@ vi.useFakeTimers() | `@/app/api/auth/oauth/utils` | `authOAuthUtilsMock`, `authOAuthUtilsMockFns` | `vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)` | | `@/app/api/knowledge/utils` | `knowledgeApiUtilsMock`, `knowledgeApiUtilsMockFns` | `vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)` | | `@/app/api/workflows/utils` | `workflowsApiUtilsMock`, `workflowsApiUtilsMockFns` | `vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)` | -| `@/lib/audit/log` | `auditMock`, `auditMockFns` | `vi.mock('@/lib/audit/log', () => auditMock)` | +| `@sim/audit` | `auditMock`, `auditMockFns` | `vi.mock('@sim/audit', () => auditMock)` | | `@/lib/auth` | `authMock`, `authMockFns` | `vi.mock('@/lib/auth', () => authMock)` | | `@/lib/auth/hybrid` | `hybridAuthMock`, `hybridAuthMockFns` | `vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)` | | `@/lib/copilot/request/http` | `copilotHttpMock`, `copilotHttpMockFns` | `vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)` | diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index f77473f3a86..c7ceff427e6 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -71,7 +71,7 @@ fi # Set up environment variables if .env doesn't exist for the sim app if [ ! -f "apps/sim/.env" ]; then - echo "šŸ“„ Creating .env file from template..." + echo "šŸ“„ Creating apps/sim/.env from template..." if [ -f "apps/sim/.env.example" ]; then cp apps/sim/.env.example apps/sim/.env else @@ -79,6 +79,18 @@ if [ ! -f "apps/sim/.env" ]; then fi fi +# Set up env for the realtime server (must match the shared values in apps/sim/.env) +if [ ! -f "apps/realtime/.env" ] && [ -f "apps/realtime/.env.example" ]; then + echo "šŸ“„ Creating apps/realtime/.env from template..." + cp apps/realtime/.env.example apps/realtime/.env +fi + +# Set up packages/db/.env for drizzle-kit and migration scripts +if [ ! -f "packages/db/.env" ] && [ -f "packages/db/.env.example" ]; then + echo "šŸ“„ Creating packages/db/.env from template..." + cp packages/db/.env.example packages/db/.env +fi + # Generate schema and run database migrations echo "šŸ—ƒļø Running database schema generation and migrations..." echo "Generating schema..." diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index c08ab495a2e..8bcd240011c 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -103,6 +103,15 @@ jobs: - name: Lint code run: bun run lint:check + - name: Enforce monorepo boundaries + run: bun run check:boundaries + + - name: Verify realtime prune graph + run: bun run check:realtime-prune + + - name: Type-check realtime server + run: bunx turbo run type-check --filter=@sim/realtime + - name: Run tests with coverage env: NODE_OPTIONS: '--no-warnings --max-old-space-size=8192' diff --git a/AGENTS.md b/AGENTS.md index 5e21c7e009c..025ad5a3883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,19 +20,42 @@ You are a professional software engineer. All code must follow best practices: a ### Root Structure ``` -apps/sim/ -ā”œā”€ā”€ app/ # Next.js app router (pages, API routes) -ā”œā”€ā”€ blocks/ # Block definitions and registry -ā”œā”€ā”€ components/ # Shared UI (emcn/, ui/) -ā”œā”€ā”€ executor/ # Workflow execution engine -ā”œā”€ā”€ hooks/ # Shared hooks (queries/, selectors/) -ā”œā”€ā”€ lib/ # App-wide utilities -ā”œā”€ā”€ providers/ # LLM provider integrations -ā”œā”€ā”€ stores/ # Zustand stores -ā”œā”€ā”€ tools/ # Tool definitions -└── triggers/ # Trigger definitions +apps/ +ā”œā”€ā”€ sim/ # Next.js app (UI + API routes + workflow editor) +│ ā”œā”€ā”€ app/ # Next.js app router (pages, API routes) +│ ā”œā”€ā”€ blocks/ # Block definitions and registry +│ ā”œā”€ā”€ components/ # Shared UI (emcn/, ui/) +│ ā”œā”€ā”€ executor/ # Workflow execution engine +│ ā”œā”€ā”€ hooks/ # Shared hooks (queries/, selectors/) +│ ā”œā”€ā”€ lib/ # App-wide utilities +│ ā”œā”€ā”€ providers/ # LLM provider integrations +│ ā”œā”€ā”€ stores/ # Zustand stores +│ ā”œā”€ā”€ tools/ # Tool definitions +│ └── triggers/ # Trigger definitions +└── realtime/ # Bun Socket.IO server (collaborative canvas) + └── src/ # auth, config, database, handlers, middleware, + # rooms, routes, internal/webhook-cleanup.ts + +packages/ +ā”œā”€ā”€ audit/ # @sim/audit — recordAudit + AuditAction + AuditResourceType +ā”œā”€ā”€ auth/ # @sim/auth — @sim/auth/verify (shared Better Auth verifier) +ā”œā”€ā”€ db/ # @sim/db — drizzle schema + client +ā”œā”€ā”€ logger/ # @sim/logger +ā”œā”€ā”€ realtime-protocol/ # @sim/realtime-protocol — socket operation constants + zod schemas +ā”œā”€ā”€ security/ # @sim/security — safeCompare +ā”œā”€ā”€ tsconfig/ # shared tsconfig presets +ā”œā”€ā”€ utils/ # @sim/utils +ā”œā”€ā”€ workflow-authz/ # @sim/workflow-authz — authorizeWorkflowByWorkspacePermission +ā”œā”€ā”€ workflow-persistence/ # @sim/workflow-persistence — raw load/save + subflow helpers +└── workflow-types/ # @sim/workflow-types — pure BlockState/Loop/Parallel/... types ``` +### Package boundaries +- `apps/* → packages/*` only. Packages never import from `apps/*`. +- Each package has explicit subpath `exports` maps; no barrels that accidentally pull in heavy halves. +- `apps/realtime` intentionally avoids Next.js, React, the block/tool registry, provider SDKs, and the executor. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`. +- Auth is shared across services via the Better Auth "Shared Database Session" pattern: both apps read the same `BETTER_AUTH_SECRET` and point at the same DB via `@sim/db`. + ### Naming Conventions - Components: PascalCase (`WorkflowList`) - Hooks: `use` prefix (`useWorkflowOperations`) diff --git a/apps/realtime/.env.example b/apps/realtime/.env.example new file mode 100644 index 00000000000..26ef119c01c --- /dev/null +++ b/apps/realtime/.env.example @@ -0,0 +1,30 @@ +# Environment variables required by the @sim/realtime (Socket.IO) server. +# These MUST match the corresponding values in apps/sim/.env for auth to work. +# See apps/realtime/src/env.ts for the full zod schema. + +# Core +NODE_ENV=development +PORT=3002 + +# Database — must point at the same Postgres as the main app +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/simstudio + +# Auth — shared with apps/sim (Better Auth "Shared Database Session" pattern) +BETTER_AUTH_URL=http://localhost:3000 +BETTER_AUTH_SECRET=your_better_auth_secret_min_32_chars + +# Internal RPC — shared with apps/sim +INTERNAL_API_SECRET=your_internal_api_secret_min_32_chars + +# Public app URL — used for CORS allow-list and base URL resolution +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Optional: Redis for cross-pod room management +# Leave unset for single-pod / in-memory rooms +# REDIS_URL=redis://localhost:6379 + +# Optional: extra Socket.IO CORS allow-list (comma-separated) +# ALLOWED_ORIGINS=https://embed.example.com,https://admin.example.com + +# Optional: disable auth entirely for trusted private networks +# DISABLE_AUTH=true diff --git a/apps/realtime/package.json b/apps/realtime/package.json new file mode 100644 index 00000000000..8451a633a35 --- /dev/null +++ b/apps/realtime/package.json @@ -0,0 +1,48 @@ +{ + "name": "@sim/realtime", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@sim/audit": "workspace:*", + "@sim/auth": "workspace:*", + "@sim/db": "workspace:*", + "@sim/logger": "workspace:*", + "@sim/realtime-protocol": "workspace:*", + "@sim/security": "workspace:*", + "@sim/utils": "workspace:*", + "@sim/workflow-authz": "workspace:*", + "@sim/workflow-persistence": "workspace:*", + "@sim/workflow-types": "workspace:*", + "@socket.io/redis-adapter": "8.3.0", + "drizzle-orm": "^0.45.2", + "postgres": "^3.4.5", + "redis": "5.10.0", + "socket.io": "^4.8.1", + "zod": "^3.24.2" + }, + "devDependencies": { + "@sim/testing": "workspace:*", + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "socket.io-client": "4.8.1", + "typescript": "^5.7.3", + "vitest": "^3.0.8" + } +} diff --git a/apps/realtime/src/auth.ts b/apps/realtime/src/auth.ts new file mode 100644 index 00000000000..40491a62af7 --- /dev/null +++ b/apps/realtime/src/auth.ts @@ -0,0 +1,17 @@ +import { createVerifyAuth } from '@sim/auth/verify' +import { env } from '@/env' + +export const ANONYMOUS_USER_ID = '00000000-0000-0000-0000-000000000000' + +export const ANONYMOUS_USER = { + id: ANONYMOUS_USER_ID, + name: 'Anonymous', + email: 'anonymous@localhost', + emailVerified: true, + image: null, +} as const + +export const auth = createVerifyAuth({ + secret: env.BETTER_AUTH_SECRET, + baseURL: env.BETTER_AUTH_URL, +}) diff --git a/apps/sim/socket/config/socket.ts b/apps/realtime/src/config/socket.ts similarity index 96% rename from apps/sim/socket/config/socket.ts rename to apps/realtime/src/config/socket.ts index 366558536ba..3e6c50ecbe9 100644 --- a/apps/sim/socket/config/socket.ts +++ b/apps/realtime/src/config/socket.ts @@ -3,9 +3,7 @@ import { createLogger } from '@sim/logger' import { createAdapter } from '@socket.io/redis-adapter' import { createClient, type RedisClientType } from 'redis' import { Server } from 'socket.io' -import { env } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/feature-flags' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { env, getBaseUrl, isProd } from '@/env' const logger = createLogger('SocketIOConfig') diff --git a/apps/sim/socket/database/operations.ts b/apps/realtime/src/database/operations.ts similarity index 97% rename from apps/sim/socket/database/operations.ts rename to apps/realtime/src/database/operations.ts index 93c117ea1dd..9e2b4d1ddbe 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -1,15 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import * as schema from '@sim/db' -import { webhook, workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db' +import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' -import { drizzle } from 'drizzle-orm/postgres-js' -import postgres from 'postgres' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import { env } from '@/lib/core/config/env' -import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' -import { getActiveWorkflowContext } from '@/lib/workflows/active-context' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { mergeSubBlockValues } from '@/lib/workflows/subblocks' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -19,7 +11,14 @@ import { SUBFLOW_OPERATIONS, VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, -} from '@/socket/constants' +} from '@sim/realtime-protocol/constants' +import { getActiveWorkflowContext } from '@sim/workflow-authz' +import { loadWorkflowFromNormalizedTablesRaw } from '@sim/workflow-persistence/load' +import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { env } from '@/env' const logger = createLogger('SocketDatabase') @@ -182,7 +181,7 @@ export async function getWorkflowState(workflowId: string) { throw new Error(`Workflow ${workflowId} not found`) } - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + const normalizedData = await loadWorkflowFromNormalizedTablesRaw(workflowId) if (normalizedData) { const finalState = { @@ -915,30 +914,10 @@ async function handleBlocksOperationTx( } } - // Clean up external webhooks - const webhooksToCleanup = await tx - .select({ - webhook: webhook, - workflow: { - id: workflow.id, - userId: workflow.userId, - workspaceId: workflow.workspaceId, - }, - }) - .from(webhook) - .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(and(eq(webhook.workflowId, workflowId), inArray(webhook.blockId, blockIdsArray))) - - if (webhooksToCleanup.length > 0) { - const requestId = `socket-batch-${workflowId}-${Date.now()}` - for (const { webhook: wh, workflow: wf } of webhooksToCleanup) { - try { - await cleanupExternalWebhook(wh, wf, requestId) - } catch (error) { - logger.error(`Failed to cleanup webhook ${wh.id}:`, error) - } - } - } + // Webhook rows are only created at deploy time (saveTriggerWebhooksForDeploy in + // lib/webhooks/deploy.ts) with deploymentVersionId set; their external-subscription + // lifecycle is managed by deploy.ts, lifecycle.ts, and the /api/webhooks/[id] route. + // Removing a trigger block from the draft canvas does not touch any webhook rows. // Delete edges connected to any of the blocks await tx diff --git a/apps/realtime/src/env.ts b/apps/realtime/src/env.ts new file mode 100644 index 00000000000..f08ff5ddd29 --- /dev/null +++ b/apps/realtime/src/env.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +const EnvSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + DATABASE_URL: z.string().url(), + REDIS_URL: z.string().url().optional(), + BETTER_AUTH_URL: z.string().url(), + BETTER_AUTH_SECRET: z.string().min(32), + INTERNAL_API_SECRET: z.string().min(32), + NEXT_PUBLIC_APP_URL: z.string().url(), + ALLOWED_ORIGINS: z.string().optional(), + PORT: z.coerce.number().int().positive().default(3002), + DISABLE_AUTH: z + .string() + .optional() + .transform((value) => value === 'true' || value === '1'), +}) + +function parseEnv() { + const parsed = EnvSchema.safeParse(process.env) + if (!parsed.success) { + const formatted = parsed.error.format() + throw new Error(`Invalid realtime server environment: ${JSON.stringify(formatted, null, 2)}`) + } + return parsed.data +} + +export const env = parseEnv() + +export const isProd = env.NODE_ENV === 'production' +export const isDev = env.NODE_ENV === 'development' +export const isTest = env.NODE_ENV === 'test' + +let appHostname = '' +try { + appHostname = new URL(env.NEXT_PUBLIC_APP_URL).hostname +} catch {} +export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') + +export const isAuthDisabled = env.DISABLE_AUTH === true && !isHosted + +export function getBaseUrl(): string { + return env.NEXT_PUBLIC_APP_URL +} diff --git a/apps/sim/socket/handlers/connection.ts b/apps/realtime/src/handlers/connection.ts similarity index 80% rename from apps/sim/socket/handlers/connection.ts rename to apps/realtime/src/handlers/connection.ts index ee7a9a77434..90eddb82464 100644 --- a/apps/sim/socket/handlers/connection.ts +++ b/apps/realtime/src/handlers/connection.ts @@ -1,8 +1,8 @@ import { createLogger } from '@sim/logger' -import { cleanupPendingSubblocksForSocket } from '@/socket/handlers/subblocks' -import { cleanupPendingVariablesForSocket } from '@/socket/handlers/variables' -import type { AuthenticatedSocket } from '@/socket/middleware/auth' -import type { IRoomManager } from '@/socket/rooms' +import { cleanupPendingSubblocksForSocket } from '@/handlers/subblocks' +import { cleanupPendingVariablesForSocket } from '@/handlers/variables' +import type { AuthenticatedSocket } from '@/middleware/auth' +import type { IRoomManager } from '@/rooms' const logger = createLogger('ConnectionHandlers') diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts new file mode 100644 index 00000000000..6ded2e54741 --- /dev/null +++ b/apps/realtime/src/handlers/index.ts @@ -0,0 +1,17 @@ +import { setupConnectionHandlers } from '@/handlers/connection' +import { setupOperationsHandlers } from '@/handlers/operations' +import { setupPresenceHandlers } from '@/handlers/presence' +import { setupSubblocksHandlers } from '@/handlers/subblocks' +import { setupVariablesHandlers } from '@/handlers/variables' +import { setupWorkflowHandlers } from '@/handlers/workflow' +import type { AuthenticatedSocket } from '@/middleware/auth' +import type { IRoomManager } from '@/rooms' + +export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { + setupWorkflowHandlers(socket, roomManager) + setupOperationsHandlers(socket, roomManager) + setupSubblocksHandlers(socket, roomManager) + setupVariablesHandlers(socket, roomManager) + setupPresenceHandlers(socket, roomManager) + setupConnectionHandlers(socket, roomManager) +} diff --git a/apps/sim/socket/handlers/operations.ts b/apps/realtime/src/handlers/operations.ts similarity index 98% rename from apps/sim/socket/handlers/operations.ts rename to apps/realtime/src/handlers/operations.ts index c47c380e896..6635d157b3a 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/realtime/src/handlers/operations.ts @@ -1,6 +1,4 @@ import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { ZodError } from 'zod' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -9,12 +7,14 @@ import { VARIABLE_OPERATIONS, type VariableOperation, WORKFLOW_OPERATIONS, -} from '@/socket/constants' -import { persistWorkflowOperation } from '@/socket/database/operations' -import type { AuthenticatedSocket } from '@/socket/middleware/auth' -import { checkRolePermission } from '@/socket/middleware/permissions' -import type { IRoomManager, UserSession } from '@/socket/rooms' -import { WorkflowOperationSchema } from '@/socket/validation/schemas' +} from '@sim/realtime-protocol/constants' +import { WorkflowOperationSchema } from '@sim/realtime-protocol/schemas' +import { generateId } from '@sim/utils/id' +import { ZodError } from 'zod' +import { persistWorkflowOperation } from '@/database/operations' +import type { AuthenticatedSocket } from '@/middleware/auth' +import { checkRolePermission } from '@/middleware/permissions' +import type { IRoomManager, UserSession } from '@/rooms' const logger = createLogger('OperationsHandlers') diff --git a/apps/sim/socket/handlers/presence.ts b/apps/realtime/src/handlers/presence.ts similarity index 93% rename from apps/sim/socket/handlers/presence.ts rename to apps/realtime/src/handlers/presence.ts index 208183d2c5c..13aadc22f34 100644 --- a/apps/sim/socket/handlers/presence.ts +++ b/apps/realtime/src/handlers/presence.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' -import type { AuthenticatedSocket } from '@/socket/middleware/auth' -import type { IRoomManager } from '@/socket/rooms' +import type { AuthenticatedSocket } from '@/middleware/auth' +import type { IRoomManager } from '@/rooms' const logger = createLogger('PresenceHandlers') diff --git a/apps/sim/socket/handlers/subblocks.ts b/apps/realtime/src/handlers/subblocks.ts similarity index 97% rename from apps/sim/socket/handlers/subblocks.ts rename to apps/realtime/src/handlers/subblocks.ts index 997f8416c7b..e71792ca685 100644 --- a/apps/sim/socket/handlers/subblocks.ts +++ b/apps/realtime/src/handlers/subblocks.ts @@ -1,11 +1,11 @@ import { db } from '@sim/db' import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants' import { and, eq } from 'drizzle-orm' -import { SUBBLOCK_OPERATIONS } from '@/socket/constants' -import type { AuthenticatedSocket } from '@/socket/middleware/auth' -import { checkRolePermission } from '@/socket/middleware/permissions' -import type { IRoomManager } from '@/socket/rooms' +import type { AuthenticatedSocket } from '@/middleware/auth' +import { checkRolePermission } from '@/middleware/permissions' +import type { IRoomManager } from '@/rooms' const logger = createLogger('SubblocksHandlers') diff --git a/apps/sim/socket/handlers/variables.ts b/apps/realtime/src/handlers/variables.ts similarity index 97% rename from apps/sim/socket/handlers/variables.ts rename to apps/realtime/src/handlers/variables.ts index bf5114c8390..b67570674aa 100644 --- a/apps/sim/socket/handlers/variables.ts +++ b/apps/realtime/src/handlers/variables.ts @@ -1,11 +1,11 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { VARIABLE_OPERATIONS } from '@sim/realtime-protocol/constants' import { eq } from 'drizzle-orm' -import { VARIABLE_OPERATIONS } from '@/socket/constants' -import type { AuthenticatedSocket } from '@/socket/middleware/auth' -import { checkRolePermission } from '@/socket/middleware/permissions' -import type { IRoomManager } from '@/socket/rooms' +import type { AuthenticatedSocket } from '@/middleware/auth' +import { checkRolePermission } from '@/middleware/permissions' +import type { IRoomManager } from '@/rooms' const logger = createLogger('VariablesHandlers') diff --git a/apps/sim/socket/handlers/workflow.test.ts b/apps/realtime/src/handlers/workflow.test.ts similarity index 96% rename from apps/sim/socket/handlers/workflow.test.ts rename to apps/realtime/src/handlers/workflow.test.ts index ac65399faf2..9dd82db87c4 100644 --- a/apps/sim/socket/handlers/workflow.test.ts +++ b/apps/realtime/src/handlers/workflow.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { IRoomManager } from '@/socket/rooms' +import type { IRoomManager } from '@/rooms' const { mockGetWorkflowState, mockVerifyWorkflowAccess } = vi.hoisted(() => ({ mockGetWorkflowState: vi.fn(), @@ -14,15 +14,15 @@ vi.mock('@sim/db', () => ({ user: { image: 'image' }, })) -vi.mock('@/socket/database/operations', () => ({ +vi.mock('@/database/operations', () => ({ getWorkflowState: mockGetWorkflowState, })) -vi.mock('@/socket/middleware/permissions', () => ({ +vi.mock('@/middleware/permissions', () => ({ verifyWorkflowAccess: mockVerifyWorkflowAccess, })) -import { setupWorkflowHandlers } from '@/socket/handlers/workflow' +import { setupWorkflowHandlers } from '@/handlers/workflow' interface JoinWorkflowPayload { workflowId: string diff --git a/apps/sim/socket/handlers/workflow.ts b/apps/realtime/src/handlers/workflow.ts similarity index 96% rename from apps/sim/socket/handlers/workflow.ts rename to apps/realtime/src/handlers/workflow.ts index 8796f2a3190..da977fcdb5e 100644 --- a/apps/sim/socket/handlers/workflow.ts +++ b/apps/realtime/src/handlers/workflow.ts @@ -1,10 +1,10 @@ import { db, user } from '@sim/db' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { getWorkflowState } from '@/socket/database/operations' -import type { AuthenticatedSocket } from '@/socket/middleware/auth' -import { verifyWorkflowAccess } from '@/socket/middleware/permissions' -import type { IRoomManager, UserPresence } from '@/socket/rooms' +import { getWorkflowState } from '@/database/operations' +import type { AuthenticatedSocket } from '@/middleware/auth' +import { verifyWorkflowAccess } from '@/middleware/permissions' +import type { IRoomManager, UserPresence } from '@/rooms' const logger = createLogger('WorkflowHandlers') diff --git a/apps/sim/socket/index.test.ts b/apps/realtime/src/index.test.ts similarity index 85% rename from apps/sim/socket/index.test.ts rename to apps/realtime/src/index.test.ts index e50f4e97c22..c00592e0d6d 100644 --- a/apps/sim/socket/index.test.ts +++ b/apps/realtime/src/index.test.ts @@ -4,21 +4,28 @@ * @vitest-environment node */ import { createServer, request as httpRequest } from 'http' -import { createEnvMock, createMockLogger } from '@sim/testing' +import { createMockLogger } from '@sim/testing' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { createSocketIOServer } from '@/socket/config/socket' -import { MemoryRoomManager } from '@/socket/rooms' -import { createHttpHandler } from '@/socket/routes/http' +import { createSocketIOServer } from '@/config/socket' +import { MemoryRoomManager } from '@/rooms' +import { createHttpHandler } from '@/routes/http' -vi.mock('@/lib/auth', () => ({ +vi.mock('@/auth', () => ({ auth: { api: { verifyOneTimeToken: vi.fn(), }, }, + ANONYMOUS_USER_ID: '00000000-0000-0000-0000-000000000000', + ANONYMOUS_USER: { + id: '00000000-0000-0000-0000-000000000000', + name: 'Anonymous', + email: 'anonymous@localhost', + emailVerified: true, + image: null, + }, })) -// Mock redis package to prevent actual Redis connections vi.mock('redis', () => ({ createClient: vi.fn(() => ({ on: vi.fn(), @@ -28,15 +35,27 @@ vi.mock('redis', () => ({ })), })) -vi.mock('@/lib/core/config/env', () => - createEnvMock({ +vi.mock('@/env', () => ({ + env: { DATABASE_URL: 'postgres://localhost/test', NODE_ENV: 'test', REDIS_URL: undefined, - }) -) + BETTER_AUTH_URL: 'http://localhost:3000', + BETTER_AUTH_SECRET: 'test-better-auth-secret-at-least-32-chars', + INTERNAL_API_SECRET: 'test-internal-api-secret-at-least-32-chars', + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + PORT: 3002, + DISABLE_AUTH: false, + }, + isProd: false, + isDev: false, + isTest: true, + isHosted: false, + isAuthDisabled: false, + getBaseUrl: () => 'http://localhost:3000', +})) -vi.mock('@/socket/middleware/auth', () => ({ +vi.mock('@/middleware/auth', () => ({ authenticateSocket: vi.fn((socket, next) => { socket.userId = 'test-user-id' socket.userName = 'Test User' @@ -45,7 +64,7 @@ vi.mock('@/socket/middleware/auth', () => ({ }), })) -vi.mock('@/socket/middleware/permissions', () => ({ +vi.mock('@/middleware/permissions', () => ({ verifyWorkflowAccess: vi.fn().mockResolvedValue({ hasAccess: true, role: 'admin', @@ -55,7 +74,7 @@ vi.mock('@/socket/middleware/permissions', () => ({ }), })) -vi.mock('@/socket/database/operations', () => ({ +vi.mock('@/database/operations', () => ({ getWorkflowState: vi.fn().mockResolvedValue({ id: 'test-workflow', name: 'Test Workflow', @@ -275,13 +294,13 @@ describe('Socket Server Index Integration', () => { describe('Module Integration', () => { it.concurrent('should properly import all extracted modules', async () => { - const { createSocketIOServer } = await import('@/socket/config/socket') - const { createHttpHandler } = await import('@/socket/routes/http') - const { MemoryRoomManager, RedisRoomManager } = await import('@/socket/rooms') - const { authenticateSocket } = await import('@/socket/middleware/auth') - const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions') - const { getWorkflowState } = await import('@/socket/database/operations') - const { WorkflowOperationSchema } = await import('@/socket/validation/schemas') + const { createSocketIOServer } = await import('@/config/socket') + const { createHttpHandler } = await import('@/routes/http') + const { MemoryRoomManager, RedisRoomManager } = await import('@/rooms') + const { authenticateSocket } = await import('@/middleware/auth') + const { verifyWorkflowAccess } = await import('@/middleware/permissions') + const { getWorkflowState } = await import('@/database/operations') + const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas') expect(createSocketIOServer).toBeTypeOf('function') expect(createHttpHandler).toBeTypeOf('function') @@ -332,7 +351,7 @@ describe('Socket Server Index Integration', () => { describe('Validation and Utils', () => { it.concurrent('should validate workflow operations', async () => { - const { WorkflowOperationSchema } = await import('@/socket/validation/schemas') + const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas') const validOperation = { operation: 'batch-add-blocks', @@ -358,7 +377,7 @@ describe('Socket Server Index Integration', () => { }) it.concurrent('should validate batch-add-blocks with edges', async () => { - const { WorkflowOperationSchema } = await import('@/socket/validation/schemas') + const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas') const validOperationWithEdge = { operation: 'batch-add-blocks', @@ -393,7 +412,7 @@ describe('Socket Server Index Integration', () => { }) it.concurrent('should validate edge operations', async () => { - const { WorkflowOperationSchema } = await import('@/socket/validation/schemas') + const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas') const validEdgeOperation = { operation: 'add', @@ -410,7 +429,7 @@ describe('Socket Server Index Integration', () => { }) it('should validate subflow operations', async () => { - const { WorkflowOperationSchema } = await import('@/socket/validation/schemas') + const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas') const validSubflowOperation = { operation: 'update', diff --git a/apps/sim/socket/index.ts b/apps/realtime/src/index.ts similarity index 92% rename from apps/sim/socket/index.ts rename to apps/realtime/src/index.ts index adca5a9c373..dde8e67e442 100644 --- a/apps/sim/socket/index.ts +++ b/apps/realtime/src/index.ts @@ -1,12 +1,12 @@ import { createServer } from 'http' import { createLogger } from '@sim/logger' import type { Server as SocketIOServer } from 'socket.io' -import { env } from '@/lib/core/config/env' -import { createSocketIOServer, shutdownSocketIOAdapter } from '@/socket/config/socket' -import { setupAllHandlers } from '@/socket/handlers' -import { type AuthenticatedSocket, authenticateSocket } from '@/socket/middleware/auth' -import { type IRoomManager, MemoryRoomManager, RedisRoomManager } from '@/socket/rooms' -import { createHttpHandler } from '@/socket/routes/http' +import { createSocketIOServer, shutdownSocketIOAdapter } from '@/config/socket' +import { env } from '@/env' +import { setupAllHandlers } from '@/handlers' +import { type AuthenticatedSocket, authenticateSocket } from '@/middleware/auth' +import { type IRoomManager, MemoryRoomManager, RedisRoomManager } from '@/rooms' +import { createHttpHandler } from '@/routes/http' const logger = createLogger('CollaborativeSocketServer') @@ -29,7 +29,7 @@ async function createRoomManager(io: SocketIOServer): Promise { async function main() { const httpServer = createServer() - const PORT = Number(env.PORT || env.SOCKET_PORT || 3002) + const PORT = env.PORT logger.info('Starting Socket.IO server...', { port: PORT, diff --git a/apps/sim/socket/middleware/auth.ts b/apps/realtime/src/middleware/auth.ts similarity index 94% rename from apps/sim/socket/middleware/auth.ts rename to apps/realtime/src/middleware/auth.ts index a9daddcfc3f..91cdb71b4b6 100644 --- a/apps/sim/socket/middleware/auth.ts +++ b/apps/realtime/src/middleware/auth.ts @@ -1,9 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { Socket } from 'socket.io' -import { auth } from '@/lib/auth' -import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from '@/lib/auth/constants' -import { isAuthDisabled } from '@/lib/core/config/feature-flags' +import { ANONYMOUS_USER, ANONYMOUS_USER_ID, auth } from '@/auth' +import { isAuthDisabled } from '@/env' const logger = createLogger('SocketAuth') diff --git a/apps/sim/socket/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts similarity index 99% rename from apps/sim/socket/middleware/permissions.test.ts rename to apps/realtime/src/middleware/permissions.test.ts index 784d4ea7ffe..2d8cd12999c 100644 --- a/apps/sim/socket/middleware/permissions.test.ts +++ b/apps/realtime/src/middleware/permissions.test.ts @@ -14,7 +14,7 @@ import { SOCKET_OPERATIONS, } from '@sim/testing' import { describe, expect, it } from 'vitest' -import { checkRolePermission } from '@/socket/middleware/permissions' +import { checkRolePermission } from '@/middleware/permissions' describe('checkRolePermission', () => { describe('admin role', () => { diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts similarity index 97% rename from apps/sim/socket/middleware/permissions.ts rename to apps/realtime/src/middleware/permissions.ts index 244cf1b07b5..dcc893b1478 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -1,8 +1,6 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -12,7 +10,9 @@ import { SUBFLOW_OPERATIONS, VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, -} from '@/socket/constants' +} from '@sim/realtime-protocol/constants' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { and, eq, isNull } from 'drizzle-orm' const logger = createLogger('SocketPermissions') diff --git a/apps/realtime/src/rooms/index.ts b/apps/realtime/src/rooms/index.ts new file mode 100644 index 00000000000..8067fc1b215 --- /dev/null +++ b/apps/realtime/src/rooms/index.ts @@ -0,0 +1,3 @@ +export { MemoryRoomManager } from '@/rooms/memory-manager' +export { RedisRoomManager } from '@/rooms/redis-manager' +export type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types' diff --git a/apps/sim/socket/rooms/memory-manager.ts b/apps/realtime/src/rooms/memory-manager.ts similarity index 99% rename from apps/sim/socket/rooms/memory-manager.ts rename to apps/realtime/src/rooms/memory-manager.ts index 1e14c9c7df1..a032e785bb5 100644 --- a/apps/sim/socket/rooms/memory-manager.ts +++ b/apps/realtime/src/rooms/memory-manager.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import type { Server } from 'socket.io' -import type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/socket/rooms/types' +import type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types' const logger = createLogger('MemoryRoomManager') diff --git a/apps/sim/socket/rooms/redis-manager.ts b/apps/realtime/src/rooms/redis-manager.ts similarity index 99% rename from apps/sim/socket/rooms/redis-manager.ts rename to apps/realtime/src/rooms/redis-manager.ts index adfe6720485..0e6b3eadf2b 100644 --- a/apps/sim/socket/rooms/redis-manager.ts +++ b/apps/realtime/src/rooms/redis-manager.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { createClient, type RedisClientType } from 'redis' import type { Server } from 'socket.io' -import type { IRoomManager, UserPresence, UserSession } from '@/socket/rooms/types' +import type { IRoomManager, UserPresence, UserSession } from '@/rooms/types' const logger = createLogger('RedisRoomManager') diff --git a/apps/sim/socket/rooms/types.ts b/apps/realtime/src/rooms/types.ts similarity index 100% rename from apps/sim/socket/rooms/types.ts rename to apps/realtime/src/rooms/types.ts diff --git a/apps/sim/socket/routes/http.ts b/apps/realtime/src/routes/http.ts similarity index 97% rename from apps/sim/socket/routes/http.ts rename to apps/realtime/src/routes/http.ts index 5c555e92843..0f8ed73cc52 100644 --- a/apps/sim/socket/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -1,7 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'http' -import { env } from '@/lib/core/config/env' -import { safeCompare } from '@/lib/core/security/encryption' -import type { IRoomManager } from '@/socket/rooms' +import { safeCompare } from '@sim/security/compare' +import { env } from '@/env' +import type { IRoomManager } from '@/rooms' interface Logger { info: (message: string, ...args: unknown[]) => void diff --git a/apps/sim/socket/tests/socket-server.test.ts b/apps/realtime/src/tests/socket-server.test.ts similarity index 100% rename from apps/sim/socket/tests/socket-server.test.ts rename to apps/realtime/src/tests/socket-server.test.ts diff --git a/apps/realtime/tsconfig.json b/apps/realtime/tsconfig.json new file mode 100644 index 00000000000..cce62771d4d --- /dev/null +++ b/apps/realtime/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@sim/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/realtime/vitest.config.ts b/apps/realtime/vitest.config.ts new file mode 100644 index 00000000000..a458a21d8dd --- /dev/null +++ b/apps/realtime/vitest.config.ts @@ -0,0 +1,27 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['**/*.test.{ts,tsx}'], + exclude: ['**/node_modules/**', '**/dist/**'], + setupFiles: ['./vitest.setup.ts'], + pool: 'threads', + testTimeout: 10000, + }, + resolve: { + alias: [ + { + find: '@sim/db', + replacement: path.resolve(__dirname, '../../packages/db'), + }, + { + find: '@sim/logger', + replacement: path.resolve(__dirname, '../../packages/logger/src'), + }, + { find: '@', replacement: path.resolve(__dirname, 'src') }, + ], + }, +}) diff --git a/apps/realtime/vitest.setup.ts b/apps/realtime/vitest.setup.ts new file mode 100644 index 00000000000..132ea305c23 --- /dev/null +++ b/apps/realtime/vitest.setup.ts @@ -0,0 +1,6 @@ +process.env.DATABASE_URL ??= 'postgres://localhost/test' +process.env.NODE_ENV ??= 'test' +process.env.BETTER_AUTH_URL ??= 'http://localhost:3000' +process.env.BETTER_AUTH_SECRET ??= 'test-better-auth-secret-at-least-32-chars' +process.env.INTERNAL_API_SECRET ??= 'test-internal-api-secret-at-least-32-chars' +process.env.NEXT_PUBLIC_APP_URL ??= 'http://localhost:3000' diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index 078de88233b..ac75315fffa 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -26,6 +26,13 @@ apps/sim/ └── triggers/ # Trigger definitions ``` +The Socket.IO collaborative-canvas server lives in a separate workspace at +`apps/realtime/`. It shares DB + auth with `apps/sim` via the `@sim/*` +packages. Do not add imports from `@/lib/webhooks/providers/*`, `@/executor/*`, +`@/blocks/*`, or `@/tools/*` to any package consumed by `apps/realtime` — +those heavyweight registries stay in this app. `apps/realtime` calls back +into this app only over internal HTTP with `INTERNAL_API_SECRET`. + ### Feature Organization Features live under `app/workspace/[workspaceId]/`: diff --git a/apps/sim/app/api/auth/forget-password/route.ts b/apps/sim/app/api/auth/forget-password/route.ts index ef20b11b299..2bf7be8782a 100644 --- a/apps/sim/app/api/auth/forget-password/route.ts +++ b/apps/sim/app/api/auth/forget-password/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { auth } from '@/lib/auth' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 185caf3e8bf..db2db24fc79 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { account, credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -12,7 +13,6 @@ import { getCanonicalScopesForProvider, getServiceAccountProviderForProviderId, } from '@/lib/oauth/utils' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 13a3b4bc7e9..3681ac5cc36 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -23,7 +23,7 @@ vi.mock('@/lib/webhooks/utils.server', () => ({ syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet, })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) import { POST } from '@/app/api/auth/oauth/disconnect/route' diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index 7a5372e652e..51767bd482e 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { account, credentialSet, credentialSetMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index fcf9e389ee3..c12c52ea4ba 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -1,5 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' @@ -34,13 +35,9 @@ function validateHmac(searchParams: URLSearchParams, clientSecret: string): bool .map((key) => `${key}=${params[key]}`) .join('&') - const generatedHmac = crypto.createHmac('sha256', clientSecret).update(message).digest('hex') + const generatedHmac = hmacSha256Hex(message, clientSecret) - try { - return crypto.timingSafeEqual(Buffer.from(hmac, 'hex'), Buffer.from(generatedHmac, 'hex')) - } catch { - return false - } + return safeCompare(hmac, generatedHmac) } export const GET = withRouteHandler(async (request: NextRequest) => { diff --git a/apps/sim/app/api/billing/credits/route.ts b/apps/sim/app/api/billing/credits/route.ts index 5afe089ab27..7f7e5390221 100644 --- a/apps/sim/app/api/billing/credits/route.ts +++ b/apps/sim/app/api/billing/credits/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getCreditBalance } from '@/lib/billing/credits/balance' import { purchaseCredits } from '@/lib/billing/credits/purchase' diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 81c04439e37..32f3a6a8311 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -33,7 +33,7 @@ const mockPerformChatUndeploy = workflowsOrchestrationMockFns.mockPerformChatUnd const mockNotifySocketDeploymentChanged = workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/core/config/feature-flags', () => ({ isDev: true, isHosted: false, diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 3564aa00a79..4f937d75258 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index f4cdb54fb84..31401d6b5ec 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -40,7 +40,7 @@ vi.mock('@/serializer', () => ({ Serializer: vi.fn(), })) -vi.mock('@/lib/workflows/subblocks', () => ({ +vi.mock('@sim/workflow-persistence/subblocks', () => ({ mergeSubblockStateWithValues: mockMergeSubblockStateWithValues, mergeSubBlockValues: mockMergeSubBlockValues, })) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 8d1147c1c83..3909dd599fe 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { @@ -9,7 +10,6 @@ import { validateAuthToken, } from '@/lib/core/security/deployment' import { decryptSecret } from '@/lib/core/security/encryption' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('ChatAuthUtils') diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index 8252c493413..bc475a8bc09 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' @@ -17,7 +18,6 @@ import { import { readFilePreviewSessions } from '@/lib/copilot/request/session' import { readEvents } from '@/lib/copilot/request/session/buffer' import { toStreamBatchEvent } from '@/lib/copilot/request/session/types' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatAPI') diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index f443fc397b8..45a4c3c9875 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -1,5 +1,6 @@ import { context as otelContext, trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' import { type NextRequest, NextResponse } from 'next/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' import { @@ -407,7 +408,7 @@ async function handleResumeRequestBody({ break } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + await sleep(POLL_INTERVAL_MS) } if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) { emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, { diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 512cd129bdc..07b6974ed45 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { copilotChats, permissions, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq, isNull, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -13,7 +14,6 @@ import { } from '@/lib/copilot/request/http' import { taskPubSub } from '@/lib/copilot/tasks' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatsListAPI') diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 05520d59841..0730fe748fb 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { authMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing' +import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -63,7 +63,7 @@ describe('Copilot Checkpoints Revert API Route', () => { authMockFns.mockGetSession.mockResolvedValue(null) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, status: 200, }) @@ -251,7 +251,7 @@ describe('Copilot Checkpoints Revert API Route', () => { thenResults.push(mockCheckpoint) // Checkpoint found thenResults.push(mockWorkflow) // Workflow found but different user - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 403, }) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 01661bf4e3b..b5c050d13d7 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflowCheckpoints, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -14,7 +15,6 @@ import { } from '@/lib/copilot/request/http' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { isUuidV4 } from '@/executor/constants' const logger = createLogger('CheckpointRevertAPI') diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts index e73b6ed0ca9..e3da1f258f4 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { authMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing' +import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -79,7 +79,7 @@ describe('Copilot Checkpoints API Route', () => { userId: 'user-123', workflowId: 'workflow-123', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, }) }) diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts index b80fad00a08..0e00dbf1c3a 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflowCheckpoints } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -13,7 +14,6 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('WorkflowCheckpointsAPI') diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts index de1a54ae185..287a78b0d23 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { getBaseUrl } from '@/lib/core/utils/urls' diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts index a522cfcf41c..d0f4c0ca00c 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -6,7 +7,6 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { getBaseUrl } from '@/lib/core/utils/urls' diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts index ca3c1894903..64d72281259 100644 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { account, credentialSet, credentialSetMember, member, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts index 47e9c44d2d1..8a7fcb51464 100644 --- a/apps/sim/app/api/credential-sets/[id]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { credentialSet, credentialSetMember, member } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts index 8c248db611c..2d8e1b77a63 100644 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { credentialSet, @@ -9,7 +10,6 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts index e198790b62d..1e3846bd0d7 100644 --- a/apps/sim/app/api/credential-sets/memberships/route.ts +++ b/apps/sim/app/api/credential-sets/memberships/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { credentialSet, credentialSetMember, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts index 55c06f8686b..cc5ba887999 100644 --- a/apps/sim/app/api/credential-sets/route.ts +++ b/apps/sim/app/api/credential-sets/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { credentialSet, credentialSetMember, member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, count, desc, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 6b265e4636c..a85a32a72c4 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 465ae5340ea..ebb429b438d 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { account, credential, credentialMember, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 9c0aca941ea..7d74c421262 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { environment } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index a9126e5bb29..1125e1d3285 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -1,6 +1,6 @@ -import { createHash } from 'crypto' import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' @@ -80,11 +80,7 @@ async function compileDocumentIfNeeded( } const code = buffer.toString('utf-8') - const cacheKey = createHash('sha256') - .update(ext) - .update(code) - .update(workspaceId ?? '') - .digest('hex') + const cacheKey = sha256Hex(`${ext}${code}${workspaceId ?? ''}`) const cached = compiledDocCache.get(cacheKey) if (cached) { return { buffer: cached, contentType: format.contentType } diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 9ea1c53d871..705ea2d8b17 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { sanitizeFileName } from '@/executor/constants' import '@/lib/uploads/core/setup.server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 7cdc446b8d5..9b7811a822f 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 04f697e74a5..477ada12fce 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -37,7 +37,7 @@ const mockPerformDeleteFolder = workflowsOrchestrationMockFns.mockPerformDeleteF const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index d3a3a173fa6..e5040507b92 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -30,7 +30,7 @@ const { mockLogger } = vi.hoisted(() => { const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('drizzle-orm', () => ({ ...drizzleOrmMock, min: vi.fn((field) => ({ type: 'min', field })), diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 69e8c42921c..14117e2b171 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, asc, eq, isNotNull, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts index a8df0decc93..501f3edbf1a 100644 --- a/apps/sim/app/api/form/manage/[id]/route.ts +++ b/apps/sim/app/api/form/manage/[id]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts index 7b1ded808be..5336d324502 100644 --- a/apps/sim/app/api/form/route.ts +++ b/apps/sim/app/api/form/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' diff --git a/apps/sim/app/api/form/utils.ts b/apps/sim/app/api/form/utils.ts index 9f4bafd05ad..55bbe65e17f 100644 --- a/apps/sim/app/api/form/utils.ts +++ b/apps/sim/app/api/form/utils.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { form, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { @@ -9,7 +10,6 @@ import { validateAuthToken, } from '@/lib/core/security/deployment' import { decryptSecret } from '@/lib/core/security/encryption' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('FormAuthUtils') diff --git a/apps/sim/app/api/guardrails/validate/route.ts b/apps/sim/app/api/guardrails/validate/route.ts index 157ed3f6907..efd47375f00 100644 --- a/apps/sim/app/api/guardrails/validate/route.ts +++ b/apps/sim/app/api/guardrails/validate/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' @@ -7,7 +8,6 @@ import { validateHallucination } from '@/lib/guardrails/validate_hallucination' import { validateJson } from '@/lib/guardrails/validate_json' import { validatePII } from '@/lib/guardrails/validate_pii' import { validateRegex } from '@/lib/guardrails/validate_regex' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { assertPermissionsAllowed, ProviderNotAllowedError, diff --git a/apps/sim/app/api/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts index 928bcf002b0..f3be2b8e1b7 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { acceptInvitation } from '@/lib/invitations/core' diff --git a/apps/sim/app/api/invitations/[id]/reject/route.ts b/apps/sim/app/api/invitations/[id]/reject/route.ts index bc4be85a7be..7f9c311b4ca 100644 --- a/apps/sim/app/api/invitations/[id]/reject/route.ts +++ b/apps/sim/app/api/invitations/[id]/reject/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { rejectInvitation } from '@/lib/invitations/core' diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index 28d0dc11937..1841f93118a 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index b6b15249baf..8e08cfc89dc 100644 --- a/apps/sim/app/api/invitations/[id]/route.ts +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { invitation, invitationWorkspaceGrant } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/jobs/[jobId]/route.test.ts b/apps/sim/app/api/jobs/[jobId]/route.test.ts index 3caf1992098..dac212544a0 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.test.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.test.ts @@ -5,9 +5,9 @@ import { hybridAuthMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@s import type { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetJobQueue, mockVerifyWorkflowAccess, mockGetJob } = vi.hoisted(() => ({ +const { mockGetJobQueue, mockAuthorizeWorkflow, mockGetJob } = vi.hoisted(() => ({ mockGetJobQueue: vi.fn(), - mockVerifyWorkflowAccess: vi.fn(), + mockAuthorizeWorkflow: vi.fn(), mockGetJob: vi.fn(), })) @@ -15,8 +15,8 @@ vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue, })) -vi.mock('@/socket/middleware/permissions', () => ({ - verifyWorkflowAccess: mockVerifyWorkflowAccess, +vi.mock('@sim/workflow-authz', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -42,7 +42,7 @@ describe('GET /api/jobs/[jobId]', () => { workspaceId: undefined, }) - mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true }) + mockAuthorizeWorkflow.mockResolvedValue({ allowed: true, status: 200 }) workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({ id: 'workflow-1', workspaceId: 'workspace-1', diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index 3275d56ae80..927c33e24dc 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -33,12 +33,13 @@ export const GET = withRouteHandler( const metadataToCheck = job.metadata if (metadataToCheck?.workflowId) { - const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions') - const accessCheck = await verifyWorkflowAccess( - authenticatedUserId, - metadataToCheck.workflowId as string - ) - if (!accessCheck.hasAccess) { + const { authorizeWorkflowByWorkspacePermission } = await import('@sim/workflow-authz') + const accessCheck = await authorizeWorkflowByWorkspacePermission({ + userId: authenticatedUserId, + workflowId: metadataToCheck.workflowId as string, + action: 'read', + }) + if (!accessCheck.allowed) { logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`) return createErrorResponse('Access denied', 403) } diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts index 5034584ee5c..57593fc9739 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts @@ -30,7 +30,7 @@ const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWrit vi.mock('@sim/db', () => ({ db: mockDbChain })) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) import { GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route' diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts index 210826cfe76..395da6b2812 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts index bfc04f50802..9f78afd961a 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -50,7 +50,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ vi.mock('@/lib/knowledge/documents/service', () => ({ deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined), })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) import { DELETE, GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route' diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index 3bc7bb41b46..77ca9942fbd 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document, @@ -11,7 +12,6 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { decryptApiKey } from '@/lib/api-key/crypto' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { hasLiveSyncAccess } from '@/lib/billing/core/subscription' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts index cb5145a1beb..b61ae4c7f27 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts @@ -34,7 +34,7 @@ vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({ dispatchSync: mockDispatchSync, })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) import { POST } from '@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route' diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index 57ea35e6161..63244e17516 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index e4e64724f24..6a6cb4c93b9 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { knowledgeBase, knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -6,7 +7,6 @@ import { and, desc, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { encryptApiKey } from '@/lib/api-key/crypto' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { hasLiveSyncAccess } from '@/lib/billing/core/subscription' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index a3bc917402b..6935272ad6b 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { batchChunkOperation, createChunk, queryChunks } from '@/lib/knowledge/chunks/service' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils' import { calculateCost } from '@/providers/utils' diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts index 2562d3ff8ee..26e76cbb3fe 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts @@ -34,7 +34,7 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ processDocumentAsync: vi.fn(), })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) import { deleteDocument, diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index bf70faecefd..f49a23a83a9 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index 355f570d804..d66f2cdd402 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -42,7 +42,7 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ retryDocumentProcessing: vi.fn(), })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) import { createDocumentRecords, diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 5e67472d537..40ed102fba7 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -1,8 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,7 +19,6 @@ import { } from '@/lib/knowledge/documents/service' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { captureServerEvent } from '@/lib/posthog/server' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('DocumentsAPI') diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts index ebcae6ab053..8dcc1385b61 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -1,11 +1,12 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -14,7 +15,6 @@ import { getProcessingConfig, processDocumentsWithQueue, } from '@/lib/knowledge/documents/service' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('DocumentUpsertAPI') diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index d8b0e89e7a2..ece42f9f5dc 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts index 3dd0603c9f9..ff58a3149e1 100644 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/route.test.ts @@ -22,7 +22,7 @@ vi.mock('@sim/db', () => ({ db: mockDbChain, })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/knowledge/service', async (importOriginal) => { const actual = await importOriginal() diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 18951456d7f..6f97a2515c4 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index 64c3638e4f6..bc0ab08d755 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -31,7 +31,7 @@ vi.mock('@sim/db', () => ({ db: mockDbChain, })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index e11c7838d48..7f8b0c1309b 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index e9efa572701..52c1fc47ccf 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -11,8 +11,8 @@ import { hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, + workflowAuthzMockFns, workflowsUtilsMock, - workflowsUtilsMockFns, } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -169,7 +169,7 @@ describe('Knowledge Search API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockClear().mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockClear().mockResolvedValue({ allowed: true, status: 200, }) @@ -324,13 +324,11 @@ describe('Knowledge Search API Route', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) - expect(workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith( - { - workflowId: 'workflow-123', - userId: 'user-123', - action: 'read', - } - ) + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: 'workflow-123', + userId: 'user-123', + action: 'read', + }) }) it.concurrent('should return unauthorized for unauthenticated request', async () => { @@ -353,7 +351,7 @@ describe('Knowledge Search API Route', () => { workflowId: 'nonexistent-workflow', } - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 404, message: 'Workflow not found', diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 2bb7739948c..6c9db51ccc2 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' @@ -10,7 +11,6 @@ import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' import type { StructuredFilter } from '@/lib/knowledge/types' import { estimateTokenCount } from '@/lib/tokenization/estimators' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { generateSearchEmbedding, getDocumentNamesByIds, diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 917470cf991..6ae73c4126d 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -15,6 +15,7 @@ import { userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { validateOAuthAccessToken } from '@/lib/auth/oauth-token' @@ -31,10 +32,7 @@ import { env } from '@/lib/core/config/env' import { RateLimiter } from '@/lib/core/rate-limiter' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - authorizeWorkflowByWorkspacePermission, - resolveWorkflowIdForUser, -} from '@/lib/workflows/utils' +import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' const logger = createLogger('CopilotMcpAPI') const mcpRateLimiter = new RateLimiter() diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index bcffb6b8232..13005bb6433 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { McpDnsResolutionError, diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index 4b9c8d93d47..bab33a9b9cb 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { McpDnsResolutionError, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index 21c76cf46fd..f90a962cc22 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 76fa504a887..be511aeb868 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 08b71262c68..611a1b80c5c 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 43105b9297c..f5c9c838557 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts index cd508506fb7..fb81ec48e0e 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -89,7 +89,7 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@sim/logger', () => loggerMock) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 68281a96b4e..dec09e2e1f8 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { invitation, member, organization, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { validateBulkInvitations, diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 16b2ecf5535..971b1e57c79 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { getUserUsageData } from '@/lib/billing/core/usage' diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index b810226fd53..36a4d1d4b65 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { invitation, @@ -9,7 +10,6 @@ import { import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index 671be8c67b9..70326038cca 100644 --- a/apps/sim/app/api/organizations/[id]/route.ts +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getOrganizationSeatAnalytics, diff --git a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts index 4203f0c5e88..6eac1e6d644 100644 --- a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts +++ b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { diff --git a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts index fc54802db7f..ef1dab40e7c 100644 --- a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts +++ b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { HEX_COLOR_REGEX } from '@/lib/branding' diff --git a/apps/sim/app/api/organizations/route.test.ts b/apps/sim/app/api/organizations/route.test.ts index c52185a0278..a3a17208d84 100644 --- a/apps/sim/app/api/organizations/route.test.ts +++ b/apps/sim/app/api/organizations/route.test.ts @@ -71,7 +71,7 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@sim/logger', () => loggerMock) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 13b8e041e98..c79e30c017c 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription as subscriptionTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, or } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts index 01932c9fe3f..df3407461f1 100644 --- a/apps/sim/app/api/schedules/[id]/route.test.ts +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -7,8 +7,8 @@ import { auditMock, authMockFns, databaseMock, + workflowAuthzMockFns, workflowsUtilsMock, - workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -21,7 +21,7 @@ vi.mock('drizzle-orm', () => ({ isNull: vi.fn(), })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) import { PUT } from './route' @@ -61,7 +61,7 @@ describe('Schedule PUT API (Reactivate)', () => { beforeEach(() => { vi.clearAllMocks() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, status: 200, workflow: { id: 'wf-1', workspaceId: 'ws-1' }, @@ -125,7 +125,7 @@ describe('Schedule PUT API (Reactivate)', () => { }) it('returns 404 when workflow does not exist for schedule', async () => { - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: false, status: 404, workflow: null, @@ -144,7 +144,7 @@ describe('Schedule PUT API (Reactivate)', () => { describe('Authorization', () => { it('returns 403 when user is not workflow owner', async () => { - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: false, status: 403, workflow: { id: 'wf-1', workspaceId: null }, @@ -165,7 +165,7 @@ describe('Schedule PUT API (Reactivate)', () => { }) it('returns 403 for workspace member with only read permission', async () => { - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: false, status: 403, workflow: { id: 'wf-1', workspaceId: 'ws-1' }, diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 73155a15892..e8e3a486e60 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -1,16 +1,16 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { validateCronExpression } from '@/lib/workflows/schedules/utils' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('ScheduleAPI') diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts index 7a0b1828db2..88929342615 100644 --- a/apps/sim/app/api/schedules/route.test.ts +++ b/apps/sim/app/api/schedules/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { authMockFns, databaseMock, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing' +import { authMockFns, databaseMock, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -44,7 +44,7 @@ describe('Schedule GET API', () => { beforeEach(() => { vi.clearAllMocks() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, status: 200, workflow: { id: 'wf-1', workspaceId: 'ws-1' }, @@ -103,7 +103,7 @@ describe('Schedule GET API', () => { }) it('returns 404 for non-existent workflow', async () => { - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: false, status: 404, message: 'Workflow not found', @@ -118,7 +118,7 @@ describe('Schedule GET API', () => { }) it('denies access for unauthorized user', async () => { - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: false, status: 403, message: 'Unauthorized: Access denied to read this workflow', diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 2deecfebdfb..6b0b17a8450 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -1,16 +1,16 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { validateCronExpression } from '@/lib/workflows/schedules/utils' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('ScheduledAPI') diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index 069d5dcbfb8..6c91c9d1d7b 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index e8139035cf2..6e5ee48c0a1 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index d810fcf2f57..55b1a25445f 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { templateCreators, templates, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -203,13 +203,14 @@ export const PUT = withRouteHandler( if (status !== undefined) updateData.status = status if (updateState && template.workflowId) { - const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions') - const { hasAccess: hasWorkflowAccess } = await verifyWorkflowAccess( - session.user.id, - template.workflowId - ) + const { authorizeWorkflowByWorkspacePermission } = await import('@sim/workflow-authz') + const authorization = await authorizeWorkflowByWorkspacePermission({ + userId: session.user.id, + workflowId: template.workflowId, + action: 'read', + }) - if (!hasWorkflowAccess) { + if (!authorization.allowed) { logger.warn(`[${requestId}] User denied workflow access for state sync on template ${id}`) return NextResponse.json({ error: 'Access denied to workflow' }, { status: 403 }) } diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index ffca21baf97..96bb31b15ad 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { templateCreators, @@ -8,10 +9,10 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq, ilike, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -20,7 +21,6 @@ import { extractRequiredCredentials, sanitizeCredentials, } from '@/lib/workflows/credentials/credential-extractor' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('TemplatesAPI') diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index 4414e922777..67e1a186e2e 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -9,8 +9,8 @@ import { hybridAuthMockFns, permissionsMock, permissionsMockFns, + workflowAuthzMockFns, workflowsUtilsMock, - workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -233,7 +233,7 @@ describe('Custom Tools API Routes', () => { }) mockGetUserEntityPermissions.mockResolvedValue('admin') mockUpsertCustomTools.mockResolvedValue(sampleTools) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, status: 200, workflow: { workspaceId: 'workspace-123' }, diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts index 5ef4bf04295..9145c585524 100644 --- a/apps/sim/app/api/tools/custom/route.ts +++ b/apps/sim/app/api/tools/custom/route.ts @@ -1,16 +1,16 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { customTools } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CustomToolsAPI') diff --git a/apps/sim/app/api/users/me/api-keys/[id]/route.ts b/apps/sim/app/api/users/me/api-keys/[id]/route.ts index e604fe5835b..147cc9d21a3 100644 --- a/apps/sim/app/api/users/me/api-keys/[id]/route.ts +++ b/apps/sim/app/api/users/me/api-keys/[id]/route.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index 098f9fe3029..b66fc85dc20 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -6,7 +7,6 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { hashApiKey } from '@/lib/api-key/crypto' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/v1/admin/access-control/route.ts b/apps/sim/app/api/v1/admin/access-control/route.ts index d1cd6f9253a..3ac24168fae 100644 --- a/apps/sim/app/api/v1/admin/access-control/route.ts +++ b/apps/sim/app/api/v1/admin/access-control/route.ts @@ -22,11 +22,11 @@ * Response: { success: true, deletedCount: number, membersRemoved: number } */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissionGroup, permissionGroupMember, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq, inArray, sql } from 'drizzle-orm' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/admin/auth.ts b/apps/sim/app/api/v1/admin/auth.ts index 5e04bcc1d90..813d3f8c5b0 100644 --- a/apps/sim/app/api/v1/admin/auth.ts +++ b/apps/sim/app/api/v1/admin/auth.ts @@ -8,8 +8,8 @@ * curl -H "x-admin-key: your_admin_key" https://your-instance/api/v1/admin/... */ -import { createHash, timingSafeEqual } from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' @@ -54,7 +54,7 @@ export function authenticateAdminRequest(request: NextRequest): AdminAuthResult } } - if (!constantTimeCompare(providedKey, adminKey)) { + if (!safeCompare(providedKey, adminKey)) { logger.warn('Invalid admin API key attempted', { keyPrefix: providedKey.slice(0, 8) }) return { authenticated: false, @@ -64,16 +64,3 @@ export function authenticateAdminRequest(request: NextRequest): AdminAuthResult return { authenticated: true } } - -/** - * Constant-time string comparison. - * - * @param a - First string to compare - * @param b - Second string to compare - * @returns True if strings are equal, false otherwise - */ -function constantTimeCompare(a: string, b: string): boolean { - const aHash = createHash('sha256').update(a).digest() - const bHash = createHash('sha256').update(b).digest() - return timingSafeEqual(aHash, bHash) -} diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index 86873b8e54b..5b10a1c3817 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts index 6416ee62433..77e5e246da9 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts @@ -15,10 +15,10 @@ import { db } from '@sim/db' import { workflowBlocks, workflowEdges } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { count, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { performDeleteWorkflow } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts index 3b1f972d001..9e27097db99 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { performActivateVersion } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts index 5633eef91cb..6fdfc3fc0c6 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 13dea76f5a7..ef909a7da15 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 3beb70d273e..e16f8b6c885 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index 19b0a80cb2a..21c6baf4e21 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document, knowledgeConnector } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteDocument } from '@/lib/knowledge/documents/service' import { diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 4cf12b2db2d..32107b050c2 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSingleDocument, diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index 700ac9a8a85..e0fe7d7c13f 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' import { diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 3f64ec3f7e1..a24ce394966 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 09ef58e5f3e..bf20d38216a 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index 67bfd5dd8fc..dad51353a5f 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteTable, type TableSchema } from '@/lib/table' diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index bd564708650..43618f93102 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index b3b6437c713..44994ebfa57 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -2,10 +2,10 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index f24a213b378..5c2a5cd51ad 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -1,9 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateInteger } from '@/lib/core/security/input-validation' import { PlatformEvents } from '@/lib/core/telemetry' @@ -11,7 +12,6 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('WebhookAPI') diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 762f0a1c7fc..e1121b1caf0 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -1,10 +1,11 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissions, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -20,7 +21,6 @@ import { import { getProviderHandler } from '@/lib/webhooks/providers' import { mergeNonUserFields } from '@/lib/webhooks/utils' import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' const logger = createLogger('WebhooksAPI') diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 6893a3192f4..9b659744d25 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' @@ -14,7 +15,6 @@ import { loadWorkflowFromNormalizedTables, type NormalizedWorkflowData, } from '@/lib/workflows/persistence/utils' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index fef312d7acf..188a5a47580 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -8,8 +8,8 @@ import { dbChainMockFns, hybridAuthMockFns, resetDbChainMock, + workflowAuthzMockFns, workflowsUtilsMock, - workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -46,7 +46,7 @@ describe('Workflow Chat Status Route', () => { userId: 'user-1', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 403, message: 'Access denied', @@ -66,7 +66,7 @@ describe('Workflow Chat Status Route', () => { userId: 'user-1', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, workflow: { id: 'wf-1', workspaceId: 'ws-1' }, diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index 334f87ef727..5b11700b56b 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -1,12 +1,12 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatStatusAPI') diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index 48e7ce50454..a1fdc2de0d4 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 6fd35a75438..5b8debdc366 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -9,6 +9,7 @@ import { hybridAuthMockFns, loggingSessionMock, requestUtilsMockFns, + workflowAuthzMockFns, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' @@ -22,7 +23,7 @@ const mockCheckHybridAuth = hybridAuthMockFns.mockCheckHybridAuth const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution const mockAuthorizeWorkflowByWorkspacePermission = - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 9df86fbf771..b6e1aeab7b7 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -3,6 +3,7 @@ import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId, isValidUuid } from '@sim/utils/id' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -45,11 +46,7 @@ import { loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' -import { - authorizeWorkflowByWorkspacePermission, - createHttpResponseFromBlock, - workflowHasResponseBlock, -} from '@/lib/workflows/utils' +import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' import { PublicApiNotAllowedError, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts index c7e514a082f..6a9808f1b0e 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts @@ -5,8 +5,8 @@ import { databaseMock, hybridAuthMockFns, + workflowAuthzMockFns, workflowsUtilsMock, - workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -68,7 +68,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { beforeEach(() => { vi.clearAllMocks() hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, }) mockAbortManualExecution.mockReturnValue(false) @@ -193,7 +193,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { it('returns 403 when workflow access is denied', async () => { mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: true, reason: 'recorded' }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: false, message: 'Access denied', status: 403, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 1e7496fe87e..1c23e6c6a09 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' @@ -10,7 +11,6 @@ import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/ev import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { captureServerEvent } from '@/lib/posthog/server' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('CancelExecutionAPI') diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts index 8cf17e17a48..c89246cdece 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { SSE_HEADERS } from '@/lib/core/utils/sse' @@ -11,7 +12,6 @@ import { readExecutionEvents, } from '@/lib/execution/event-buffer' import { formatSSEEvent } from '@/lib/workflows/executor/execution-events' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('ExecutionStreamReconnectAPI') diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.test.ts b/apps/sim/app/api/workflows/[id]/form/status/route.test.ts index 5787f6cd261..3fa8cd76157 100644 --- a/apps/sim/app/api/workflows/[id]/form/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/form/status/route.test.ts @@ -8,8 +8,8 @@ import { dbChainMockFns, hybridAuthMockFns, resetDbChainMock, + workflowAuthzMockFns, workflowsUtilsMock, - workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -45,7 +45,7 @@ describe('Workflow Form Status Route', () => { userId: 'user-1', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 403, message: 'Access denied', @@ -65,7 +65,7 @@ describe('Workflow Form Status Route', () => { userId: 'user-1', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, workflow: { id: 'wf-1', workspaceId: 'ws-1' }, diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.ts b/apps/sim/app/api/workflows/[id]/form/status/route.ts index 00ce06ce3a6..ebe71b1ba29 100644 --- a/apps/sim/app/api/workflows/[id]/form/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/form/status/route.ts @@ -1,11 +1,11 @@ import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('FormStatusAPI') diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index 54b3db3a3c4..e0205917494 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 8201561adc1..2d3ec73334c 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -10,6 +10,7 @@ import { envMock, hybridAuthMockFns, telemetryMock, + workflowAuthzMockFns, workflowsOrchestrationMock, workflowsOrchestrationMockFns, workflowsPersistenceUtilsMock, @@ -24,7 +25,7 @@ const mockLoadWorkflowFromNormalizedTables = workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById const mockAuthorizeWorkflowByWorkspacePermission = - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow const mockDbUpdate = vi.fn() const mockDbSelect = vi.fn() @@ -52,7 +53,7 @@ vi.mock('@/lib/core/config/env', () => envMock) vi.mock('@/lib/core/telemetry', () => telemetryMock) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index c9b68ca180c..7f862131f81 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -10,7 +11,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils' +import { getWorkflowById } from '@/lib/workflows/utils' const logger = createLogger('WorkflowByIdAPI') diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index ac76548f8be..d6c164da53a 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -16,7 +17,6 @@ import { saveWorkflowToNormalizedTables, } from '@/lib/workflows/persistence/utils' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { validateEdges } from '@/stores/workflows/workflow/edge-validation' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' diff --git a/apps/sim/app/api/workflows/[id]/variables/route.test.ts b/apps/sim/app/api/workflows/[id]/variables/route.test.ts index 511d46c09c7..be226926086 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.test.ts @@ -7,13 +7,13 @@ import { auditMock, hybridAuthMockFns, + workflowAuthzMockFns, workflowsUtilsMock, - workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -47,7 +47,7 @@ describe('Workflow Variables API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 404, message: 'Workflow not found', @@ -80,7 +80,7 @@ describe('Workflow Variables API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, workflow: mockWorkflow, @@ -112,7 +112,7 @@ describe('Workflow Variables API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, workflow: mockWorkflow, @@ -142,7 +142,7 @@ describe('Workflow Variables API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 403, message: 'Unauthorized: Access denied to read this workflow', @@ -175,7 +175,7 @@ describe('Workflow Variables API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, workflow: mockWorkflow, @@ -207,7 +207,7 @@ describe('Workflow Variables API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, workflow: mockWorkflow, @@ -250,7 +250,7 @@ describe('Workflow Variables API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 403, message: 'Unauthorized: Access denied to write this workflow', @@ -294,7 +294,7 @@ describe('Workflow Variables API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, workflow: mockWorkflow, @@ -324,7 +324,7 @@ describe('Workflow Variables API Route', () => { userId: 'user-123', authType: 'session', }) - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockRejectedValueOnce( + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockRejectedValueOnce( new Error('Database connection failed') ) diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 9a069c8ed7f..62d90a7e8a5 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -1,14 +1,14 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import type { Variable } from '@/stores/variables/types' const logger = createLogger('WorkflowVariablesAPI') diff --git a/apps/sim/app/api/workflows/middleware.ts b/apps/sim/app/api/workflows/middleware.ts index af745564152..2a66a616c77 100644 --- a/apps/sim/app/api/workflows/middleware.ts +++ b/apps/sim/app/api/workflows/middleware.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import type { NextRequest } from 'next/server' import { type ApiKeyAuthResult, @@ -6,7 +7,7 @@ import { updateApiKeyLastUsed, } from '@/lib/api-key/service' import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid' -import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils' +import { getWorkflowById } from '@/lib/workflows/utils' const logger = createLogger('WorkflowMiddleware') diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index f01c4af4e06..ed10d8dc497 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -41,7 +41,7 @@ vi.mock('@sim/db', () => ({ }, })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index aa211fa9eb3..d8b902388ea 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissions, workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts index 65bf80f1c4b..4677eb2e544 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, not } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index b9404581796..f0539f5b3a9 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -7,7 +8,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { hashApiKey } from '@/lib/api-key/crypto' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts index 29af19b139d..aa9728b7df0 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workspaceBYOKKeys } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateShortId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/workspaces/[id]/data-retention/route.ts b/apps/sim/app/api/workspaces/[id]/data-retention/route.ts index 9d8f02ed89e..f1dbc043b33 100644 --- a/apps/sim/app/api/workspaces/[id]/data-retention/route.ts +++ b/apps/sim/app/api/workspaces/[id]/data-retention/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' diff --git a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts index d0b1bbaf922..3d0f939073e 100644 --- a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts @@ -1,7 +1,7 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index affbf182946..4cabaec2583 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index 0897b99e193..606978a9279 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index b932c53f267..525db3167c4 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index b692a15a2fc..1e9f63a61d0 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index ca9bb9e18f3..a006dd2e963 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,6 +1,6 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts index 53a625e4af3..201a34bf704 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workspaceNotificationSubscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts index 270522f2642..d3afc81e232 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts @@ -1,7 +1,7 @@ -import { createHmac } from 'crypto' import { db } from '@sim/db' import { account, workspaceNotificationSubscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' @@ -36,9 +36,7 @@ interface SlackConfig { function generateSignature(secret: string, timestamp: number, body: string): string { const signatureBase = `${timestamp}.${body}` - const hmac = createHmac('sha256', secret) - hmac.update(signatureBase) - return hmac.digest('hex') + return hmacSha256Hex(signatureBase, secret) } function buildTestPayload(subscription: typeof workspaceNotificationSubscription.$inferSelect) { diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts index ee8753fbdde..25bd80bba8c 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workspaceNotificationSubscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/bulk/route.ts index 66a60ec2cee..a0031101bf4 100644 --- a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/bulk/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/bulk/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissionGroup, permissionGroupMember, permissions } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -6,7 +7,6 @@ import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/route.ts index 41579e1c975..d7a0cdc59b6 100644 --- a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissionGroup, permissionGroupMember, permissions, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -6,7 +7,6 @@ import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/route.ts index 0450d123c41..bc4f4643b5b 100644 --- a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissionGroup, permissionGroupMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/permission-groups/route.ts b/apps/sim/app/api/workspaces/[id]/permission-groups/route.ts index 1181890bc1e..1680b7c1128 100644 --- a/apps/sim/app/api/workspaces/[id]/permission-groups/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permission-groups/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissionGroup, permissionGroupMember, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, count, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index f228f8ef033..236bcb7187b 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index e1fffb08a3c..a17a78a2a55 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { captureServerEvent } from '@/lib/posthog/server' import { archiveWorkspace } from '@/lib/workspaces/lifecycle' diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 38d3cdd63fe..e15b4236061 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -92,7 +92,7 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {}, })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn(), diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 5f6c848257b..a994d6daa48 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getUserOrganization } from '@/lib/billing/organizations/membership' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 067112b74be..43add66c447 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index a83f115e835..e1e874170a9 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -5,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, desc, eq, isNull, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts index 41b394b8a77..a9a319688e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import { hasWorkflowChanged } from '@/lib/workflows/comparison' -import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' import { useVariablesStore } from '@/stores/variables/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index a0e1eab3ac0..454e68bea90 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -1,4 +1,3 @@ -import { createHmac } from 'crypto' import { db, workflowExecutionLogs } from '@sim/db' import { account, @@ -6,9 +5,11 @@ import { workspaceNotificationSubscription, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { formatDuration } from '@sim/utils/formatting' import { generateId } from '@sim/utils/id' +import { getActiveWorkflowContext } from '@sim/workflow-authz' import { task } from '@trigger.dev/sdk' import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' import { @@ -26,7 +27,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import { sendEmail } from '@/lib/messaging/email/mailer' import type { AlertConfig } from '@/lib/notifications/alert-rules' -import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' const logger = createLogger('WorkspaceNotificationDelivery') @@ -62,9 +62,7 @@ interface NotificationPayload { function generateSignature(secret: string, timestamp: number, body: string): string { const signatureBase = `${timestamp}.${body}` - const hmac = createHmac('sha256', secret) - hmac.update(signatureBase) - return hmac.digest('hex') + return hmacSha256Hex(signatureBase, secret) } async function buildPayload( diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 79242ff0037..5d02261dcaa 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -1,18 +1,18 @@ import type { JSX, SVGProps } from 'react' +import type { + OutputCondition, + OutputFieldDefinition, + PrimitiveValueType, + SubBlockType, +} from '@sim/workflow-types/blocks' import type { SelectorKey } from '@/hooks/selectors/types' import type { ToolResponse } from '@/tools/types' +export type { OutputCondition, OutputFieldDefinition, PrimitiveValueType, SubBlockType } +export { isHiddenFromDisplay } from '@sim/workflow-types/blocks' + export type BlockIcon = (props: SVGProps) => JSX.Element export type ParamType = 'string' | 'number' | 'boolean' | 'json' | 'array' | 'file' -export type PrimitiveValueType = - | 'string' - | 'number' - | 'boolean' - | 'json' - | 'array' - | 'file' - | 'file[]' - | 'any' export type BlockCategory = 'blocks' | 'tools' | 'triggers' @@ -117,53 +117,6 @@ export type GenerationType = | 'cron-expression' | 'odata-expression' -export type SubBlockType = - | 'short-input' // Single line input - | 'long-input' // Multi-line input - | 'dropdown' // Select menu - | 'combobox' // Searchable dropdown with text input - | 'slider' // Range input - | 'table' // Grid layout - | 'code' // Code editor - | 'switch' // Toggle button - | 'tool-input' // Tool configuration - | 'skill-input' // Skill selection for agent blocks - | 'checkbox-list' // Multiple selection - | 'grouped-checkbox-list' // Grouped, scrollable checkbox list with select all - | 'condition-input' // Conditional logic - | 'eval-input' // Evaluation input - | 'time-input' // Time input - | 'oauth-input' // OAuth credential selector - | 'webhook-config' // Webhook configuration - | 'schedule-info' // Schedule status display (next run, last ran, failure badge) - | 'file-selector' // File selector for Google Drive, etc. - | 'sheet-selector' // Sheet/tab selector for Google Sheets, Microsoft Excel - | 'project-selector' // Project selector for Jira, Discord, etc. - | 'channel-selector' // Channel selector for Slack, Discord, etc. - | 'user-selector' // User selector for Slack, etc. - | 'folder-selector' // Folder selector for Gmail, etc. - | 'knowledge-base-selector' // Knowledge base selector - | 'knowledge-tag-filters' // Multiple tag filters for knowledge bases - | 'document-selector' // Document selector for knowledge bases - | 'document-tag-entry' // Document tag entry for creating documents - | 'mcp-server-selector' // MCP server selector - | 'mcp-tool-selector' // MCP tool selector - | 'mcp-dynamic-args' // MCP dynamic arguments based on tool schema - | 'input-format' // Input structure format - | 'response-format' // Response structure format - | 'filter-builder' // Filter conditions builder - | 'sort-builder' // Sort conditions builder - | 'file-upload' // File uploader - | 'input-mapping' // Map parent variables to child workflow input schema - | 'variables-input' // Variable assignments for updating workflow variables - | 'messages-input' // Multiple message inputs with role and content for LLM message history - | 'workflow-selector' // Workflow selector for agent tools - | 'workflow-input-mapper' // Dynamic workflow input mapper based on selected workflow - | 'text' // Read-only text display - | 'router-input' // Router route definitions with descriptions - | 'table-selector' // Table selector with link to view table - | 'modal' // Launches a modal component resolved via the client-side modal registry - /** * Selector types that require display name hydration * These show IDs/keys that need to be resolved to human-readable names @@ -203,51 +156,6 @@ export type ToolOutputToValueType = T extends Record export type BlockOutput = PrimitiveValueType | { [key: string]: any } -/** - * Condition for showing an output field. - * Uses the same pattern as SubBlockConfig.condition - */ -export interface OutputCondition { - field: string - value: string | number | boolean | Array - not?: boolean - and?: { - field: string - value: - | string - | number - | boolean - | Array - | undefined - | null - not?: boolean - } -} - -export type OutputFieldDefinition = - | PrimitiveValueType - | { - type: PrimitiveValueType - description?: string - /** - * Optional condition for when this output should be shown. - * If not specified, the output is always shown. - * Uses the same condition format as subBlocks. - */ - condition?: OutputCondition - /** - * If true, this output is hidden from display in the tag dropdown and logs, - * but still available for resolution and execution. - */ - hiddenFromDisplay?: boolean - } - -export function isHiddenFromDisplay(def: unknown): boolean { - return Boolean( - def && typeof def === 'object' && 'hiddenFromDisplay' in def && def.hiddenFromDisplay - ) -} - export interface ParamConfig { type: ParamType description?: string diff --git a/apps/sim/ee/audit-logs/constants.ts b/apps/sim/ee/audit-logs/constants.ts index 445265f4f81..b1bd15fadaf 100644 --- a/apps/sim/ee/audit-logs/constants.ts +++ b/apps/sim/ee/audit-logs/constants.ts @@ -1,5 +1,5 @@ +import { AuditResourceType } from '@sim/audit' import type { ComboboxOption } from '@/components/emcn' -import { AuditResourceType } from '@/lib/audit/types' const ACRONYMS = new Set(['API', 'BYOK', 'MCP', 'OAUTH']) diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index bbf54868a27..e119cdf8e44 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -1,5 +1,15 @@ import { useCallback, useEffect, useRef } from 'react' import { createLogger } from '@sim/logger' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + SUBBLOCK_OPERATIONS, + SUBFLOW_OPERATIONS, + VARIABLE_OPERATIONS, + WORKFLOW_OPERATIONS, +} from '@sim/realtime-protocol/constants' import { generateId } from '@sim/utils/id' import { useQueryClient } from '@tanstack/react-query' import type { Edge } from 'reactflow' @@ -10,16 +20,6 @@ import { getBlock } from '@/blocks' import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' import { invalidateDeploymentQueries } from '@/hooks/queries/deployments' import { useUndoRedo } from '@/hooks/use-undo-redo' -import { - BLOCK_OPERATIONS, - BLOCKS_OPERATIONS, - EDGES_OPERATIONS, - OPERATION_TARGETS, - SUBBLOCK_OPERATIONS, - SUBFLOW_OPERATIONS, - VARIABLE_OPERATIONS, - WORKFLOW_OPERATIONS, -} from '@/socket/constants' import { useNotificationStore } from '@/stores/notifications' import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store' import { usePanelEditorStore } from '@/stores/panel' diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 023b2f1d8f8..2140de378a9 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -8,9 +8,6 @@ declare global { } } -import type { Edge } from 'reactflow' -import { useSession } from '@/lib/auth/auth-client' -import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -18,7 +15,10 @@ import { EDGES_OPERATIONS, OPERATION_TARGETS, UNDO_REDO_OPERATIONS, -} from '@/socket/constants' +} from '@sim/realtime-protocol/constants' +import type { Edge } from 'reactflow' +import { useSession } from '@/lib/auth/auth-client' +import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations' import { useOperationQueue } from '@/stores/operation-queue/store' import { type BatchAddBlocksOperation, diff --git a/apps/sim/lib/api-key/auth.ts b/apps/sim/lib/api-key/auth.ts index 8c359565630..99e527b18f8 100644 --- a/apps/sim/lib/api-key/auth.ts +++ b/apps/sim/lib/api-key/auth.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { generateShortId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { @@ -13,7 +14,6 @@ import { isLegacyApiKeyFormat, } from '@/lib/api-key/crypto' import { env } from '@/lib/core/config/env' -import { safeCompare } from '@/lib/core/security/encryption' const logger = createLogger('ApiKeyAuth') diff --git a/apps/sim/lib/api-key/crypto.ts b/apps/sim/lib/api-key/crypto.ts index 3e3118ccbb8..b8e89ecb030 100644 --- a/apps/sim/lib/api-key/crypto.ts +++ b/apps/sim/lib/api-key/crypto.ts @@ -1,13 +1,12 @@ -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto' import { createLogger } from '@sim/logger' +import { decrypt, encrypt } from '@sim/security/encryption' +import { sha256Hex } from '@sim/security/hash' +import { generateSecureToken } from '@sim/security/tokens' +import { toError } from '@sim/utils/errors' import { env } from '@/lib/core/config/env' const logger = createLogger('ApiKeyCrypto') -/** - * Get the API encryption key from the environment - * @returns The API encryption key - */ function getApiEncryptionKey(): Buffer | null { const key = env.API_ENCRYPTION_KEY if (!key) { @@ -23,77 +22,38 @@ function getApiEncryptionKey(): Buffer | null { } /** - * Encrypts an API key using the dedicated API encryption key - * @param apiKey - The API key to encrypt - * @returns A promise that resolves to an object containing the encrypted API key and IV + * Encrypts an API key using the dedicated API encryption key. Falls back to + * returning the plain key when `API_ENCRYPTION_KEY` is unset, for backward + * compatibility with deployments that predate encryption-at-rest. */ export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> { const key = getApiEncryptionKey() - - // If no API encryption key is set, return the key as-is for backward compatibility if (!key) { return { encrypted: apiKey, iv: '' } } - - const iv = randomBytes(16) - const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) - let encrypted = cipher.update(apiKey, 'utf8', 'hex') - encrypted += cipher.final('hex') - - const authTag = cipher.getAuthTag() - const ivHex = iv.toString('hex') - - // Format: iv:encrypted:authTag - return { - encrypted: `${ivHex}:${encrypted}:${authTag.toString('hex')}`, - iv: ivHex, - } + return encrypt(apiKey, key) } /** - * Decrypts an API key using the dedicated API encryption key - * @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text - * @returns A promise that resolves to an object containing the decrypted API key + * Decrypts an API key previously produced by {@link encryptApiKey}. Values + * that lack the `iv:ciphertext:authTag` shape are assumed to be legacy plain + * text and returned unchanged. */ export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> { const parts = encryptedValue.split(':') - - // Check if this is actually encrypted (contains colons) if (parts.length !== 3) { - // This is a plain text key, return as-is return { decrypted: encryptedValue } } const key = getApiEncryptionKey() - - // If no API encryption key is set, assume it's plain text if (!key) { return { decrypted: encryptedValue } } - const ivHex = parts[0] - const authTagHex = parts[2] - const encrypted = parts[1] - - if (!ivHex || !encrypted || !authTagHex) { - throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"') - } - - const iv = Buffer.from(ivHex, 'hex') - const authTag = Buffer.from(authTagHex, 'hex') - try { - const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) - decipher.setAuthTag(authTag) - - let decrypted = decipher.update(encrypted, 'hex', 'utf8') - decrypted += decipher.final('utf8') - - return { decrypted } - } catch (error: unknown) { - logger.error('API key decryption error:', { - error: error instanceof Error ? error.message : 'Unknown error', - }) + return await decrypt(encryptedValue, key) + } catch (error) { + logger.error('API key decryption error:', { error: toError(error).message }) throw error } } @@ -103,7 +63,7 @@ export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted * @returns A new API key string */ export function generateApiKey(): string { - return `sim_${randomBytes(24).toString('base64url')}` + return `sim_${generateSecureToken(24)}` } /** @@ -111,7 +71,7 @@ export function generateApiKey(): string { * @returns A new encrypted API key string */ export function generateEncryptedApiKey(): string { - return `sk-sim-${randomBytes(24).toString('base64url')}` + return `sk-sim-${generateSecureToken(24)}` } /** @@ -142,5 +102,5 @@ export function isLegacyApiKeyFormat(apiKey: string): boolean { * @returns The hex-encoded SHA-256 digest */ export function hashApiKey(plainKey: string): string { - return createHash('sha256').update(plainKey, 'utf8').digest('hex') + return sha256Hex(plainKey) } diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index f770920fc19..3bc9286c6e1 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -746,7 +746,7 @@ export const auth = betterAuth({ } }, onPasswordReset: async ({ user: resetUser }) => { - const { AuditAction, AuditResourceType, recordAudit } = await import('@/lib/audit/log') + const { AuditAction, AuditResourceType, recordAudit } = await import('@sim/audit') recordAudit({ actorId: resetUser.id, actorName: resetUser.name, diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts index 2f5fd1cb50b..712235df5aa 100644 --- a/apps/sim/lib/auth/internal.ts +++ b/apps/sim/lib/auth/internal.ts @@ -1,8 +1,8 @@ import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { jwtVerify, SignJWT } from 'jose' import { type NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' -import { safeCompare } from '@/lib/core/security/encryption' import { getClientIp } from '@/lib/core/utils/request' const logger = createLogger('CronAuth') diff --git a/apps/sim/lib/copilot/auth/permissions.test.ts b/apps/sim/lib/copilot/auth/permissions.test.ts index dbb7222380b..9e0c4dcb3e8 100644 --- a/apps/sim/lib/copilot/auth/permissions.test.ts +++ b/apps/sim/lib/copilot/auth/permissions.test.ts @@ -10,7 +10,7 @@ const { mockGetActiveWorkflowContext } = vi.hoisted(() => ({ const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -vi.mock('@/lib/workflows/active-context', () => ({ +vi.mock('@sim/workflow-authz', () => ({ getActiveWorkflowContext: mockGetActiveWorkflowContext, })) diff --git a/apps/sim/lib/copilot/auth/permissions.ts b/apps/sim/lib/copilot/auth/permissions.ts index 556bfdb0fb5..ab36213b8ca 100644 --- a/apps/sim/lib/copilot/auth/permissions.ts +++ b/apps/sim/lib/copilot/auth/permissions.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getActiveWorkflowContext } from '@/lib/workflows/active-context' +import { getActiveWorkflowContext } from '@sim/workflow-authz' import { getUserEntityPermissions, type PermissionType } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotPermissions') diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index c96287a6d2c..9dd3e5d7c05 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -1,9 +1,11 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + authorizeWorkflowByWorkspacePermission, + getActiveWorkflowRecord, +} from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' -import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index ba12391aee3..df27a2b140d 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -1,3 +1,4 @@ +import { generateId } from '@sim/utils/id' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -191,7 +192,7 @@ export function buildPersistedAssistantMessage( requestId?: string ): PersistedMessage { const message: PersistedMessage = { - id: crypto.randomUUID(), + id: generateId(), role: 'assistant', content: result.content, timestamp: new Date().toISOString(), @@ -488,7 +489,7 @@ function normalizeBlocks(rawBlocks: RawBlock[], messageContent: string): Persist export function normalizeMessage(raw: Record): PersistedMessage { const msg: PersistedMessage = { - id: (raw.id as string) ?? crypto.randomUUID(), + id: (raw.id as string) ?? generateId(), role: (raw.role as 'user' | 'assistant') ?? 'assistant', content: (raw.content as string) ?? '', timestamp: (raw.timestamp as string) ?? new Date().toISOString(), diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index ecd52209df2..1d598f1b4c1 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -2,6 +2,7 @@ import { type Context as OtelContext, context as otelContextApi } from '@opentel import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -615,8 +616,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { // trace ID) as soon as startCopilotOtelRoot runs. Empty only in the // narrow pre-otelRoot window where errors don't correlate anyway. let requestId = '' - const executionId = crypto.randomUUID() - const runId = crypto.randomUUID() + const executionId = generateId() + const runId = generateId() try { const session = await getSession() @@ -628,7 +629,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { const body = ChatMessageSchema.parse(await req.json()) const normalizedContexts = normalizeContexts(body.contexts) ?? [] - userMessageId = body.userMessageId || crypto.randomUUID() + userMessageId = body.userMessageId || generateId() otelRoot = startCopilotOtelRoot({ streamId: userMessageId, diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index f62109e9202..c82217c90f4 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -1,6 +1,10 @@ import { db } from '@sim/db' import { document, knowledgeBase, templates } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + authorizeWorkflowByWorkspacePermission, + getActiveWorkflowRecord, +} from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { serializeFileMeta, @@ -11,10 +15,8 @@ import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { getTableById } from '@/lib/table/service' import { canAccessTemplate } from '@/lib/templates/permissions' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { isHiddenFromDisplay } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' diff --git a/apps/sim/lib/copilot/request/http.ts b/apps/sim/lib/copilot/request/http.ts index 713e01eabb8..4f2ec7de374 100644 --- a/apps/sim/lib/copilot/request/http.ts +++ b/apps/sim/lib/copilot/request/http.ts @@ -1,10 +1,10 @@ +import { safeCompare } from '@sim/security/compare' import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' import { env } from '@/lib/core/config/env' -import { safeCompare } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' export const NotificationStatus = { diff --git a/apps/sim/lib/copilot/request/lifecycle/finalize.ts b/apps/sim/lib/copilot/request/lifecycle/finalize.ts index 319a5717f83..56c34efaf1e 100644 --- a/apps/sim/lib/copilot/request/lifecycle/finalize.ts +++ b/apps/sim/lib/copilot/request/lifecycle/finalize.ts @@ -64,7 +64,7 @@ export async function finalizeStream( span.setStatus({ code: SpanStatusCode.OK }) } } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))) + span.recordException(toError(error)) span.setStatus({ code: SpanStatusCode.ERROR, message: 'finalize threw' }) throw error } finally { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index f39ddf6a9c5..bc5ab1da6d0 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1,6 +1,7 @@ import type { Context } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants' @@ -556,7 +557,7 @@ function isRetryableStreamError(error: unknown): boolean { function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise { if (!abortSignal) { - return new Promise((resolve) => setTimeout(resolve, ms)) + return sleep(ms) } if (abortSignal.aborted) { return Promise.resolve() diff --git a/apps/sim/lib/copilot/request/otel.ts b/apps/sim/lib/copilot/request/otel.ts index 57d21d571f3..5ceb78c8a73 100644 --- a/apps/sim/lib/copilot/request/otel.ts +++ b/apps/sim/lib/copilot/request/otel.ts @@ -10,6 +10,7 @@ import { TraceFlags, trace, } from '@opentelemetry/api' +import { toError } from '@sim/utils/errors' import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1' import { CopilotBranchKind, @@ -92,12 +93,12 @@ export function isActionableErrorStatus(code: number): boolean { // client disconnect, internal timeout, uncategorized AbortError — // becomes a real error that the dashboards will surface. export function markSpanForError(span: Span, error: unknown): void { - const asError = error instanceof Error ? error : new Error(String(error)) + const asError = toError(error) span.recordException(asError) if (!isExplicitUserStopError(error)) { span.setStatus({ code: SpanStatusCode.ERROR, - message: error instanceof Error ? error.message : String(error), + message: asError.message, }) } } diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 1d1760b7f46..772c4ba03ba 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { parse as csvParse } from 'csv-parse/sync' import { eq } from 'drizzle-orm' import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' @@ -107,7 +108,7 @@ export async function maybeWriteOutputToTable( } const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) const values = chunk.map((rowData, j) => ({ - id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + id: `row_${generateId().replace(/-/g, '')}`, tableId: outputTable, workspaceId: context.workspaceId!, data: rowData, @@ -251,7 +252,7 @@ export async function maybeWriteReadCsvToTable( } const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) const values = chunk.map((rowData, j) => ({ - id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + id: `row_${generateId().replace(/-/g, '')}`, tableId: outputTable, workspaceId: context.workspaceId!, data: rowData, diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts index b989ea34396..a435511a2f8 100644 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ b/apps/sim/lib/copilot/tools/handlers/access.ts @@ -1,7 +1,8 @@ import { db } from '@sim/db' import { permissions, workspace } from '@sim/db/schema' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq, isNull } from 'drizzle-orm' -import { authorizeWorkflowByWorkspacePermission, type getWorkflowById } from '@/lib/workflows/utils' +import type { getWorkflowById } from '@/lib/workflows/utils' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' type WorkflowRecord = NonNullable>> diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index b57bf076f89..149b0084d80 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { chat, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { mcpPubSub } from '@/lib/mcp/pubsub' diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index 785fc7bca39..6af4a09b638 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -26,7 +26,7 @@ vi.mock('@sim/db', () => ({ workflowMcpTool: {}, })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index cde7a541780..3953878c886 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { chat, @@ -9,7 +10,6 @@ import { import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { mcpPubSub } from '@/lib/mcp/pubsub' import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' diff --git a/apps/sim/lib/copilot/tools/handlers/jobs.ts b/apps/sim/lib/copilot/tools/handlers/jobs.ts index eb797a771f4..6f70ac1fa24 100644 --- a/apps/sim/lib/copilot/tools/handlers/jobs.ts +++ b/apps/sim/lib/copilot/tools/handlers/jobs.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { copilotChats, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { parseCronToHumanReadable, validateCronExpression } from '@/lib/workflows/schedules/utils' diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 7db12ce4b5d..f7341038b90 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' import { getServePathPrefix } from '@/lib/uploads' diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 2fb2e0eb6f8..af410e9cf1c 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { pendingCredentialDraft, user } from '@sim/db/schema' import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -140,7 +141,7 @@ export async function generateOAuthLink( await db .insert(pendingCredentialDraft) .values({ - id: crypto.randomUUID(), + id: generateId(), userId, workspaceId, providerId, diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index ef3a652c38d..7e1d8281830 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -1,10 +1,10 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { createWorkspaceApiKey } from '@/lib/api-key/auth' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index 974388e9ece..3c6367fb638 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1' import { @@ -26,7 +27,6 @@ import { saveWorkflowToNormalizedTables, } from '@/lib/workflows/persistence/utils' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation' diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts index 2258ec101df..3ab0cc2d573 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts @@ -1,10 +1,10 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq } from 'drizzle-orm' import { GetWorkflowLogs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import type { TraceSpan } from '@/stores/logs/filters/types' const logger = createLogger('GetWorkflowLogsServerTool') diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index ea14dcad181..0acd2c4e9e3 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -248,7 +248,6 @@ export const env = createEnv({ // Real-time Communication SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features - SOCKET_PORT: z.number().optional(), // Port for WebSocket server PORT: z.number().optional(), // Main application port INTERNAL_API_BASE_URL: z.string().optional(), // Optional internal base URL for server-side self-calls; must include protocol if set (e.g., http://sim-app.namespace.svc.cluster.local:3000) ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index 10a0781f83a..c3658b9d39a 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -1,4 +1,6 @@ -import { createHash, createHmac, timingSafeEqual } from 'crypto' +import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' +import { hmacSha256Hex } from '@sim/security/hmac' import type { NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { isDev } from '@/lib/core/config/feature-flags' @@ -9,12 +11,12 @@ import { isDev } from '@/lib/core/config/feature-flags' */ function signPayload(payload: string): string { - return createHmac('sha256', env.BETTER_AUTH_SECRET).update(payload).digest('hex') + return hmacSha256Hex(payload, env.BETTER_AUTH_SECRET) } function passwordSlot(encryptedPassword?: string | null): string { if (!encryptedPassword) return '' - return createHash('sha256').update(encryptedPassword).digest('hex').slice(0, 8) + return sha256Hex(encryptedPassword).slice(0, 8) } function generateAuthToken( @@ -46,10 +48,7 @@ export function validateAuthToken( const sig = decoded.slice(lastColon + 1) const expectedSig = signPayload(payload) - if ( - sig.length !== expectedSig.length || - !timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig)) - ) { + if (!safeCompare(sig, expectedSig)) { return false } diff --git a/apps/sim/lib/core/security/encryption.ts b/apps/sim/lib/core/security/encryption.ts index 811a60ada48..3d2e7390ec1 100644 --- a/apps/sim/lib/core/security/encryption.ts +++ b/apps/sim/lib/core/security/encryption.ts @@ -1,5 +1,6 @@ -import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'crypto' import { createLogger } from '@sim/logger' +import { decrypt, encrypt } from '@sim/security/encryption' +import { toError } from '@sim/utils/errors' import { env } from '@/lib/core/config/env' const logger = createLogger('Encryption') @@ -13,59 +14,23 @@ function getEncryptionKey(): Buffer { } /** - * Encrypts a secret using AES-256-GCM + * Encrypts a secret using AES-256-GCM with the app's `ENCRYPTION_KEY`. * @param secret - The secret to encrypt - * @returns A promise that resolves to an object containing the encrypted secret and IV + * @returns A promise resolving to the encrypted value (`iv:ciphertext:authTag`) and the IV. */ 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, { authTagLength: 16 }) - let encrypted = cipher.update(secret, 'utf8', 'hex') - encrypted += cipher.final('hex') - - const authTag = cipher.getAuthTag() - const ivHex = iv.toString('hex') - - // Format: iv:encrypted:authTag - return { - encrypted: `${ivHex}:${encrypted}:${authTag.toString('hex')}`, - iv: ivHex, - } + return encrypt(secret, getEncryptionKey()) } /** - * 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 + * Decrypts a secret previously produced by {@link encryptSecret}. Logs and + * rethrows on malformed input or tampered ciphertext. */ export async function decryptSecret(encryptedValue: string): Promise<{ decrypted: string }> { - const parts = encryptedValue.split(':') - const ivHex = parts[0] - const authTagHex = parts[parts.length - 1] - const encrypted = parts.slice(1, -1).join(':') - - 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') - try { - const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) - decipher.setAuthTag(authTag) - - let decrypted = decipher.update(encrypted, 'hex', 'utf8') - decrypted += decipher.final('utf8') - - return { decrypted } - } catch (error: unknown) { - logger.error('Decryption error:', { - error: error instanceof Error ? error.message : 'Unknown error', - }) + return await decrypt(encryptedValue, getEncryptionKey()) + } catch (error) { + logger.error('Decryption error:', { error: toError(error).message }) throw error } } @@ -85,17 +50,3 @@ export function generatePassword(length = 24): string { return result } - -/** - * Compares two strings in constant time to prevent timing attacks. - * Used for HMAC signature validation. - * @param a - First string to compare - * @param b - Second string to compare - * @returns True if strings are equal, false otherwise - */ -export function safeCompare(a: string, b: string): boolean { - const key = 'safeCompare' - const ha = createHmac('sha256', key).update(a).digest() - const hb = createHmac('sha256', key).update(b).digest() - return timingSafeEqual(ha, hb) -} diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts index a90f9368ffb..a74950ec982 100644 --- a/apps/sim/lib/execution/preprocessing.test.ts +++ b/apps/sim/lib/execution/preprocessing.test.ts @@ -28,7 +28,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, })) -vi.mock('@/lib/workflows/active-context', () => ({ +vi.mock('@sim/workflow-authz', () => ({ getActiveWorkflowRecord: vi.fn().mockResolvedValue({ id: 'workflow-1', workspaceId: 'workspace-1', diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 2aab6c5d6a1..567dbede2a8 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -1,5 +1,6 @@ import type { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' @@ -7,7 +8,6 @@ import { getExecutionTimeout } from '@/lib/core/execution-limits' import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' import { LoggingSession, type SessionStartParams } from '@/lib/logs/execution/logging-session' -import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' import type { CoreTriggerType } from '@/stores/logs/filters/types' diff --git a/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts b/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts index 35195972dd5..2fea91bfe0a 100644 --- a/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts +++ b/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts @@ -28,7 +28,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, })) -vi.mock('@/lib/workflows/active-context', () => ({ +vi.mock('@sim/workflow-authz', () => ({ getActiveWorkflowRecord: vi.fn().mockResolvedValue({ id: 'workflow-1', workspaceId: 'workspace-1', diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index e8bfac6679e..5e3cf68918a 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -1,7 +1,7 @@ -import { createHash } from 'crypto' import { db } from '@sim/db' import { document, embedding, knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { generateId } from '@sim/utils/id' import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm' import type { @@ -155,7 +155,7 @@ export async function createChunk( knowledgeBaseId, documentId, chunkIndex: nextChunkIndex, - chunkHash: createHash('sha256').update(chunkData.content).digest('hex'), + chunkHash: sha256Hex(chunkData.content), content: chunkData.content, contentLength: chunkData.content.length, tokenCount: tokenCount.count, @@ -368,7 +368,7 @@ export async function updateChunk( dbUpdateData.content = content dbUpdateData.contentLength = newContentLength dbUpdateData.tokenCount = tokenCount.count - dbUpdateData.chunkHash = createHash('sha256').update(content).digest('hex') + dbUpdateData.chunkHash = sha256Hex(content) // Add the embedding field to the update data dbUpdateData.embedding = embeddings[0] } else { @@ -376,7 +376,7 @@ export async function updateChunk( dbUpdateData.content = content dbUpdateData.contentLength = newContentLength dbUpdateData.tokenCount = oldTokenCount // Keep the same token count if content is identical - dbUpdateData.chunkHash = createHash('sha256').update(content).digest('hex') + dbUpdateData.chunkHash = sha256Hex(content) } if (updateData.enabled !== undefined) { diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 14a7858e006..84241c57725 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -1,4 +1,3 @@ -import crypto from 'crypto' import { db } from '@sim/db' import { document, @@ -8,6 +7,7 @@ import { knowledgeConnector, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { tasks } from '@trigger.dev/sdk' @@ -520,7 +520,7 @@ export async function processDocumentAsync( knowledgeBaseId, documentId, chunkIndex, - chunkHash: crypto.createHash('sha256').update(chunk.text).digest('hex'), + chunkHash: sha256Hex(chunk.text), content: chunk.text, contentLength: chunk.text.length, tokenCount: Math.ceil(chunk.text.length / 4), diff --git a/apps/sim/lib/logs/events.ts b/apps/sim/lib/logs/events.ts index 02703ba7ce0..5a40fab190d 100644 --- a/apps/sim/lib/logs/events.ts +++ b/apps/sim/lib/logs/events.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workspaceNotificationDelivery, workspaceNotificationSubscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { getActiveWorkflowContext } from '@sim/workflow-authz' import { and, eq, or, sql } from 'drizzle-orm' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import type { WorkflowExecutionLog } from '@/lib/logs/types' @@ -10,7 +11,6 @@ import { type AlertConfig, shouldTriggerAlert, } from '@/lib/notifications/alert-rules' -import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { executeNotificationDelivery, workspaceNotificationDeliveryTask, diff --git a/apps/sim/lib/logs/execution/snapshot/service.ts b/apps/sim/lib/logs/execution/snapshot/service.ts index 82327c1903e..ea6cec7e6d1 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.ts @@ -1,7 +1,7 @@ -import { createHash } from 'crypto' import { db } from '@sim/db' import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { generateId } from '@sim/utils/id' import { and, eq, lt, notExists, sql } from 'drizzle-orm' import type { @@ -85,7 +85,7 @@ export class SnapshotService implements ISnapshotService { computeStateHash(state: WorkflowState): string { const normalizedState = normalizeWorkflowState(state) const stateString = normalizedStringify(normalizedState) - return createHash('sha256').update(stateString).digest('hex') + return sha256Hex(stateString) } async cleanupOrphanedSnapshots(olderThanDays: number): Promise { diff --git a/apps/sim/lib/messaging/email/unsubscribe.ts b/apps/sim/lib/messaging/email/unsubscribe.ts index 30ace1d69a4..3c02fdfe3a3 100644 --- a/apps/sim/lib/messaging/email/unsubscribe.ts +++ b/apps/sim/lib/messaging/email/unsubscribe.ts @@ -1,7 +1,8 @@ -import { createHash, randomBytes } from 'crypto' +import { randomBytes } from 'crypto' import { db } from '@sim/db' import { settings, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { eq } from 'drizzle-orm' import { env } from '@/lib/core/config/env' import type { EmailType } from '@/lib/messaging/email/mailer' @@ -20,9 +21,7 @@ export interface EmailPreferences { */ export function generateUnsubscribeToken(email: string, emailType = 'marketing'): string { const salt = randomBytes(16).toString('hex') - const hash = createHash('sha256') - .update(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`) - .digest('hex') + const hash = sha256Hex(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`) return `${salt}:${hash}:${emailType}` } @@ -40,9 +39,7 @@ export function verifyUnsubscribeToken( if (parts.length === 2) { const [salt, expectedHash] = parts - const hash = createHash('sha256') - .update(`${email}:${salt}:${env.BETTER_AUTH_SECRET}`) - .digest('hex') + const hash = sha256Hex(`${email}:${salt}:${env.BETTER_AUTH_SECRET}`) return { valid: hash === expectedHash, emailType: 'marketing' } } @@ -50,9 +47,7 @@ export function verifyUnsubscribeToken( const [salt, expectedHash, emailType] = parts if (!salt || !expectedHash || !emailType) return { valid: false } - const hash = createHash('sha256') - .update(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`) - .digest('hex') + const hash = sha256Hex(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`) return { valid: hash === expectedHash, emailType } } catch (error) { diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index 27840dbaf0e..03123a2bd3d 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -56,7 +56,7 @@ vi.mock('@/lib/core/async-jobs', () => ({ vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) -vi.mock('@/lib/core/security/encryption', () => ({ +vi.mock('@sim/security/compare', () => ({ safeCompare: vi.fn().mockReturnValue(true), })) diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index a6c112ff8d1..0580a899b8f 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -1,7 +1,7 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' -import { safeCompare } from '@/lib/core/security/encryption' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, @@ -24,7 +24,7 @@ function validateAshbySignature(secretToken: string, signature: string, body: st return false } const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secretToken) return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Ashby signature:', error) diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts index 7ac8f87c7b5..ea9debff8f5 100644 --- a/apps/sim/lib/webhooks/providers/attio.ts +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -1,8 +1,8 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -29,7 +29,7 @@ function validateAttioSignature(secret: string, signature: string, body: string) }) return false } - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Attio signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/calcom.ts b/apps/sim/lib/webhooks/providers/calcom.ts index b018b16f581..798cea64f20 100644 --- a/apps/sim/lib/webhooks/providers/calcom.ts +++ b/apps/sim/lib/webhooks/providers/calcom.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' -import { safeCompare } from '@/lib/core/security/encryption' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' @@ -22,7 +22,7 @@ function validateCalcomSignature(secret: string, signature: string, body: string } else { providedSignature = signature } - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Cal.com signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/circleback.ts b/apps/sim/lib/webhooks/providers/circleback.ts index 1beb8a6814f..ef0c9facd2e 100644 --- a/apps/sim/lib/webhooks/providers/circleback.ts +++ b/apps/sim/lib/webhooks/providers/circleback.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' -import { safeCompare } from '@/lib/core/security/encryption' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import type { FormatInputContext, FormatInputResult, @@ -20,7 +20,7 @@ function validateCirclebackSignature(secret: string, signature: string, body: st }) return false } - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Circleback signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/fireflies.ts b/apps/sim/lib/webhooks/providers/fireflies.ts index 0897a7ff161..e7e67da0599 100644 --- a/apps/sim/lib/webhooks/providers/fireflies.ts +++ b/apps/sim/lib/webhooks/providers/fireflies.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' -import { safeCompare } from '@/lib/core/security/encryption' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import type { FormatInputContext, FormatInputResult, @@ -27,7 +27,7 @@ function validateFirefliesSignature(secret: string, signature: string, body: str return false } const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Fireflies signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/github.ts b/apps/sim/lib/webhooks/providers/github.ts index a0fd90f2e6d..20d1a3fff33 100644 --- a/apps/sim/lib/webhooks/providers/github.ts +++ b/apps/sim/lib/webhooks/providers/github.ts @@ -1,7 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' import type { AuthContext, EventMatchContext, diff --git a/apps/sim/lib/webhooks/providers/gong.ts b/apps/sim/lib/webhooks/providers/gong.ts index f1c3f355dc3..399e9074d20 100644 --- a/apps/sim/lib/webhooks/providers/gong.ts +++ b/apps/sim/lib/webhooks/providers/gong.ts @@ -1,5 +1,5 @@ -import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { toError } from '@sim/utils/errors' import * as jose from 'jose' import { NextResponse } from 'next/server' @@ -102,7 +102,7 @@ export async function verifyGongJwtAuth(ctx: AuthContext): Promise): string { - return crypto.createHash('sha256').update(stableSerialize(body), 'utf8').digest('hex') + return sha256Hex(stableSerialize(body)) } function pickRecordId(body: Record, record: Record): string { diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index a5488f0aa4e..a48032c08d5 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -1,8 +1,8 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' import { secureFetchWithPinnedIP, validateUrlWithDNS, @@ -207,10 +207,7 @@ function validateSlackSignature( const providedSignature = signature.substring(3) const basestring = `v0:${timestamp}:${rawBody}` - const computedHash = crypto - .createHmac('sha256', signingSecret) - .update(basestring, 'utf8') - .digest('hex') + const computedHash = hmacSha256Hex(basestring, signingSecret) return safeCompare(computedHash, providedSignature) } catch (error) { diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.ts b/apps/sim/lib/webhooks/providers/twilio-voice.ts index be0417ef71d..543264cd730 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.ts @@ -1,7 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' import type { AuthContext, FormatInputContext, diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts index 8c96d05907f..16df0e6c47d 100644 --- a/apps/sim/lib/webhooks/providers/typeform.ts +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' -import { safeCompare } from '@/lib/core/security/encryption' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Base64 } from '@sim/security/hmac' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, @@ -23,7 +23,7 @@ function validateTypeformSignature(secret: string, signature: string, body: stri return false } const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') + const computedHash = hmacSha256Base64(body, secret) return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Typeform signature:', error) diff --git a/apps/sim/lib/webhooks/providers/utils.ts b/apps/sim/lib/webhooks/providers/utils.ts index f2a56047081..dff3db7ce1e 100644 --- a/apps/sim/lib/webhooks/providers/utils.ts +++ b/apps/sim/lib/webhooks/providers/utils.ts @@ -1,7 +1,7 @@ import type { Logger } from '@sim/logger' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types' const logger = createLogger('WebhookProviderAuth') diff --git a/apps/sim/lib/webhooks/providers/vercel.ts b/apps/sim/lib/webhooks/providers/vercel.ts index 218afb3d6fd..edf5f9d6220 100644 --- a/apps/sim/lib/webhooks/providers/vercel.ts +++ b/apps/sim/lib/webhooks/providers/vercel.ts @@ -1,7 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index e1a76ef2188..6e20d4f7287 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -1,10 +1,11 @@ -import { createHash, createHmac } from 'crypto' import { db, workflowDeploymentVersion } from '@sim/db' import { webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' +import { hmacSha256Hex } from '@sim/security/hmac' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' import type { FormatInputContext, FormatInputResult, @@ -110,7 +111,7 @@ function validateWhatsAppSignature(secret: string, signature: string, body: stri } const providedSignature = signature.substring(7) - const computedSignature = createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedSignature = hmacSha256Hex(body, secret) return safeCompare(computedSignature, providedSignature) } catch (error) { @@ -125,7 +126,7 @@ function buildWhatsAppIdempotencyKey(keys: Set): string | null { } const sortedKeys = Array.from(keys).sort() - const digest = createHash('sha256').update(sortedKeys.join('|'), 'utf8').digest('hex') + const digest = sha256Hex(sortedKeys.join('|')) return `whatsapp:${sortedKeys.length}:${digest}` } diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 409d7c8f921..60f4e0ef749 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -1,11 +1,11 @@ -import crypto from 'crypto' import { db, webhook, workflow } from '@sim/db' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' import type { AuthContext, @@ -41,7 +41,7 @@ export function validateZoomSignature( } const message = `v0:${timestamp}:${body}` - const computedHash = crypto.createHmac('sha256', secretToken).update(message).digest('hex') + const computedHash = hmacSha256Hex(message, secretToken) const expectedSignature = `v0=${computedHash}` return safeCompare(expectedSignature, signature) @@ -206,10 +206,7 @@ export const zoomHandler: WebhookProviderHandler = { secretToken && validateZoomSignature(secretToken, signature, timestamp, bodyForSignature) ) { - const hashForValidate = crypto - .createHmac('sha256', secretToken) - .update(plainToken) - .digest('hex') + const hashForValidate = hmacSha256Hex(plainToken, secretToken) return NextResponse.json({ plainToken, diff --git a/apps/sim/lib/workflows/active-context.ts b/apps/sim/lib/workflows/active-context.ts deleted file mode 100644 index 612a00e4d37..00000000000 --- a/apps/sim/lib/workflows/active-context.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { db } from '@sim/db' -import { workflow, workspace } from '@sim/db/schema' -import { and, eq, isNull } from 'drizzle-orm' - -export type ActiveWorkflowRecord = typeof workflow.$inferSelect - -export interface ActiveWorkflowContext { - workflow: ActiveWorkflowRecord - workspaceId: string -} - -/** - * Returns the workflow and workspace context only when both are still active. - */ -export async function getActiveWorkflowContext( - workflowId: string -): Promise { - const rows = await db - .select({ - workflow, - workspaceId: workspace.id, - }) - .from(workflow) - .innerJoin(workspace, eq(workflow.workspaceId, workspace.id)) - .where( - and(eq(workflow.id, workflowId), isNull(workflow.archivedAt), isNull(workspace.archivedAt)) - ) - .limit(1) - - if (rows.length === 0) { - return null - } - - return { - workflow: rows[0].workflow, - workspaceId: rows[0].workspaceId, - } -} - -/** - * Returns the workflow row only when its parent workspace is also active. - */ -export async function getActiveWorkflowRecord( - workflowId: string -): Promise { - const context = await getActiveWorkflowContext(workflowId) - return context?.workflow ?? null -} - -export async function assertActiveWorkflowContext( - workflowId: string -): Promise { - const context = await getActiveWorkflowContext(workflowId) - if (!context) { - throw new Error(`Active workflow not found: ${workflowId}`) - } - return context -} diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 86b3a085bdb..da8092d3366 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -59,7 +59,7 @@ vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({ vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) -vi.mock('@/lib/workflows/subblocks', () => ({ +vi.mock('@sim/workflow-persistence/subblocks', () => ({ mergeSubblockStateWithValues: mergeSubblockStateWithValuesMock, })) diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index d115b17486d..13ff2510570 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -4,6 +4,7 @@ */ import { createLogger } from '@sim/logger' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import type { Edge } from 'reactflow' import { z } from 'zod' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' @@ -14,7 +15,6 @@ import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' -import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { Executor } from '@/executor' diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index e10323445d4..d8c93253167 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -1,9 +1,9 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' import { performFullDeploy } from '@/lib/workflows/orchestration/deploy' diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 4f81701b558..2b23ae23dd8 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -1,8 +1,8 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { NextRequest } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl, getSocketServerUrl } from '@/lib/core/utils/urls' diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index 03264f20153..6d3f52fd49f 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { a2aAgent, @@ -11,7 +12,6 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull } from 'drizzle-orm' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle' import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 3e757e6b2bf..eaf0b2a656a 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -1,8 +1,8 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { templates, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { archiveWorkflow } from '@/lib/workflows/lifecycle' import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' diff --git a/apps/sim/lib/workflows/persistence/duplicate.test.ts b/apps/sim/lib/workflows/persistence/duplicate.test.ts index e2aa9033520..0bd948cdeca 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.test.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.test.ts @@ -4,6 +4,7 @@ import { permissionsMock, permissionsMockFns, + workflowAuthzMockFns, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' @@ -11,7 +12,7 @@ import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' const mockAuthorizeWorkflowByWorkspacePermission = - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions const { mockDb } = vi.hoisted(() => ({ diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index ff111d203a0..5386455bb1a 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -8,12 +8,10 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull, min } from 'drizzle-orm' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' -import { - authorizeWorkflowByWorkspacePermission, - deduplicateWorkflowName, -} from '@/lib/workflows/utils' +import { deduplicateWorkflowName } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import type { Variable } from '@/stores/variables/types' import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index bbf939b78a5..80af8e9dad4 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -1,33 +1,29 @@ -import { - db, - workflow, - workflowBlocks, - workflowDeploymentVersion, - workflowEdges, - workflowSubflows, -} from '@sim/db' +import { db, workflow, workflowDeploymentVersion } from '@sim/db' import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' +import { getActiveWorkflowContext } from '@sim/workflow-authz' +import { + loadWorkflowFromNormalizedTablesRaw, + persistMigratedBlocks, +} from '@sim/workflow-persistence/load' +import { saveWorkflowToNormalizedTables as saveWorkflowToNormalizedTablesRaw } from '@sim/workflow-persistence/save' +import type { DbOrTx, NormalizedWorkflowData } from '@sim/workflow-persistence/types' +import type { BlockState, Loop, Parallel, WorkflowState } from '@sim/workflow-types/workflow' +import type { InferSelectModel } from 'drizzle-orm' import { and, desc, eq, inArray, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' -import type { DbOrTx } from '@/lib/db/types' -import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { backfillCanonicalModes, migrateSubblockIds, } from '@/lib/workflows/migrations/subblock-migrations' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' -import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' -import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' const logger = createLogger('WorkflowDBHelpers') +export type { DbOrTx, NormalizedWorkflowData } from '@sim/workflow-persistence/types' export type WorkflowDeploymentVersion = InferSelectModel -type SubflowInsert = InferInsertModel export interface WorkflowDeploymentVersionResponse { id: string @@ -40,14 +36,6 @@ export interface WorkflowDeploymentVersionResponse { deployedBy?: string | null } -export interface NormalizedWorkflowData { - blocks: Record - edges: Edge[] - loops: Record - parallels: Record - isFromNormalizedTables: boolean // Flag to indicate source (true = normalized tables, false = deployed state) -} - export interface DeployedWorkflowData extends NormalizedWorkflowData { deploymentVersionId: string variables?: Record @@ -188,10 +176,6 @@ const applyBlockMigrations = createMigrationPipeline([ /** * Migrates agent blocks from old format (systemPrompt/userPrompt) to new format (messages array) - * This ensures backward compatibility for workflows created before the messages-input refactor. - * - * @param blocks - Record of block states to migrate - * @returns Migrated blocks with messages array format for agent blocks */ export function migrateAgentBlocksToMessagesFormat( blocks: Record @@ -203,11 +187,9 @@ export function migrateAgentBlocksToMessagesFormat( const userPrompt = block.subBlocks.userPrompt?.value const messages = block.subBlocks.messages?.value - // Only migrate if old format exists and new format doesn't if ((systemPrompt || userPrompt) && !messages) { const newMessages: Array<{ role: string; content: string }> = [] - // Add system message first (industry standard) if (systemPrompt) { newMessages.push({ role: 'system', @@ -215,16 +197,13 @@ export function migrateAgentBlocksToMessagesFormat( }) } - // Add user message if (userPrompt) { let userContent = userPrompt - // Handle object format (e.g., { input: "..." }) if (typeof userContent === 'object' && userContent !== null) { if ('input' in userContent) { userContent = (userContent as any).input } else { - // If it's an object but doesn't have 'input', stringify it userContent = JSON.stringify(userContent) } } @@ -235,7 +214,6 @@ export function migrateAgentBlocksToMessagesFormat( }) } - // Return block with migrated messages subBlock return [ id, { @@ -259,11 +237,6 @@ export function migrateAgentBlocksToMessagesFormat( const CREDENTIAL_SUBBLOCK_IDS = new Set(['credential', 'triggerCredentials']) -/** - * Migrates legacy `account.id` values to `credential.id` in OAuth subblocks. - * Collects all potential legacy IDs in a single batch query for efficiency. - * Also migrates `tool.params.credential` in agent block tool arrays. - */ async function migrateCredentialIds( blocks: Record, workspaceId: string @@ -359,275 +332,60 @@ async function migrateCredentialIds( } /** - * Load workflow state from normalized tables - * Returns null if no data found (fallback to JSON blob) + * Load workflow from normalized tables and apply all block migrations + * (credential ID rewrites, agent message migration, subblock ID migrations, + * canonical-mode backfill, tool sanitization). Returns null if the workflow + * has not been migrated to normalized tables yet. */ export async function loadWorkflowFromNormalizedTables( workflowId: string ): Promise { - try { - const [blocks, edges, subflows, [workflowRow]] = await Promise.all([ - db.select().from(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), - db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), - db.select().from(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), - db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1), - ]) + const raw = await loadWorkflowFromNormalizedTablesRaw(workflowId) + if (!raw) return null - // If no blocks found, assume this workflow hasn't been migrated yet - if (blocks.length === 0) { - return null - } + const { blocks: finalBlocks, migrated } = await applyBlockMigrations(raw.blocks, raw.workspaceId) - // Convert blocks to the expected format - const blocksMap: Record = {} - blocks.forEach((block) => { - const blockData = block.data || {} - - const assembled: BlockState = { - id: block.id, - type: block.type, - name: block.name, - position: { - x: Number(block.positionX), - y: Number(block.positionY), - }, - enabled: block.enabled, - horizontalHandles: block.horizontalHandles, - advancedMode: block.advancedMode, - triggerMode: block.triggerMode, - height: Number(block.height), - subBlocks: (block.subBlocks as BlockState['subBlocks']) || {}, - outputs: (block.outputs as BlockState['outputs']) || {}, - data: blockData, - locked: block.locked, - } + if (migrated) { + Promise.resolve().then(() => persistMigratedBlocks(workflowId, raw.blocks, finalBlocks)) + } - blocksMap[block.id] = assembled - }) + const patchedLoops: Record = { ...raw.loops } + const patchedParallels: Record = { ...raw.parallels } - if (!workflowRow?.workspaceId) { - throw new Error(`Workflow ${workflowId} has no workspace`) + for (const id of Object.keys(raw.loops)) { + if (finalBlocks[id]) { + patchedLoops[id] = { ...raw.loops[id], enabled: finalBlocks[id].enabled ?? true } } - - const { blocks: finalBlocks, migrated } = await applyBlockMigrations( - blocksMap, - workflowRow.workspaceId - ) - - if (migrated) { - Promise.resolve().then(async () => { - try { - for (const [blockId, block] of Object.entries(finalBlocks)) { - if (block !== blocksMap[blockId]) { - await db - .update(workflowBlocks) - .set({ - subBlocks: block.subBlocks, - data: block.data, - updatedAt: new Date(), - }) - .where( - and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)) - ) - } - } - } catch (err) { - logger.warn('Failed to persist block migrations', { workflowId, error: err }) - } - }) - } - - // Convert edges to the expected format - const edgesArray: Edge[] = edges.map((edge) => ({ - id: edge.id, - source: edge.sourceBlockId, - target: edge.targetBlockId, - sourceHandle: edge.sourceHandle ?? undefined, - targetHandle: edge.targetHandle ?? undefined, - type: 'default', - data: {}, - })) - - // Convert subflows to loops and parallels - const loops: Record = {} - const parallels: Record = {} - - subflows.forEach((subflow) => { - const config = (subflow.config ?? {}) as Partial - - if (subflow.type === SUBFLOW_TYPES.LOOP) { - const loopType = - (config as Loop).loopType === 'for' || - (config as Loop).loopType === 'forEach' || - (config as Loop).loopType === 'while' || - (config as Loop).loopType === 'doWhile' - ? (config as Loop).loopType - : 'for' - - const loop: Loop = { - id: subflow.id, - nodes: Array.isArray((config as Loop).nodes) ? (config as Loop).nodes : [], - iterations: - typeof (config as Loop).iterations === 'number' ? (config as Loop).iterations : 1, - loopType, - forEachItems: (config as Loop).forEachItems ?? '', - whileCondition: (config as Loop).whileCondition ?? '', - doWhileCondition: (config as Loop).doWhileCondition ?? '', - enabled: finalBlocks[subflow.id]?.enabled ?? true, - } - loops[subflow.id] = loop - - if (finalBlocks[subflow.id]) { - const block = finalBlocks[subflow.id] - finalBlocks[subflow.id] = { - ...block, - data: { - ...block.data, - collection: loop.forEachItems ?? block.data?.collection ?? '', - whileCondition: loop.whileCondition ?? block.data?.whileCondition ?? '', - doWhileCondition: loop.doWhileCondition ?? block.data?.doWhileCondition ?? '', - }, - } - } - } else if (subflow.type === SUBFLOW_TYPES.PARALLEL) { - const parallel: Parallel = { - id: subflow.id, - nodes: Array.isArray((config as Parallel).nodes) ? (config as Parallel).nodes : [], - count: typeof (config as Parallel).count === 'number' ? (config as Parallel).count : 5, - distribution: (config as Parallel).distribution ?? '', - parallelType: - (config as Parallel).parallelType === 'count' || - (config as Parallel).parallelType === 'collection' - ? (config as Parallel).parallelType - : 'count', - enabled: finalBlocks[subflow.id]?.enabled ?? true, - } - parallels[subflow.id] = parallel - } else { - logger.warn(`Unknown subflow type: ${subflow.type} for subflow ${subflow.id}`) + } + for (const id of Object.keys(raw.parallels)) { + if (finalBlocks[id]) { + patchedParallels[id] = { + ...raw.parallels[id], + enabled: finalBlocks[id].enabled ?? true, } - }) - - return { - blocks: finalBlocks, - edges: edgesArray, - loops, - parallels, - isFromNormalizedTables: true, } - } catch (error) { - logger.error(`Error loading workflow ${workflowId} from normalized tables:`, error) - return null + } + + return { + blocks: finalBlocks, + edges: raw.edges, + loops: patchedLoops, + parallels: patchedParallels, + isFromNormalizedTables: true, } } -/** - * Save workflow state to normalized tables - */ export async function saveWorkflowToNormalizedTables( workflowId: string, state: WorkflowState, externalTx?: DbOrTx ): Promise<{ success: boolean; error?: string }> { - const blockRecords = state.blocks as Record - const canonicalLoops = generateLoopBlocks(blockRecords) - const canonicalParallels = generateParallelBlocks(blockRecords) - - const execute = async (tx: DbOrTx) => { - await Promise.all([ - tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), - tx.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), - tx.delete(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), - ]) - - if (Object.keys(state.blocks).length > 0) { - const blockInserts = Object.values(state.blocks).map((block) => ({ - id: block.id, - workflowId: workflowId, - type: block.type, - name: block.name || '', - positionX: String(block.position?.x || 0), - positionY: String(block.position?.y || 0), - enabled: block.enabled ?? true, - horizontalHandles: block.horizontalHandles ?? true, - advancedMode: block.advancedMode ?? false, - triggerMode: block.triggerMode ?? false, - height: String(block.height || 0), - subBlocks: block.subBlocks || {}, - outputs: block.outputs || {}, - data: block.data || {}, - parentId: block.data?.parentId || null, - extent: block.data?.extent || null, - locked: block.locked ?? false, - })) - - await tx.insert(workflowBlocks).values(blockInserts) - } - - if (state.edges.length > 0) { - const edgeInserts = state.edges.map((edge) => ({ - id: edge.id, - workflowId: workflowId, - sourceBlockId: edge.source, - targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, - })) - - await tx.insert(workflowEdges).values(edgeInserts) - } - - const subflowInserts: SubflowInsert[] = [] - - Object.values(canonicalLoops).forEach((loop) => { - subflowInserts.push({ - id: loop.id, - workflowId: workflowId, - type: SUBFLOW_TYPES.LOOP, - config: loop, - }) - }) - - Object.values(canonicalParallels).forEach((parallel) => { - subflowInserts.push({ - id: parallel.id, - workflowId: workflowId, - type: SUBFLOW_TYPES.PARALLEL, - config: parallel, - }) - }) - - if (subflowInserts.length > 0) { - await tx.insert(workflowSubflows).values(subflowInserts) - } - } - - if (externalTx) { - await execute(externalTx) - return { success: true } - } - - try { - await db.transaction(execute) - return { success: true } - } catch (error) { - logger.error(`Error saving workflow ${workflowId} to normalized tables:`, error) - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - } - } + return saveWorkflowToNormalizedTablesRaw(workflowId, state, externalTx) } -/** - * Check if a workflow exists in normalized tables - */ export async function workflowExistsInNormalizedTables(workflowId: string): Promise { try { + const { workflowBlocks } = await import('@sim/db') const blocks = await db .select({ id: workflowBlocks.id }) .from(workflowBlocks) @@ -641,12 +399,9 @@ export async function workflowExistsInNormalizedTables(workflowId: string): Prom } } -/** - * Deploy a workflow by creating a new deployment version - */ export async function deployWorkflow(params: { workflowId: string - deployedBy: string // User ID of the person deploying + deployedBy: string workflowName?: string }): Promise<{ success: boolean @@ -664,7 +419,6 @@ export async function deployWorkflow(params: { return { success: false, error: 'Failed to load workflow state' } } - // Also fetch workflow variables const [workflowRecord] = await db .select({ variables: workflow.variables }) .from(workflow) @@ -683,7 +437,6 @@ export async function deployWorkflow(params: { const now = new Date() const deployedVersion = await db.transaction(async (tx) => { - // Get next version number const [{ maxVersion }] = await tx .select({ maxVersion: sql`COALESCE(MAX("version"), 0)` }) .from(workflowDeploymentVersion) @@ -692,13 +445,11 @@ export async function deployWorkflow(params: { const nextVersion = Number(maxVersion) + 1 const deploymentVersionId = generateId() - // Deactivate all existing versions await tx .update(workflowDeploymentVersion) .set({ isActive: false }) .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - // Create new deployment version await tx.insert(workflowDeploymentVersion).values({ id: deploymentVersionId, workflowId, @@ -709,7 +460,6 @@ export async function deployWorkflow(params: { createdAt: now, }) - // Update workflow to deployed const updateData: Record = { isDeployed: true, deployedAt: now, @@ -717,9 +467,6 @@ export async function deployWorkflow(params: { await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) - // Note: Templates are NOT automatically updated on deployment - // Template updates must be done explicitly through the "Update Template" button - return { version: nextVersion, deploymentVersionId } }) @@ -766,7 +513,6 @@ export async function deployWorkflow(params: { } } -/** Input state for ID regeneration - partial to handle external sources */ export interface RegenerateStateInput { blocks?: Record edges?: Edge[] @@ -777,7 +523,6 @@ export interface RegenerateStateInput { metadata?: Record } -/** Output state after ID regeneration */ interface RegenerateStateOutput { blocks: Record edges: Edge[] @@ -788,49 +533,35 @@ interface RegenerateStateOutput { metadata?: Record } -/** - * Regenerates all IDs in a workflow state to avoid conflicts when duplicating or using templates - * Returns a new state with all IDs regenerated and references updated - */ export function regenerateWorkflowStateIds(state: RegenerateStateInput): RegenerateStateOutput { - // Create ID mappings const blockIdMapping = new Map() const edgeIdMapping = new Map() const loopIdMapping = new Map() const parallelIdMapping = new Map() - // First pass: Create all ID mappings - // Map block IDs Object.keys(state.blocks || {}).forEach((oldId) => { blockIdMapping.set(oldId, generateId()) }) - // Map edge IDs - ;(state.edges || []).forEach((edge: Edge) => { edgeIdMapping.set(edge.id, generateId()) }) - // Map loop IDs Object.keys(state.loops || {}).forEach((oldId) => { loopIdMapping.set(oldId, generateId()) }) - // Map parallel IDs Object.keys(state.parallels || {}).forEach((oldId) => { parallelIdMapping.set(oldId, generateId()) }) - // Second pass: Create new state with regenerated IDs and updated references const newBlocks: Record = {} const newEdges: Edge[] = [] const newLoops: Record = {} const newParallels: Record = {} - // Regenerate blocks with updated references Object.entries(state.blocks || {}).forEach(([oldId, block]) => { const newId = blockIdMapping.get(oldId)! - // Duplicated blocks are always unlocked so users can edit them const newBlock: BlockState = { ...block, id: newId, @@ -838,7 +569,6 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener locked: false, } - // Update parentId reference if it exists if (newBlock.data?.parentId) { const newParentId = blockIdMapping.get(newBlock.data.parentId) if (newParentId) { @@ -846,13 +576,11 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener } } - // Update any block references in subBlocks if (newBlock.subBlocks) { const updatedSubBlocks: Record = {} Object.entries(newBlock.subBlocks).forEach(([subId, subBlock]) => { const updatedSubBlock = { ...subBlock } - // If subblock value contains block references, update them if ( typeof updatedSubBlock.value === 'string' && blockIdMapping.has(updatedSubBlock.value) @@ -860,7 +588,6 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value } - // Remap condition/router IDs embedded in condition-input/router-input subBlocks if ( (updatedSubBlock.type === 'condition-input' || updatedSubBlock.type === 'router-input') && typeof updatedSubBlock.value === 'string' @@ -870,9 +597,7 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldId, newId)) { updatedSubBlock.value = JSON.stringify(parsed) } - } catch { - // Not valid JSON, skip - } + } catch {} } updatedSubBlocks[subId] = updatedSubBlock @@ -883,8 +608,6 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener newBlocks[newId] = newBlock }) - // Regenerate edges with updated source/target references - ;(state.edges || []).forEach((edge: Edge) => { const newId = edgeIdMapping.get(edge.id)! const newSource = blockIdMapping.get(edge.source) || edge.source @@ -903,12 +626,10 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener }) }) - // Regenerate loops with updated node references Object.entries(state.loops || {}).forEach(([oldId, loop]) => { const newId = loopIdMapping.get(oldId)! const newLoop: Loop = { ...loop, id: newId } - // Update nodes array with new block IDs if (newLoop.nodes) { newLoop.nodes = newLoop.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId) } @@ -916,12 +637,10 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener newLoops[newId] = newLoop }) - // Regenerate parallels with updated node references Object.entries(state.parallels || {}).forEach(([oldId, parallel]) => { const newId = parallelIdMapping.get(oldId)! const newParallel: Parallel = { ...parallel, id: newId } - // Update nodes array with new block IDs if (newParallel.nodes) { newParallel.nodes = newParallel.nodes.map( (nodeId: string) => blockIdMapping.get(nodeId) || nodeId @@ -942,10 +661,6 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener } } -/** - * Undeploy a workflow by deactivating all versions and clearing deployment state. - * Handles schedule deletion and returns the result. - */ export async function undeployWorkflow(params: { workflowId: string; tx?: DbOrTx }): Promise<{ success: boolean error?: string @@ -987,10 +702,6 @@ export async function undeployWorkflow(params: { workflowId: string; tx?: DbOrTx } } -/** - * Activate a specific deployment version for a workflow. - * Deactivates the current active version and activates the specified one. - */ export async function activateWorkflowVersion(params: { workflowId: string version: number @@ -1133,9 +844,6 @@ export async function activateWorkflowVersionById(params: { } } -/** - * List all deployment versions for a workflow. - */ export async function listWorkflowVersions(workflowId: string): Promise<{ versions: Array<{ id: string diff --git a/apps/sim/lib/workflows/utils.test.ts b/apps/sim/lib/workflows/utils.test.ts index f5fd238a235..5f630f39e65 100644 --- a/apps/sim/lib/workflows/utils.test.ts +++ b/apps/sim/lib/workflows/utils.test.ts @@ -11,24 +11,24 @@ import { authMockFns, createSession, createWorkflowRecord, - databaseMock, expectWorkflowAccessDenied, expectWorkflowAccessGranted, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetActiveWorkflowContext } = vi.hoisted(() => ({ - mockGetActiveWorkflowContext: vi.fn(), +const { mockAuthorizeWorkflow } = vi.hoisted(() => ({ + mockAuthorizeWorkflow: vi.fn(), })) -vi.mock('@/lib/workflows/active-context', () => ({ - getActiveWorkflowContext: mockGetActiveWorkflowContext, +vi.mock('@sim/workflow-authz', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, + getActiveWorkflowContext: vi.fn(), + getActiveWorkflowRecord: vi.fn(), + assertActiveWorkflowContext: vi.fn(), })) import { validateWorkflowPermissions } from '@/lib/workflows/utils' -const mockDb = databaseMock.db - const mockSession = createSession({ userId: 'user-1', email: 'user1@test.com' }) const mockWorkflow = createWorkflowRecord({ id: 'wf-1', @@ -36,9 +36,25 @@ const mockWorkflow = createWorkflowRecord({ workspaceId: 'ws-1', }) +const allowed = (workspacePermission: 'read' | 'write' | 'admin') => ({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission, +}) + +const denied = (status: number, message: string, workspacePermission: string | null = null) => ({ + allowed: false, + status, + message, + workflow: mockWorkflow, + workspacePermission, +}) + describe('validateWorkflowPermissions', () => { beforeEach(() => { vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue(mockSession) }) describe('authentication', () => { @@ -62,8 +78,13 @@ describe('validateWorkflowPermissions', () => { describe('workflow not found', () => { it('should return 404 when workflow does not exist', async () => { - authMockFns.mockGetSession.mockResolvedValue(mockSession) - mockGetActiveWorkflowContext.mockResolvedValue(null) + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: false, + status: 404, + message: 'Workflow not found', + workflow: null, + workspacePermission: null, + }) const result = await validateWorkflowPermissions('non-existent', 'req-1', 'read') @@ -77,18 +98,11 @@ describe('validateWorkflowPermissions', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'owner-1', email: 'owner-1@test.com' }, }) - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue( + denied(403, 'Unauthorized: Access denied to read this workflow') + ) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read') - expectWorkflowAccessDenied(result, 403) }) @@ -96,18 +110,11 @@ describe('validateWorkflowPermissions', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'owner-1', email: 'owner-1@test.com' }, }) - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue( + denied(403, 'Unauthorized: Access denied to write this workflow') + ) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write') - expectWorkflowAccessDenied(result, 403) }) @@ -115,198 +122,111 @@ describe('validateWorkflowPermissions', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'owner-1', email: 'owner-1@test.com' }, }) - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue( + denied(403, 'Unauthorized: Access denied to admin this workflow') + ) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin') - expectWorkflowAccessDenied(result, 403) }) }) describe('workspace member access with permissions', () => { - beforeEach(() => { - authMockFns.mockGetSession.mockResolvedValue(mockSession) - }) - it('should grant read access to user with read permission', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'read' }]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue(allowed('read')) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read') - expectWorkflowAccessGranted(result) }) it('should deny write access to user with only read permission', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'read' }]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue( + denied(403, 'Unauthorized: Access denied to write this workflow', 'read') + ) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write') - expectWorkflowAccessDenied(result, 403) expect(result.error?.message).toContain('write') }) it('should grant write access to user with write permission', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'write' }]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue(allowed('write')) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write') - expectWorkflowAccessGranted(result) }) it('should grant write access to user with admin permission', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'admin' }]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue(allowed('admin')) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write') - expectWorkflowAccessGranted(result) }) it('should deny admin access to user with only write permission', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'write' }]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue( + denied(403, 'Unauthorized: Access denied to admin this workflow', 'write') + ) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin') - expectWorkflowAccessDenied(result, 403) expect(result.error?.message).toContain('admin') }) it('should grant admin access to user with admin permission', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'admin' }]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue(allowed('admin')) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin') - expectWorkflowAccessGranted(result) }) }) describe('no workspace permission', () => { it('should deny access to user without any workspace permission', async () => { - authMockFns.mockGetSession.mockResolvedValue(mockSession) - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue( + denied(403, 'Unauthorized: Access denied to read this workflow') + ) const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read') - expectWorkflowAccessDenied(result, 403) }) }) describe('workflow without workspace', () => { it('should deny access to non-owner for workflow without workspace', async () => { - const workflowWithoutWorkspace = createWorkflowRecord({ - id: 'wf-2', - userId: 'other-user', - workspaceId: null, - }) - - authMockFns.mockGetSession.mockResolvedValue(mockSession) - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: workflowWithoutWorkspace, - workspaceId: '', + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: false, + status: 403, + message: + 'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.', + workflow: createWorkflowRecord({ id: 'wf-2', userId: 'other-user', workspaceId: null }), + workspacePermission: null, }) const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read') - expectWorkflowAccessDenied(result, 403) }) it('should deny access to owner for workflow without workspace', async () => { - const workflowWithoutWorkspace = createWorkflowRecord({ - id: 'wf-2', - userId: 'user-1', - workspaceId: null, - }) - - authMockFns.mockGetSession.mockResolvedValue(mockSession) - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: workflowWithoutWorkspace, - workspaceId: '', + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: false, + status: 403, + message: + 'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.', + workflow: createWorkflowRecord({ id: 'wf-2', userId: 'user-1', workspaceId: null }), + workspacePermission: null, }) const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read') - expectWorkflowAccessDenied(result, 403) }) }) describe('default action', () => { it('should default to read action when not specified', async () => { - authMockFns.mockGetSession.mockResolvedValue(mockSession) - mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: mockWorkflow, - workspaceId: 'ws-1', - }) - - const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'read' }]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockAuthorizeWorkflow.mockResolvedValue(allowed('read')) const result = await validateWorkflowPermissions('wf-1', 'req-1') - expectWorkflowAccessGranted(result) }) }) diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 5dfdc5c0668..a6b971f932a 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -2,14 +2,13 @@ import { db } from '@sim/db' import { permissions, userStats, workflowFolder, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import type { PermissionType } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' import type { ExecutionResult } from '@/executor/types' @@ -228,18 +227,6 @@ export async function resolveWorkflowIdForUser( } } -type WorkflowRecord = ReturnType extends Promise - ? NonNullable - : never - -export interface WorkflowWorkspaceAuthorizationResult { - allowed: boolean - status: number - message?: string - workflow: WorkflowRecord | null - workspacePermission: PermissionType | null -} - export async function updateWorkflowRunCounts(workflowId: string, runs = 1) { try { const workflow = await getWorkflowById(workflowId) @@ -402,86 +389,6 @@ export async function validateWorkflowPermissions( } } -export async function authorizeWorkflowByWorkspacePermission(params: { - workflowId: string - userId: string - action?: 'read' | 'write' | 'admin' -}): Promise { - const { workflowId, userId, action = 'read' } = params - - const activeContext = await getActiveWorkflowContext(workflowId) - if (!activeContext) { - return { - allowed: false, - status: 404, - message: 'Workflow not found', - workflow: null, - workspacePermission: null, - } - } - - const workflow = activeContext.workflow - - if (!workflow.workspaceId) { - return { - allowed: false, - status: 403, - message: - 'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.', - workflow, - workspacePermission: null, - } - } - - const [permissionRow] = await db - .select({ permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId) - ) - ) - .limit(1) - - const workspacePermission = permissionRow?.permissionType ?? null - - if (workspacePermission === null) { - return { - allowed: false, - status: 403, - message: `Unauthorized: Access denied to ${action} this workflow`, - workflow, - workspacePermission, - } - } - - const permissionSatisfied = - action === 'read' - ? true - : action === 'write' - ? workspacePermission === 'write' || workspacePermission === 'admin' - : workspacePermission === 'admin' - - if (!permissionSatisfied) { - return { - allowed: false, - status: 403, - message: `Unauthorized: Access denied to ${action} this workflow`, - workflow, - workspacePermission, - } - } - - return { - allowed: true, - status: 200, - workflow, - workspacePermission, - } -} - // ── Workflow CRUD ── export interface CreateWorkflowInput { diff --git a/apps/sim/package.json b/apps/sim/package.json index 1a84029605a..1322e2302c2 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -10,8 +10,6 @@ "scripts": { "dev": "next dev --port 3000", "dev:webpack": "next dev --webpack", - "dev:sockets": "bun run socket/index.ts", - "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"", "load:workflow": "bun run load:workflow:baseline", "load:workflow:baseline": "BASE_URL=${BASE_URL:-http://localhost:3000} WARMUP_DURATION=${WARMUP_DURATION:-10} WARMUP_RATE=${WARMUP_RATE:-2} PEAK_RATE=${PEAK_RATE:-8} HOLD_DURATION=${HOLD_DURATION:-20} bunx artillery run scripts/load/workflow-concurrency.yml", "load:workflow:waves": "BASE_URL=${BASE_URL:-http://localhost:3000} WAVE_ONE_DURATION=${WAVE_ONE_DURATION:-10} WAVE_ONE_RATE=${WAVE_ONE_RATE:-6} QUIET_DURATION=${QUIET_DURATION:-5} WAVE_TWO_DURATION=${WAVE_TWO_DURATION:-15} WAVE_TWO_RATE=${WAVE_TWO_RATE:-8} WAVE_THREE_DURATION=${WAVE_THREE_DURATION:-20} WAVE_THREE_RATE=${WAVE_THREE_RATE:-10} bunx artillery run scripts/load/workflow-waves.yml", @@ -35,6 +33,7 @@ "@1password/sdk": "0.3.1", "@a2a-js/sdk": "0.3.7", "@anthropic-ai/sdk": "0.71.2", + "@aws-sdk/client-athena": "3.1024.0", "@aws-sdk/client-bedrock-runtime": "3.940.0", "@aws-sdk/client-cloudformation": "3.1019.0", "@aws-sdk/client-cloudwatch": "3.940.0", @@ -94,9 +93,14 @@ "@radix-ui/react-visually-hidden": "1.2.4", "@react-email/components": "^0.0.34", "@react-email/render": "2.0.0", + "@sim/audit": "workspace:*", "@sim/logger": "workspace:*", + "@sim/realtime-protocol": "workspace:*", + "@sim/security": "workspace:*", "@sim/utils": "workspace:*", - "@socket.io/redis-adapter": "8.3.0", + "@sim/workflow-authz": "workspace:*", + "@sim/workflow-persistence": "workspace:*", + "@sim/workflow-types": "workspace:*", "@t3-oss/env-nextjs": "0.13.4", "@tanstack/react-query": "5.90.8", "@tanstack/react-query-devtools": "5.90.2", @@ -185,7 +189,6 @@ "safe-regex2": "5.1.0", "sharp": "0.34.3", "soap": "1.8.0", - "socket.io": "^4.8.1", "socket.io-client": "4.8.1", "ssh2": "^1.17.0", "streamdown": "2.5.0", diff --git a/apps/sim/socket/handlers/index.ts b/apps/sim/socket/handlers/index.ts deleted file mode 100644 index 4afeed40e36..00000000000 --- a/apps/sim/socket/handlers/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { setupConnectionHandlers } from '@/socket/handlers/connection' -import { setupOperationsHandlers } from '@/socket/handlers/operations' -import { setupPresenceHandlers } from '@/socket/handlers/presence' -import { setupSubblocksHandlers } from '@/socket/handlers/subblocks' -import { setupVariablesHandlers } from '@/socket/handlers/variables' -import { setupWorkflowHandlers } from '@/socket/handlers/workflow' -import type { AuthenticatedSocket } from '@/socket/middleware/auth' -import type { IRoomManager } from '@/socket/rooms' - -export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { - setupWorkflowHandlers(socket, roomManager) - setupOperationsHandlers(socket, roomManager) - setupSubblocksHandlers(socket, roomManager) - setupVariablesHandlers(socket, roomManager) - setupPresenceHandlers(socket, roomManager) - setupConnectionHandlers(socket, roomManager) -} diff --git a/apps/sim/socket/rooms/index.ts b/apps/sim/socket/rooms/index.ts deleted file mode 100644 index 3cbdc41348a..00000000000 --- a/apps/sim/socket/rooms/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { MemoryRoomManager } from '@/socket/rooms/memory-manager' -export { RedisRoomManager } from '@/socket/rooms/redis-manager' -export type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/socket/rooms/types' diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 6776bf66e1d..30ec1a3a237 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -1,8 +1,8 @@ import { createLogger } from '@sim/logger' +import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants' import type { Edge } from 'reactflow' import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' -import { UNDO_REDO_OPERATIONS } from '@/socket/constants' import type { BatchAddBlocksOperation, BatchAddEdgesOperation, diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index 8d5df192fd3..672383ad47d 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -1,5 +1,5 @@ +import type { UNDO_REDO_OPERATIONS, UndoRedoOperation } from '@sim/realtime-protocol/constants' import type { Edge } from 'reactflow' -import type { UNDO_REDO_OPERATIONS, UndoRedoOperation } from '@/socket/constants' import type { BlockState } from '@/stores/workflows/workflow/types' export type OperationType = UndoRedoOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index 861277e02e4..1803970cd68 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -1,6 +1,6 @@ +import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants' import { generateId } from '@sim/utils/id' import type { Edge } from 'reactflow' -import { UNDO_REDO_OPERATIONS } from '@/socket/constants' import type { BatchAddBlocksOperation, BatchAddEdgesOperation, diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 25b372693b1..fbb97f22fca 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -1,9 +1,9 @@ import { generateId } from '@sim/utils/id' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import type { Edge } from 'reactflow' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' -import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getBlock } from '@/blocks' diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 37d86847bcb..c209cfd0eef 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -1,179 +1,42 @@ +import type { + BlockData, + BlockLayoutState, + BlockState, + DragStartPosition, + Loop, + LoopBlock, + LoopConfig, + Parallel, + ParallelBlock, + ParallelConfig, + Position, + SubBlockState, + Subflow, + SubflowType, + Variable, + WorkflowState, +} from '@sim/workflow-types/workflow' import type { Edge } from 'reactflow' -import type { OutputFieldDefinition, SubBlockType } from '@/blocks/types' -export const SUBFLOW_TYPES = { - LOOP: 'loop', - PARALLEL: 'parallel', -} as const - -export type SubflowType = (typeof SUBFLOW_TYPES)[keyof typeof SUBFLOW_TYPES] - -export function isValidSubflowType(type: string): type is SubflowType { - return Object.values(SUBFLOW_TYPES).includes(type as SubflowType) -} - -export interface LoopConfig { - nodes: string[] - iterations: number - loopType: 'for' | 'forEach' | 'while' | 'doWhile' - forEachItems?: unknown[] | Record | string - whileCondition?: string // JS expression that evaluates to boolean (for while loops) - doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops) -} - -export interface ParallelConfig { - nodes: string[] - distribution?: unknown[] | Record | string - parallelType?: 'count' | 'collection' -} - -export interface Subflow { - id: string - workflowId: string - type: SubflowType - config: LoopConfig | ParallelConfig - createdAt: Date - updatedAt: Date -} - -export interface Position { - x: number - y: number -} - -export interface BlockData { - // Parent-child relationships for container nodes - parentId?: string - extent?: 'parent' - - // Container dimensions - width?: number - height?: number - - // Loop-specific properties - collection?: any // The items to iterate over in a forEach loop - count?: number // Number of iterations for numeric loops - loopType?: 'for' | 'forEach' | 'while' | 'doWhile' // Type of loop - must match Loop interface - whileCondition?: string // While loop condition (JS expression) - doWhileCondition?: string // Do-While loop condition (JS expression) - - // Parallel-specific properties - parallelType?: 'collection' | 'count' // Type of parallel execution - - // Container node type (for ReactFlow node type determination) - type?: string - - /** Canonical swap overrides keyed by canonicalParamId */ - canonicalModes?: Record -} - -export interface BlockLayoutState { - measuredWidth?: number - measuredHeight?: number -} - -export interface BlockState { - id: string - type: string - name: string - position: Position - subBlocks: Record - outputs: Record - enabled: boolean - horizontalHandles?: boolean - height?: number - advancedMode?: boolean - triggerMode?: boolean - data?: BlockData - layout?: BlockLayoutState - locked?: boolean -} - -export interface SubBlockState { - id: string - type: SubBlockType - value: string | number | string[][] | null -} - -export interface LoopBlock { - id: string - loopType: 'for' | 'forEach' - count: number - collection: string - width: number - height: number - executionState: { - isExecuting: boolean - startTime: null | number - endTime: null | number - } -} - -export interface ParallelBlock { - id: string - collection: string - width: number - height: number - executionState: { - currentExecution: number - isExecuting: boolean - startTime: null | number - endTime: null | number - } -} - -export interface Loop { - id: string - nodes: string[] - iterations: number - loopType: 'for' | 'forEach' | 'while' | 'doWhile' - forEachItems?: any[] | Record | string // Items or expression - whileCondition?: string // JS expression that evaluates to boolean (for while loops) - doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops) - enabled: boolean - locked?: boolean -} - -export interface Parallel { - id: string - nodes: string[] - distribution?: any[] | Record | string // Items or expression - count?: number // Number of parallel executions for count-based parallel - parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs - enabled: boolean - locked?: boolean -} - -export interface Variable { - id: string - name: string - type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' - value: unknown -} - -export interface DragStartPosition { - id: string - x: number - y: number - parentId?: string | null -} - -export interface WorkflowState { - currentWorkflowId?: string | null - blocks: Record - edges: Edge[] - lastSaved?: number - loops: Record - parallels: Record - lastUpdate?: number - metadata?: { - name?: string - description?: string - exportedAt?: string - } - variables?: Record - dragStartPosition?: DragStartPosition | null -} +export type { + BlockData, + BlockLayoutState, + BlockState, + DragStartPosition, + Loop, + LoopBlock, + LoopConfig, + Parallel, + ParallelBlock, + ParallelConfig, + Position, + SubBlockState, + Subflow, + SubflowType, + Variable, + WorkflowState, +} +export { isValidSubflowType, SUBFLOW_TYPES } from '@sim/workflow-types/workflow' export interface WorkflowActions { updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void diff --git a/apps/sim/tools/http/webhook_request.ts b/apps/sim/tools/http/webhook_request.ts index 569f535e34a..39e4e8e21b5 100644 --- a/apps/sim/tools/http/webhook_request.ts +++ b/apps/sim/tools/http/webhook_request.ts @@ -1,4 +1,4 @@ -import { createHmac } from 'crypto' +import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' import type { RequestResponse, WebhookRequestParams } from '@/tools/http/types' import type { ToolConfig } from '@/tools/types' @@ -8,7 +8,7 @@ import type { ToolConfig } from '@/tools/types' */ function generateSignature(secret: string, timestamp: number, body: string): string { const signatureBase = `${timestamp}.${body}` - return createHmac('sha256', secret).update(signatureBase).digest('hex') + return hmacSha256Hex(signatureBase, secret) } export const webhookRequestTool: ToolConfig = { diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts index 588a053bb23..922e8b7a919 100644 --- a/apps/sim/vitest.setup.ts +++ b/apps/sim/vitest.setup.ts @@ -8,6 +8,7 @@ import { schemaMock, setupGlobalFetchMock, setupGlobalStorageMocks, + workflowAuthzMock, } from '@sim/testing' import { afterAll, vi } from 'vitest' import '@testing-library/jest-dom/vitest' @@ -19,6 +20,7 @@ vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => drizzleOrmMock) vi.mock('@sim/logger', () => loggerMock) +vi.mock('@sim/workflow-authz', () => workflowAuthzMock) vi.mock('@/lib/auth', () => authMock) vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/lib/core/utils/request', () => requestUtilsMock) diff --git a/bun.lock b/bun.lock index ac1e89e4922..916bc86fb35 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,9 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", - "dependencies": { - "@aws-sdk/client-athena": "3.1024.0", - }, "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", "@octokit/rest": "^21.0.0", @@ -52,6 +50,36 @@ "typescript": "^5.8.2", }, }, + "apps/realtime": { + "name": "@sim/realtime", + "version": "0.1.0", + "dependencies": { + "@sim/audit": "workspace:*", + "@sim/auth": "workspace:*", + "@sim/db": "workspace:*", + "@sim/logger": "workspace:*", + "@sim/realtime-protocol": "workspace:*", + "@sim/security": "workspace:*", + "@sim/utils": "workspace:*", + "@sim/workflow-authz": "workspace:*", + "@sim/workflow-persistence": "workspace:*", + "@sim/workflow-types": "workspace:*", + "@socket.io/redis-adapter": "8.3.0", + "drizzle-orm": "^0.45.2", + "postgres": "^3.4.5", + "redis": "5.10.0", + "socket.io": "^4.8.1", + "zod": "^3.24.2", + }, + "devDependencies": { + "@sim/testing": "workspace:*", + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "socket.io-client": "4.8.1", + "typescript": "^5.7.3", + "vitest": "^3.0.8", + }, + }, "apps/sim": { "name": "sim", "version": "0.1.0", @@ -59,6 +87,7 @@ "@1password/sdk": "0.3.1", "@a2a-js/sdk": "0.3.7", "@anthropic-ai/sdk": "0.71.2", + "@aws-sdk/client-athena": "3.1024.0", "@aws-sdk/client-bedrock-runtime": "3.940.0", "@aws-sdk/client-cloudformation": "3.1019.0", "@aws-sdk/client-cloudwatch": "3.940.0", @@ -118,9 +147,14 @@ "@radix-ui/react-visually-hidden": "1.2.4", "@react-email/components": "^0.0.34", "@react-email/render": "2.0.0", + "@sim/audit": "workspace:*", "@sim/logger": "workspace:*", + "@sim/realtime-protocol": "workspace:*", + "@sim/security": "workspace:*", "@sim/utils": "workspace:*", - "@socket.io/redis-adapter": "8.3.0", + "@sim/workflow-authz": "workspace:*", + "@sim/workflow-persistence": "workspace:*", + "@sim/workflow-types": "workspace:*", "@t3-oss/env-nextjs": "0.13.4", "@tanstack/react-query": "5.90.8", "@tanstack/react-query-devtools": "5.90.2", @@ -209,7 +243,6 @@ "safe-regex2": "5.1.0", "sharp": "0.34.3", "soap": "1.8.0", - "socket.io": "^4.8.1", "socket.io-client": "4.8.1", "ssh2": "^1.17.0", "streamdown": "2.5.0", @@ -259,6 +292,34 @@ "vitest": "^3.0.8", }, }, + "packages/audit": { + "name": "@sim/audit", + "version": "0.1.0", + "dependencies": { + "@sim/db": "workspace:*", + "@sim/logger": "workspace:*", + "@sim/utils": "workspace:*", + "drizzle-orm": "^0.45.2", + }, + "devDependencies": { + "@sim/testing": "workspace:*", + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3", + "vitest": "^3.0.8", + }, + }, + "packages/auth": { + "name": "@sim/auth", + "version": "0.1.0", + "dependencies": { + "@sim/db": "workspace:*", + "better-auth": "1.3.12", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3", + }, + }, "packages/cli": { "name": "simstudio", "version": "0.1.19", @@ -307,6 +368,27 @@ "vitest": "^3.0.8", }, }, + "packages/realtime-protocol": { + "name": "@sim/realtime-protocol", + "version": "0.1.0", + "dependencies": { + "zod": "^3.24.2", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3", + }, + }, + "packages/security": { + "name": "@sim/security", + "version": "0.1.0", + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3", + "vitest": "^3.0.8", + }, + }, "packages/testing": { "name": "@sim/testing", "version": "0.1.0", @@ -346,6 +428,46 @@ "vitest": "^3.0.8", }, }, + "packages/workflow-authz": { + "name": "@sim/workflow-authz", + "version": "0.1.0", + "dependencies": { + "@sim/db": "workspace:*", + "drizzle-orm": "^0.45.2", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3", + }, + }, + "packages/workflow-persistence": { + "name": "@sim/workflow-persistence", + "version": "0.1.0", + "dependencies": { + "@sim/db": "workspace:*", + "@sim/logger": "workspace:*", + "@sim/utils": "workspace:*", + "@sim/workflow-types": "workspace:*", + "drizzle-orm": "^0.45.2", + "reactflow": "^11.11.4", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3", + }, + }, + "packages/workflow-types": { + "name": "@sim/workflow-types", + "version": "0.1.0", + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "reactflow": "^11.11.4", + "typescript": "^5.7.3", + }, + "peerDependencies": { + "reactflow": "^11.11.4", + }, + }, }, "trustedDependencies": [ "ffmpeg-static", @@ -1351,16 +1473,32 @@ "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], + "@sim/audit": ["@sim/audit@workspace:packages/audit"], + + "@sim/auth": ["@sim/auth@workspace:packages/auth"], + "@sim/db": ["@sim/db@workspace:packages/db"], "@sim/logger": ["@sim/logger@workspace:packages/logger"], + "@sim/realtime": ["@sim/realtime@workspace:apps/realtime"], + + "@sim/realtime-protocol": ["@sim/realtime-protocol@workspace:packages/realtime-protocol"], + + "@sim/security": ["@sim/security@workspace:packages/security"], + "@sim/testing": ["@sim/testing@workspace:packages/testing"], "@sim/tsconfig": ["@sim/tsconfig@workspace:packages/tsconfig"], "@sim/utils": ["@sim/utils@workspace:packages/utils"], + "@sim/workflow-authz": ["@sim/workflow-authz@workspace:packages/workflow-authz"], + + "@sim/workflow-persistence": ["@sim/workflow-persistence@workspace:packages/workflow-persistence"], + + "@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"], + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], "@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="], @@ -4995,6 +5133,10 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "@sim/realtime/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + + "@sim/security/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "@smithy/core/@smithy/util-stream": ["@smithy/util-stream@4.5.21", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.1", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q=="], "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], @@ -6003,6 +6145,10 @@ "@shikijs/rehype/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + "@sim/realtime/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@sim/security/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="], diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index eab09c9b157..366312273fd 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -18,13 +18,30 @@ FROM base AS deps WORKDIR /app COPY package.json bun.lock turbo.json ./ -RUN mkdir -p apps packages/db packages/testing packages/logger packages/tsconfig packages/utils +RUN mkdir -p apps \ + packages/audit \ + packages/db \ + packages/logger \ + packages/realtime-protocol \ + packages/security \ + packages/testing \ + packages/tsconfig \ + packages/utils \ + packages/workflow-authz \ + packages/workflow-persistence \ + packages/workflow-types COPY apps/sim/package.json ./apps/sim/package.json +COPY packages/audit/package.json ./packages/audit/package.json COPY packages/db/package.json ./packages/db/package.json -COPY packages/testing/package.json ./packages/testing/package.json COPY packages/logger/package.json ./packages/logger/package.json +COPY packages/realtime-protocol/package.json ./packages/realtime-protocol/package.json +COPY packages/security/package.json ./packages/security/package.json +COPY packages/testing/package.json ./packages/testing/package.json COPY packages/tsconfig/package.json ./packages/tsconfig/package.json COPY packages/utils/package.json ./packages/utils/package.json +COPY packages/workflow-authz/package.json ./packages/workflow-authz/package.json +COPY packages/workflow-persistence/package.json ./packages/workflow-persistence/package.json +COPY packages/workflow-types/package.json ./packages/workflow-types/package.json # Install dependencies, then rebuild isolated-vm for Node.js # Use --linker=hoisted for flat node_modules layout (required for Docker multi-stage builds) @@ -49,10 +66,17 @@ COPY --from=deps /app/node_modules ./node_modules # Copy package configuration files (needed for build) COPY package.json bun.lock turbo.json ./ COPY apps/sim/package.json ./apps/sim/package.json +COPY packages/audit/package.json ./packages/audit/package.json COPY packages/db/package.json ./packages/db/package.json -COPY packages/testing/package.json ./packages/testing/package.json COPY packages/logger/package.json ./packages/logger/package.json +COPY packages/realtime-protocol/package.json ./packages/realtime-protocol/package.json +COPY packages/security/package.json ./packages/security/package.json +COPY packages/testing/package.json ./packages/testing/package.json +COPY packages/tsconfig/package.json ./packages/tsconfig/package.json COPY packages/utils/package.json ./packages/utils/package.json +COPY packages/workflow-authz/package.json ./packages/workflow-authz/package.json +COPY packages/workflow-persistence/package.json ./packages/workflow-persistence/package.json +COPY packages/workflow-types/package.json ./packages/workflow-types/package.json # Copy workspace configuration files (needed for turbo) COPY apps/sim/next.config.ts ./apps/sim/next.config.ts diff --git a/docker/realtime.Dockerfile b/docker/realtime.Dockerfile index 4bd565c39e5..8092709267f 100644 --- a/docker/realtime.Dockerfile +++ b/docker/realtime.Dockerfile @@ -6,45 +6,28 @@ FROM oven/bun:1.3.11-alpine AS base RUN apk add --no-cache libc6-compat curl # ======================================== -# Dependencies Stage: Install Dependencies +# Pruner Stage: Emit a minimal monorepo subset that @sim/realtime depends on # ======================================== -FROM base AS deps +FROM base AS pruner WORKDIR /app -COPY package.json bun.lock turbo.json ./ -RUN mkdir -p apps packages/db packages/testing packages/logger packages/tsconfig packages/utils -COPY apps/sim/package.json ./apps/sim/package.json -COPY packages/db/package.json ./packages/db/package.json -COPY packages/testing/package.json ./packages/testing/package.json -COPY packages/logger/package.json ./packages/logger/package.json -COPY packages/tsconfig/package.json ./packages/tsconfig/package.json -COPY packages/utils/package.json ./packages/utils/package.json +RUN bun add -g turbo -# Install dependencies with hoisted layout for Docker compatibility -# Using --linker=hoisted to avoid .bun directory symlinks that don't copy between stages -RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \ - bun install --omit=dev --ignore-scripts --linker=hoisted +COPY . . + +RUN turbo prune @sim/realtime --docker # ======================================== -# Builder Stage: Prepare source code +# Dependencies Stage: Install Dependencies # ======================================== -FROM base AS builder +FROM base AS deps WORKDIR /app -# Copy node_modules from deps stage (cached if dependencies don't change) -COPY --from=deps /app/node_modules ./node_modules +COPY --from=pruner /app/out/json/ ./ +COPY --from=pruner /app/out/bun.lock ./bun.lock -# Copy package configuration files (needed for build) -COPY package.json bun.lock turbo.json ./ -COPY apps/sim/package.json ./apps/sim/package.json -COPY packages/db/package.json ./packages/db/package.json -COPY packages/testing/package.json ./packages/testing/package.json -COPY packages/logger/package.json ./packages/logger/package.json -COPY packages/utils/package.json ./packages/utils/package.json - -# Copy source code (changes most frequently - placed last to maximize cache hits) -COPY apps/sim ./apps/sim -COPY packages ./packages +RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \ + bun install --linker=hoisted --omit=dev --ignore-scripts # ======================================== # Runner Stage: Run the Socket Server @@ -52,38 +35,18 @@ COPY packages ./packages FROM base AS runner WORKDIR /app -ENV NODE_ENV=production +ENV NODE_ENV=production \ + PORT=3002 \ + HOSTNAME="0.0.0.0" -# Create non-root user and group (cached separately) RUN addgroup -g 1001 -S nodejs && \ adduser -S nextjs -u 1001 -# Copy package.json first (changes less frequently) -COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json - -# Copy node_modules from builder (cached if dependencies don't change) -COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules - -# Copy db package (needed by socket) -COPY --from=builder --chown=nextjs:nodejs /app/packages/db ./packages/db - -# Copy logger package (workspace dependency used by socket) -COPY --from=builder --chown=nextjs:nodejs /app/packages/logger ./packages/logger +COPY --from=deps --chown=nextjs:nodejs /app ./ +COPY --from=pruner --chown=nextjs:nodejs /app/out/full/ ./ -# Copy utils package (workspace dependency used by socket) -COPY --from=builder --chown=nextjs:nodejs /app/packages/utils ./packages/utils - -# Copy sim app (changes most frequently - placed last) -COPY --from=builder --chown=nextjs:nodejs /app/apps/sim ./apps/sim - -# Switch to non-root user USER nextjs -# Expose socket server port (default 3002, but configurable via PORT env var) EXPOSE 3002 -ENV PORT=3002 \ - SOCKET_PORT=3002 \ - HOSTNAME="0.0.0.0" -# Run the socket server directly -CMD ["bun", "apps/sim/socket/index.ts"] \ No newline at end of file +CMD ["bun", "apps/realtime/src/index.ts"] diff --git a/package.json b/package.json index 335e8831934..ee8302a95b5 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "scripts": { "build": "turbo run build", "dev": "turbo run dev", - "dev:sockets": "cd apps/sim && bun run dev:sockets", - "dev:full": "cd apps/sim && bun run dev:full", + "dev:sockets": "cd apps/realtime && bun run dev", + "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"", "test": "turbo run test", "format": "turbo run format", "format:check": "turbo run format:check", @@ -21,6 +21,8 @@ "lint:helm": "helm lint ./helm/sim --strict --values ./helm/sim/test/values-lint.yaml", "lint:all": "turbo run lint && bun run lint:helm", "check": "turbo run format:check", + "check:boundaries": "bun run scripts/check-monorepo-boundaries.ts", + "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", "mship-tools:generate": "bun run scripts/sync-tool-catalog.ts", @@ -65,8 +67,5 @@ }, "trustedDependencies": [ "sharp" - ], - "dependencies": { - "@aws-sdk/client-athena": "3.1024.0" - } + ] } diff --git a/packages/audit/package.json b/packages/audit/package.json new file mode 100644 index 00000000000..bad5fe28b39 --- /dev/null +++ b/packages/audit/package.json @@ -0,0 +1,39 @@ +{ + "name": "@sim/audit", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@sim/db": "workspace:*", + "@sim/logger": "workspace:*", + "@sim/utils": "workspace:*", + "drizzle-orm": "^0.45.2" + }, + "devDependencies": { + "@sim/testing": "workspace:*", + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3", + "vitest": "^3.0.8" + } +} diff --git a/packages/audit/src/index.ts b/packages/audit/src/index.ts new file mode 100644 index 00000000000..f3ec73f8c5d --- /dev/null +++ b/packages/audit/src/index.ts @@ -0,0 +1,3 @@ +export { recordAudit } from './log' +export type { AuditActionType, AuditResourceTypeValue } from './types' +export { AuditAction, AuditResourceType } from './types' diff --git a/apps/sim/lib/audit/log.test.ts b/packages/audit/src/log.test.ts similarity index 93% rename from apps/sim/lib/audit/log.test.ts rename to packages/audit/src/log.test.ts index 1c755867462..19a44aea416 100644 --- a/apps/sim/lib/audit/log.test.ts +++ b/packages/audit/src/log.test.ts @@ -13,6 +13,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/db', () => ({ ...dbChainMock, auditLog: { id: 'id', workspaceId: 'workspace_id' }, + user: { id: 'id', name: 'name', email: 'email' }, +})) +vi.mock('drizzle-orm', () => ({ + eq: vi.fn(), + and: vi.fn(), + or: vi.fn(), + sql: vi.fn(), +})) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), })) vi.mock('@sim/utils/id', () => ({ generateId: () => 'test-uuid-123', @@ -21,7 +36,7 @@ vi.mock('@sim/utils/id', () => ({ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v), })) -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { AuditAction, AuditResourceType, recordAudit } from './index' const flush = () => new Promise((resolve) => setTimeout(resolve, 10)) @@ -356,8 +371,9 @@ describe('auditMock sync', () => { }) it('has the same AuditAction values as the source', () => { + const mockActions = auditMock.AuditAction as Record for (const key of Object.keys(AuditAction)) { - expect(auditMock.AuditAction[key]).toBe(AuditAction[key as keyof typeof AuditAction]) + expect(mockActions[key]).toBe(AuditAction[key as keyof typeof AuditAction]) } }) @@ -368,10 +384,9 @@ describe('auditMock sync', () => { }) it('has the same AuditResourceType values as the source', () => { + const mockResourceTypes = auditMock.AuditResourceType as Record for (const key of Object.keys(AuditResourceType)) { - expect(auditMock.AuditResourceType[key]).toBe( - AuditResourceType[key as keyof typeof AuditResourceType] - ) + expect(mockResourceTypes[key]).toBe(AuditResourceType[key as keyof typeof AuditResourceType]) } }) }) diff --git a/apps/sim/lib/audit/log.ts b/packages/audit/src/log.ts similarity index 75% rename from apps/sim/lib/audit/log.ts rename to packages/audit/src/log.ts index 3176fcbee9a..4ee040ba43a 100644 --- a/apps/sim/lib/audit/log.ts +++ b/packages/audit/src/log.ts @@ -1,13 +1,8 @@ -import { auditLog, db } from '@sim/db' -import { user } from '@sim/db/schema' +import { auditLog, db, user } from '@sim/db' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { eq } from 'drizzle-orm' -import type { AuditActionType, AuditResourceTypeValue } from '@/lib/audit/types' -import { getClientIp } from '@/lib/core/utils/request' - -export type { AuditActionType, AuditResourceTypeValue } from '@/lib/audit/types' -export { AuditAction, AuditResourceType } from '@/lib/audit/types' +import type { AuditActionType, AuditResourceTypeValue } from './types' const logger = createLogger('AuditLog') @@ -22,13 +17,20 @@ interface AuditLogParams { resourceName?: string description?: string metadata?: Record - request?: Request + request?: { headers: { get(name: string): string | null } } +} + +function getClientIp(request: { headers: { get(name: string): string | null } }): string { + return ( + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + request.headers.get('x-real-ip')?.trim() || + 'unknown' + ) } /** - * Records an audit log entry. Fire-and-forget — never throws or blocks the caller. - * If actorName and actorEmail are both undefined (not provided by the caller), - * resolves them from the user table before inserting. + * Fire-and-forget audit log write. Never throws; failures are logged. + * Resolves actorName/actorEmail from the user table when both are omitted. */ export function recordAudit(params: AuditLogParams): void { insertAuditLog(params).catch((error) => { diff --git a/apps/sim/lib/audit/types.ts b/packages/audit/src/types.ts similarity index 100% rename from apps/sim/lib/audit/types.ts rename to packages/audit/src/types.ts diff --git a/packages/audit/tsconfig.json b/packages/audit/tsconfig.json new file mode 100644 index 00000000000..7b64383489d --- /dev/null +++ b/packages/audit/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] +} diff --git a/packages/audit/vitest.config.ts b/packages/audit/vitest.config.ts new file mode 100644 index 00000000000..471771e48fe --- /dev/null +++ b/packages/audit/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 00000000000..b44406abdfc --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,33 @@ +{ + "name": "@sim/auth", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + "./verify": { + "types": "./src/verify.ts", + "default": "./src/verify.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": { + "@sim/db": "workspace:*", + "better-auth": "1.3.12" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3" + } +} diff --git a/packages/auth/src/verify.ts b/packages/auth/src/verify.ts new file mode 100644 index 00000000000..38fe06df323 --- /dev/null +++ b/packages/auth/src/verify.ts @@ -0,0 +1,36 @@ +import { db } from '@sim/db' +import * as schema from '@sim/db/schema' +import { betterAuth } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { oneTimeToken } from 'better-auth/plugins' + +export interface VerifyAuthOptions { + /** Better Auth shared secret. Must match the apps/sim Better Auth secret. */ + secret: string + /** Public-facing Better Auth URL (usually same as NEXT_PUBLIC_APP_URL). */ + baseURL: string +} + +/** + * Minimal Better Auth instance used by services that only need to verify + * one-time tokens issued by the main app. Shares the Better Auth DB schema + * (`verification` table) and secret with the main app, so tokens issued by + * `apps/sim`'s full auth config are accepted here. + */ +export function createVerifyAuth(options: VerifyAuthOptions) { + return betterAuth({ + baseURL: options.baseURL, + secret: options.secret, + database: drizzleAdapter(db, { + provider: 'pg', + schema, + }), + plugins: [ + oneTimeToken({ + expiresIn: 24 * 60 * 60, + }), + ], + }) +} + +export type VerifyAuth = ReturnType diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 00000000000..1ffa3d2e844 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/db/.env.example b/packages/db/.env.example index bc06b7099c5..14459e61f51 100644 --- a/packages/db/.env.example +++ b/packages/db/.env.example @@ -1,2 +1,5 @@ -# Database URL (Required for migrations and database operations) -DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio" \ No newline at end of file +# Database connection used by @sim/db scripts (drizzle-kit generate, +# db:migrate, register-sso-provider, etc.). Must match DATABASE_URL in +# apps/sim/.env and apps/realtime/.env. + +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/simstudio" diff --git a/packages/realtime-protocol/package.json b/packages/realtime-protocol/package.json new file mode 100644 index 00000000000..63590ce1b8e --- /dev/null +++ b/packages/realtime-protocol/package.json @@ -0,0 +1,36 @@ +{ + "name": "@sim/realtime-protocol", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + "./constants": { + "types": "./src/constants.ts", + "default": "./src/constants.ts" + }, + "./schemas": { + "types": "./src/schemas.ts", + "default": "./src/schemas.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": { + "zod": "^3.24.2" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3" + } +} diff --git a/apps/sim/socket/constants.ts b/packages/realtime-protocol/src/constants.ts similarity index 100% rename from apps/sim/socket/constants.ts rename to packages/realtime-protocol/src/constants.ts diff --git a/apps/sim/socket/validation/schemas.ts b/packages/realtime-protocol/src/schemas.ts similarity index 99% rename from apps/sim/socket/validation/schemas.ts rename to packages/realtime-protocol/src/schemas.ts index fc48500ab64..19d76437ab2 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/packages/realtime-protocol/src/schemas.ts @@ -8,7 +8,7 @@ import { SUBFLOW_OPERATIONS, VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, -} from '@/socket/constants' +} from './constants' const PositionSchema = z.object({ x: z.number(), diff --git a/packages/realtime-protocol/tsconfig.json b/packages/realtime-protocol/tsconfig.json new file mode 100644 index 00000000000..1ffa3d2e844 --- /dev/null +++ b/packages/realtime-protocol/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/security/package.json b/packages/security/package.json new file mode 100644 index 00000000000..dc823111799 --- /dev/null +++ b/packages/security/package.json @@ -0,0 +1,50 @@ +{ + "name": "@sim/security", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + "./compare": { + "types": "./src/compare.ts", + "default": "./src/compare.ts" + }, + "./encryption": { + "types": "./src/encryption.ts", + "default": "./src/encryption.ts" + }, + "./hash": { + "types": "./src/hash.ts", + "default": "./src/hash.ts" + }, + "./hmac": { + "types": "./src/hmac.ts", + "default": "./src/hmac.ts" + }, + "./tokens": { + "types": "./src/tokens.ts", + "default": "./src/tokens.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3", + "vitest": "^3.0.8" + } +} diff --git a/packages/security/src/compare.test.ts b/packages/security/src/compare.test.ts new file mode 100644 index 00000000000..265235e65b5 --- /dev/null +++ b/packages/security/src/compare.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { safeCompare } from './compare' + +describe('safeCompare', () => { + it('returns true for identical strings', () => { + expect(safeCompare('abc', 'abc')).toBe(true) + }) + + it('returns false for equal-length different strings', () => { + expect(safeCompare('abc', 'abd')).toBe(false) + }) + + it('returns false for different-length strings without throwing', () => { + expect(safeCompare('short', 'longer-value')).toBe(false) + expect(safeCompare('', 'a')).toBe(false) + expect(safeCompare('a', '')).toBe(false) + }) + + it('returns true for two empty strings', () => { + expect(safeCompare('', '')).toBe(true) + }) + + it('handles long inputs', () => { + const a = 'x'.repeat(10_000) + const b = 'x'.repeat(10_000) + expect(safeCompare(a, b)).toBe(true) + expect(safeCompare(a, `${b.slice(0, -1)}y`)).toBe(false) + }) + + it('is case-sensitive', () => { + expect(safeCompare('ABC', 'abc')).toBe(false) + }) + + it('distinguishes hex digests that differ in one nibble', () => { + const a = 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7' + const b = 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff8' + expect(safeCompare(a, b)).toBe(false) + expect(safeCompare(a, a)).toBe(true) + }) +}) diff --git a/packages/security/src/compare.ts b/packages/security/src/compare.ts new file mode 100644 index 00000000000..3d68040e145 --- /dev/null +++ b/packages/security/src/compare.ts @@ -0,0 +1,13 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +/** + * Constant-time string comparison using HMAC-digest wrapping to handle + * inputs of differing length. Use for HMAC signatures, API keys, and other + * secrets where leaking length or content via timing must be avoided. + */ +export function safeCompare(a: string, b: string): boolean { + const key = 'safeCompare' + const ha = createHmac('sha256', key).update(a).digest() + const hb = createHmac('sha256', key).update(b).digest() + return timingSafeEqual(ha, hb) +} diff --git a/packages/security/src/encryption.test.ts b/packages/security/src/encryption.test.ts new file mode 100644 index 00000000000..13369500fd1 --- /dev/null +++ b/packages/security/src/encryption.test.ts @@ -0,0 +1,74 @@ +import { randomBytes } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { decrypt, encrypt } from './encryption' + +const KEY = Buffer.from('0'.repeat(64), 'hex') + +describe('encrypt', () => { + it('returns iv:ciphertext:authTag and a 32-char hex IV', async () => { + const result = await encrypt('secret', KEY) + expect(result.encrypted.split(':')).toHaveLength(3) + expect(result.iv).toHaveLength(32) + }) + + it('produces distinct ciphertexts for the same input', async () => { + const a = await encrypt('same', KEY) + const b = await encrypt('same', KEY) + expect(a.encrypted).not.toBe(b.encrypted) + }) + + it('rejects keys that are not 32 bytes', async () => { + await expect(encrypt('x', Buffer.alloc(16))).rejects.toThrow(/32 bytes/) + }) +}) + +describe('decrypt', () => { + it('round-trips arbitrary UTF-8 input', async () => { + const plaintext = 'Hello, !"#$%&\'()*+,-./0123456789:;<=>?@' + const { encrypted } = await encrypt(plaintext, KEY) + const { decrypted } = await decrypt(encrypted, KEY) + expect(decrypted).toBe(plaintext) + }) + + it('round-trips empty strings', async () => { + const { encrypted } = await encrypt('', KEY) + const { decrypted } = await decrypt(encrypted, KEY) + expect(decrypted).toBe('') + }) + + it('round-trips long inputs', async () => { + const plaintext = 'a'.repeat(10_000) + const { encrypted } = await encrypt(plaintext, KEY) + const { decrypted } = await decrypt(encrypted, KEY) + expect(decrypted).toBe(plaintext) + }) + + it('throws on malformed input', async () => { + await expect(decrypt('invalid', KEY)).rejects.toThrow( + 'Invalid encrypted value format. Expected "iv:encrypted:authTag"' + ) + await expect(decrypt('part1:part2', KEY)).rejects.toThrow( + 'Invalid encrypted value format. Expected "iv:encrypted:authTag"' + ) + }) + + it('throws when ciphertext is tampered', async () => { + const { encrypted } = await encrypt('original', KEY) + const parts = encrypted.split(':') + parts[1] = `deadbeef${parts[1].slice(8)}` + await expect(decrypt(parts.join(':'), KEY)).rejects.toThrow() + }) + + it('throws when auth tag is tampered', async () => { + const { encrypted } = await encrypt('original', KEY) + const parts = encrypted.split(':') + parts[2] = '0'.repeat(32) + await expect(decrypt(parts.join(':'), KEY)).rejects.toThrow() + }) + + it('throws when decrypted with a different key', async () => { + const { encrypted } = await encrypt('original', KEY) + const otherKey = randomBytes(32) + await expect(decrypt(encrypted, otherKey)).rejects.toThrow() + }) +}) diff --git a/packages/security/src/encryption.ts b/packages/security/src/encryption.ts new file mode 100644 index 00000000000..c1bbf078e38 --- /dev/null +++ b/packages/security/src/encryption.ts @@ -0,0 +1,68 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' + +/** + * AES-256-GCM encryption primitive. Produces a self-contained string in the + * format `iv:ciphertext:authTag` (all hex-encoded) that can be stored and + * later passed directly to {@link decrypt}. + * + * @param plaintext - UTF-8 string to encrypt + * @param key - 32-byte encryption key + */ +export async function encrypt( + plaintext: string, + key: Buffer +): Promise<{ encrypted: string; iv: string }> { + assertKey(key) + + const iv = randomBytes(16) + const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) + let encrypted = cipher.update(plaintext, 'utf8', 'hex') + encrypted += cipher.final('hex') + + const authTag = cipher.getAuthTag() + const ivHex = iv.toString('hex') + + return { + encrypted: `${ivHex}:${encrypted}:${authTag.toString('hex')}`, + iv: ivHex, + } +} + +/** + * AES-256-GCM decryption primitive. Expects input produced by {@link encrypt} + * in the format `iv:ciphertext:authTag`. Throws when the format is malformed + * or when the GCM auth tag does not verify (tampered ciphertext, wrong key). + */ +export async function decrypt(encryptedValue: string, key: Buffer): Promise<{ decrypted: string }> { + assertKey(key) + + const parts = encryptedValue.split(':') + if (parts.length < 3) { + throw new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"') + } + + const ivHex = parts[0] + const authTagHex = parts[parts.length - 1] + const encrypted = parts.slice(1, -1).join(':') + + if (!ivHex || !authTagHex) { + throw new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"') + } + + const iv = Buffer.from(ivHex, 'hex') + const authTag = Buffer.from(authTagHex, 'hex') + + const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) + decipher.setAuthTag(authTag) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return { decrypted } +} + +function assertKey(key: Buffer): void { + if (key.length !== 32) { + throw new Error('Encryption key must be 32 bytes (256 bits)') + } +} diff --git a/packages/security/src/hash.test.ts b/packages/security/src/hash.test.ts new file mode 100644 index 00000000000..89f9b0929ae --- /dev/null +++ b/packages/security/src/hash.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { sha256Hex } from './hash' + +describe('sha256Hex', () => { + it('is deterministic', () => { + expect(sha256Hex('hello')).toBe(sha256Hex('hello')) + }) + + it('returns a 64-char hex digest', () => { + expect(sha256Hex('hello')).toMatch(/^[0-9a-f]{64}$/) + }) + + it('matches the published vector for the empty string', () => { + expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') + }) + + it('differs for different inputs', () => { + expect(sha256Hex('a')).not.toBe(sha256Hex('b')) + }) +}) diff --git a/packages/security/src/hash.ts b/packages/security/src/hash.ts new file mode 100644 index 00000000000..9a9a18b498d --- /dev/null +++ b/packages/security/src/hash.ts @@ -0,0 +1,10 @@ +import { createHash } from 'node:crypto' + +/** + * Deterministic SHA-256 digest of a UTF-8 string, hex-encoded. Use for + * indexed lookup of sensitive values (e.g. API key hash columns) where the + * caller only needs to verify equality without ever reversing the hash. + */ +export function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf8').digest('hex') +} diff --git a/packages/security/src/hmac.test.ts b/packages/security/src/hmac.test.ts new file mode 100644 index 00000000000..8daecafa0d0 --- /dev/null +++ b/packages/security/src/hmac.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { hmacSha256Base64, hmacSha256Hex } from './hmac' + +describe('hmacSha256Hex', () => { + it('is deterministic', () => { + expect(hmacSha256Hex('body', 'secret')).toBe(hmacSha256Hex('body', 'secret')) + }) + + it('returns a 64-char hex digest', () => { + expect(hmacSha256Hex('body', 'secret')).toMatch(/^[0-9a-f]{64}$/) + }) + + it('matches RFC 4231 test vector 1', () => { + const key = Buffer.from('0b'.repeat(20), 'hex').toString('binary') + expect(hmacSha256Hex('Hi There', key)).toBe( + 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7' + ) + }) + + it('differs when body changes', () => { + expect(hmacSha256Hex('a', 'k')).not.toBe(hmacSha256Hex('b', 'k')) + }) + + it('differs when secret changes', () => { + expect(hmacSha256Hex('body', 'k1')).not.toBe(hmacSha256Hex('body', 'k2')) + }) + + it('accepts a Buffer secret and matches the equivalent binary-string secret', () => { + const raw = Buffer.from('0b'.repeat(20), 'hex') + expect(hmacSha256Hex('Hi There', raw)).toBe(hmacSha256Hex('Hi There', raw.toString('binary'))) + }) +}) + +describe('hmacSha256Base64', () => { + it('is deterministic', () => { + expect(hmacSha256Base64('body', 'secret')).toBe(hmacSha256Base64('body', 'secret')) + }) + + it('returns a base64 digest', () => { + expect(hmacSha256Base64('body', 'secret')).toMatch(/^[A-Za-z0-9+/]+=*$/) + }) + + it('agrees with hex form via Buffer conversion', () => { + const hex = hmacSha256Hex('body', 'secret') + const b64 = hmacSha256Base64('body', 'secret') + expect(Buffer.from(b64, 'base64').toString('hex')).toBe(hex) + }) + + it('accepts a Buffer secret (Svix / MS-Teams scheme)', () => { + const secret = Buffer.from('whsec-decoded-bytes') + const hex = hmacSha256Hex('body', secret) + const b64 = hmacSha256Base64('body', secret) + expect(Buffer.from(b64, 'base64').toString('hex')).toBe(hex) + }) +}) diff --git a/packages/security/src/hmac.ts b/packages/security/src/hmac.ts new file mode 100644 index 00000000000..58512634106 --- /dev/null +++ b/packages/security/src/hmac.ts @@ -0,0 +1,24 @@ +import { createHmac } from 'node:crypto' + +/** + * HMAC-SHA256 of a UTF-8 body using the given secret, hex-encoded. Use for + * webhook signature verification where the provider sends a hex digest + * (e.g. `X-Signature: ` or `X-Hub-Signature-256: sha256=`). Pass the + * secret as a `Buffer` when the provider's scheme requires base64-decoding + * (e.g. Svix-compatible `whsec_...` secrets). Pair with + * {@link ../compare | safeCompare} for timing-safe comparison. + */ +export function hmacSha256Hex(body: string, secret: string | Buffer): string { + return createHmac('sha256', secret).update(body, 'utf8').digest('hex') +} + +/** + * HMAC-SHA256 of a UTF-8 body using the given secret, base64-encoded. Use for + * webhook signature verification where the provider sends a base64 digest + * (e.g. Typeform, Microsoft Teams outgoing webhooks). Pass the secret as a + * `Buffer` when the provider's scheme requires base64-decoding. Pair with + * {@link ../compare | safeCompare} for timing-safe comparison. + */ +export function hmacSha256Base64(body: string, secret: string | Buffer): string { + return createHmac('sha256', secret).update(body, 'utf8').digest('base64') +} diff --git a/packages/security/src/tokens.test.ts b/packages/security/src/tokens.test.ts new file mode 100644 index 00000000000..035b2a4e1f2 --- /dev/null +++ b/packages/security/src/tokens.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { generateSecureToken } from './tokens' + +describe('generateSecureToken', () => { + it('defaults to 24 bytes (32-char base64url)', () => { + const token = generateSecureToken() + expect(token).toHaveLength(32) + expect(token).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + it('honors a custom byte length', () => { + const token = generateSecureToken(16) + expect(token).toHaveLength(22) + }) + + it('never repeats across 1000 draws', () => { + const seen = new Set() + for (let i = 0; i < 1000; i++) seen.add(generateSecureToken()) + expect(seen.size).toBe(1000) + }) + + it('is URL-safe (no +, /, or = padding)', () => { + const token = generateSecureToken(64) + expect(token).not.toMatch(/[+/=]/) + }) +}) diff --git a/packages/security/src/tokens.ts b/packages/security/src/tokens.ts new file mode 100644 index 00000000000..311aaf46796 --- /dev/null +++ b/packages/security/src/tokens.ts @@ -0,0 +1,12 @@ +import { randomBytes } from 'node:crypto' + +/** + * Generate a cryptographically secure random token encoded as base64url. The + * returned string is URL-safe (no padding, no `+`/`/`) and suitable for use + * as an API key body, bearer token, or one-time identifier. + * + * @param byteLength - Number of random bytes to draw before encoding. Defaults to 24 (~32 chars). + */ +export function generateSecureToken(byteLength = 24): string { + return randomBytes(byteLength).toString('base64url') +} diff --git a/packages/security/tsconfig.json b/packages/security/tsconfig.json new file mode 100644 index 00000000000..1ffa3d2e844 --- /dev/null +++ b/packages/security/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/security/vitest.config.ts b/packages/security/vitest.config.ts new file mode 100644 index 00000000000..471771e48fe --- /dev/null +++ b/packages/security/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 4dadec22819..58671d6ba46 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest' /** - * Controllable mock functions for `@/lib/audit/log`. + * Controllable mock functions for `@sim/audit`. * Exposes `mockRecordAudit` so tests can assert or override behavior per test. * * @example @@ -17,11 +17,11 @@ export const auditMockFns = { } /** - * Static mock module for `@/lib/audit/log`. + * Static mock module for `@sim/audit`. * * @example * ```ts - * vi.mock('@/lib/audit/log', () => auditMock) + * vi.mock('@sim/audit', () => auditMock) * ``` */ export const auditMock = { diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index c3ab30078c5..46c55c2e0ac 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -120,6 +120,8 @@ export { export { telemetryMock } from './telemetry.mock' // URL mocks export { urlsMock, urlsMockFns } from './urls.mock' +// Workflow authz package mocks (for @sim/workflow-authz) +export { workflowAuthzMock, workflowAuthzMockFns } from './workflow-authz.mock' // Workflows API utils mocks (for @/app/api/workflows/utils) export { workflowsApiUtilsMock, workflowsApiUtilsMockFns } from './workflows-api-utils.mock' // Workflows orchestration mocks (for @/lib/workflows/orchestration) diff --git a/packages/testing/src/mocks/workflow-authz.mock.ts b/packages/testing/src/mocks/workflow-authz.mock.ts new file mode 100644 index 00000000000..dea86a272d4 --- /dev/null +++ b/packages/testing/src/mocks/workflow-authz.mock.ts @@ -0,0 +1,39 @@ +import { vi } from 'vitest' + +/** + * Controllable mocks for the `@sim/workflow-authz` package. + * + * @example + * ```ts + * import { workflowAuthzMockFns } from '@sim/testing' + * + * workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + * allowed: true, + * status: 200, + * workflow: { id: 'wf-1' }, + * workspacePermission: 'admin', + * }) + * ``` + */ +export const workflowAuthzMockFns = { + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + mockGetActiveWorkflowContext: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockAssertActiveWorkflowContext: vi.fn(), +} + +/** + * Static mock module for `@sim/workflow-authz`. + * + * @example + * ```ts + * vi.mock('@sim/workflow-authz', () => workflowAuthzMock) + * ``` + */ +export const workflowAuthzMock = { + authorizeWorkflowByWorkspacePermission: + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission, + getActiveWorkflowContext: workflowAuthzMockFns.mockGetActiveWorkflowContext, + getActiveWorkflowRecord: workflowAuthzMockFns.mockGetActiveWorkflowRecord, + assertActiveWorkflowContext: workflowAuthzMockFns.mockAssertActiveWorkflowContext, +} diff --git a/packages/testing/src/mocks/workflows-utils.mock.ts b/packages/testing/src/mocks/workflows-utils.mock.ts index 0930d9b5dc9..1c70412f014 100644 --- a/packages/testing/src/mocks/workflows-utils.mock.ts +++ b/packages/testing/src/mocks/workflows-utils.mock.ts @@ -20,7 +20,6 @@ export const workflowsUtilsMockFns = { mockWorkflowHasResponseBlock: vi.fn(), mockCreateHttpResponseFromBlock: vi.fn(), mockValidateWorkflowPermissions: vi.fn(), - mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), mockCreateWorkflowRecord: vi.fn(), mockUpdateWorkflowRecord: vi.fn(), mockDeleteWorkflowRecord: vi.fn(), @@ -38,10 +37,12 @@ export const workflowsUtilsMockFns = { * * Default behaviors: * - `getWorkflowById` resolves to `null` - * - `authorizeWorkflowByWorkspacePermission` resolves to allowed with `test-workspace-id` * - `validateWorkflowPermissions` resolves to an authorized result * - Other functions resolve to sensible empty/success defaults * + * `authorizeWorkflowByWorkspacePermission` moved to `@sim/workflow-authz`; + * use `workflowAuthzMock` / `workflowAuthzMockFns` for that surface. + * * @example * ```ts * vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -56,8 +57,6 @@ export const workflowsUtilsMock = { workflowHasResponseBlock: workflowsUtilsMockFns.mockWorkflowHasResponseBlock, createHttpResponseFromBlock: workflowsUtilsMockFns.mockCreateHttpResponseFromBlock, validateWorkflowPermissions: workflowsUtilsMockFns.mockValidateWorkflowPermissions, - authorizeWorkflowByWorkspacePermission: - workflowsUtilsMockFns.mockAuthorizeWorkflowByWorkspacePermission, createWorkflowRecord: workflowsUtilsMockFns.mockCreateWorkflowRecord, updateWorkflowRecord: workflowsUtilsMockFns.mockUpdateWorkflowRecord, deleteWorkflowRecord: workflowsUtilsMockFns.mockDeleteWorkflowRecord, diff --git a/packages/workflow-authz/package.json b/packages/workflow-authz/package.json new file mode 100644 index 00000000000..8bfd7b9ebe2 --- /dev/null +++ b/packages/workflow-authz/package.json @@ -0,0 +1,33 @@ +{ + "name": "@sim/workflow-authz", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": { + "@sim/db": "workspace:*", + "drizzle-orm": "^0.45.2" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3" + } +} diff --git a/packages/workflow-authz/src/index.ts b/packages/workflow-authz/src/index.ts new file mode 100644 index 00000000000..534adc1b667 --- /dev/null +++ b/packages/workflow-authz/src/index.ts @@ -0,0 +1,143 @@ +import { db, permissions, type permissionTypeEnum, workflow, workspace } from '@sim/db' +import { and, eq, isNull } from 'drizzle-orm' + +export type ActiveWorkflowRecord = typeof workflow.$inferSelect + +export interface ActiveWorkflowContext { + workflow: ActiveWorkflowRecord + workspaceId: string +} + +export async function getActiveWorkflowContext( + workflowId: string +): Promise { + const rows = await db + .select({ + workflow, + workspaceId: workspace.id, + }) + .from(workflow) + .innerJoin(workspace, eq(workflow.workspaceId, workspace.id)) + .where( + and(eq(workflow.id, workflowId), isNull(workflow.archivedAt), isNull(workspace.archivedAt)) + ) + .limit(1) + + if (rows.length === 0) { + return null + } + + return { + workflow: rows[0].workflow, + workspaceId: rows[0].workspaceId, + } +} + +export async function getActiveWorkflowRecord( + workflowId: string +): Promise { + const context = await getActiveWorkflowContext(workflowId) + return context?.workflow ?? null +} + +export async function assertActiveWorkflowContext( + workflowId: string +): Promise { + const context = await getActiveWorkflowContext(workflowId) + if (!context) { + throw new Error(`Active workflow not found: ${workflowId}`) + } + return context +} + +export type PermissionType = (typeof permissionTypeEnum.enumValues)[number] + +type WorkflowRecord = typeof workflow.$inferSelect + +export interface WorkflowWorkspaceAuthorizationResult { + allowed: boolean + status: number + message?: string + workflow: WorkflowRecord | null + workspacePermission: PermissionType | null +} + +export async function authorizeWorkflowByWorkspacePermission(params: { + workflowId: string + userId: string + action?: 'read' | 'write' | 'admin' +}): Promise { + const { workflowId, userId, action = 'read' } = params + + const activeContext = await getActiveWorkflowContext(workflowId) + if (!activeContext) { + return { + allowed: false, + status: 404, + message: 'Workflow not found', + workflow: null, + workspacePermission: null, + } + } + + const wf = activeContext.workflow + + if (!wf.workspaceId) { + return { + allowed: false, + status: 403, + message: + 'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.', + workflow: wf, + workspacePermission: null, + } + } + + const [permissionRow] = await db + .select({ permissionType: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, wf.workspaceId) + ) + ) + .limit(1) + + const workspacePermission = (permissionRow?.permissionType as PermissionType | undefined) ?? null + + if (workspacePermission === null) { + return { + allowed: false, + status: 403, + message: `Unauthorized: Access denied to ${action} this workflow`, + workflow: wf, + workspacePermission, + } + } + + const permissionSatisfied = + action === 'read' + ? true + : action === 'write' + ? workspacePermission === 'write' || workspacePermission === 'admin' + : workspacePermission === 'admin' + + if (!permissionSatisfied) { + return { + allowed: false, + status: 403, + message: `Unauthorized: Access denied to ${action} this workflow`, + workflow: wf, + workspacePermission, + } + } + + return { + allowed: true, + status: 200, + workflow: wf, + workspacePermission, + } +} diff --git a/packages/workflow-authz/tsconfig.json b/packages/workflow-authz/tsconfig.json new file mode 100644 index 00000000000..1ffa3d2e844 --- /dev/null +++ b/packages/workflow-authz/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/workflow-persistence/package.json b/packages/workflow-persistence/package.json new file mode 100644 index 00000000000..00a99f1dfff --- /dev/null +++ b/packages/workflow-persistence/package.json @@ -0,0 +1,57 @@ +{ + "name": "@sim/workflow-persistence", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./load": { + "types": "./src/load.ts", + "default": "./src/load.ts" + }, + "./save": { + "types": "./src/save.ts", + "default": "./src/save.ts" + }, + "./subblocks": { + "types": "./src/subblocks.ts", + "default": "./src/subblocks.ts" + }, + "./subflow-helpers": { + "types": "./src/subflow-helpers.ts", + "default": "./src/subflow-helpers.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": { + "@sim/db": "workspace:*", + "@sim/logger": "workspace:*", + "@sim/utils": "workspace:*", + "@sim/workflow-types": "workspace:*", + "drizzle-orm": "^0.45.2", + "reactflow": "^11.11.4" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3" + } +} diff --git a/packages/workflow-persistence/src/index.ts b/packages/workflow-persistence/src/index.ts new file mode 100644 index 00000000000..ad0b35248c1 --- /dev/null +++ b/packages/workflow-persistence/src/index.ts @@ -0,0 +1,19 @@ +export { + loadWorkflowFromNormalizedTablesRaw, + persistMigratedBlocks, + type RawNormalizedWorkflow, +} from './load' +export { saveWorkflowToNormalizedTables } from './save' +export { + DEFAULT_SUBBLOCK_TYPE, + mergeSubBlockValues, + mergeSubblockStateWithValues, +} from './subblocks' +export { + convertLoopBlockToLoop, + convertParallelBlockToParallel, + findChildNodes, + generateLoopBlocks, + generateParallelBlocks, +} from './subflow-helpers' +export type { DbOrTx, NormalizedWorkflowData } from './types' diff --git a/packages/workflow-persistence/src/load.ts b/packages/workflow-persistence/src/load.ts new file mode 100644 index 00000000000..8f19375c811 --- /dev/null +++ b/packages/workflow-persistence/src/load.ts @@ -0,0 +1,182 @@ +import { db, workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db' +import { createLogger } from '@sim/logger' +import type { BlockState, Loop, Parallel } from '@sim/workflow-types/workflow' +import { SUBFLOW_TYPES } from '@sim/workflow-types/workflow' +import { eq } from 'drizzle-orm' +import type { Edge } from 'reactflow' +import type { NormalizedWorkflowData } from './types' + +const logger = createLogger('WorkflowPersistenceLoad') + +export interface RawNormalizedWorkflow extends NormalizedWorkflowData { + workspaceId: string +} + +/** + * Load workflow state from normalized tables without running block migrations. + * Block migrations (credential rewrites, subblock ID migrations, canonical-mode + * backfill, tool sanitization) depend on the block/tool registry that lives in + * the Next app and should not be pulled into leaf services. Callers that want + * migrated state should wrap this with their own migration pipeline. + * + * Invariant: downstream migrations must not mutate `block.data.collection`, + * `block.data.whileCondition`, or `block.data.doWhileCondition`. Those fields + * are patched here from the subflow config on the pre-migration block, and + * callers re-sync only `loop.enabled`/`parallel.enabled` from the migrated + * block. If a future migration rewrites these data fields, the loop/parallel + * config on the returned object will silently diverge from the migrated block. + */ +export async function loadWorkflowFromNormalizedTablesRaw( + workflowId: string +): Promise { + try { + const [blocks, edges, subflows, [workflowRow]] = await Promise.all([ + db.select().from(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), + db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), + db.select().from(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), + db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), + ]) + + if (blocks.length === 0) { + return null + } + + if (!workflowRow?.workspaceId) { + throw new Error(`Workflow ${workflowId} has no workspace`) + } + + const blocksMap: Record = {} + blocks.forEach((block) => { + const blockData = (block.data ?? {}) as BlockState['data'] + + const assembled: BlockState = { + id: block.id, + type: block.type, + name: block.name, + position: { + x: Number(block.positionX), + y: Number(block.positionY), + }, + enabled: block.enabled, + horizontalHandles: block.horizontalHandles, + advancedMode: block.advancedMode, + triggerMode: block.triggerMode, + height: Number(block.height), + subBlocks: (block.subBlocks as BlockState['subBlocks']) || {}, + outputs: (block.outputs as BlockState['outputs']) || {}, + data: blockData, + locked: block.locked, + } + + blocksMap[block.id] = assembled + }) + + const edgesArray: Edge[] = edges.map((edge) => ({ + id: edge.id, + source: edge.sourceBlockId, + target: edge.targetBlockId, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + type: 'default', + data: {}, + })) + + const loops: Record = {} + const parallels: Record = {} + + subflows.forEach((subflow) => { + const config = (subflow.config ?? {}) as Partial + + if (subflow.type === SUBFLOW_TYPES.LOOP) { + const loopType = + (config as Loop).loopType === 'for' || + (config as Loop).loopType === 'forEach' || + (config as Loop).loopType === 'while' || + (config as Loop).loopType === 'doWhile' + ? (config as Loop).loopType + : 'for' + + const loop: Loop = { + id: subflow.id, + nodes: Array.isArray((config as Loop).nodes) ? (config as Loop).nodes : [], + iterations: + typeof (config as Loop).iterations === 'number' ? (config as Loop).iterations : 1, + loopType, + forEachItems: (config as Loop).forEachItems ?? '', + whileCondition: (config as Loop).whileCondition ?? '', + doWhileCondition: (config as Loop).doWhileCondition ?? '', + enabled: blocksMap[subflow.id]?.enabled ?? true, + } + loops[subflow.id] = loop + + if (blocksMap[subflow.id]) { + const block = blocksMap[subflow.id] + blocksMap[subflow.id] = { + ...block, + data: { + ...block.data, + collection: loop.forEachItems ?? block.data?.collection ?? '', + whileCondition: loop.whileCondition ?? block.data?.whileCondition ?? '', + doWhileCondition: loop.doWhileCondition ?? block.data?.doWhileCondition ?? '', + }, + } + } + } else if (subflow.type === SUBFLOW_TYPES.PARALLEL) { + const parallel: Parallel = { + id: subflow.id, + nodes: Array.isArray((config as Parallel).nodes) ? (config as Parallel).nodes : [], + count: typeof (config as Parallel).count === 'number' ? (config as Parallel).count : 5, + distribution: (config as Parallel).distribution ?? '', + parallelType: + (config as Parallel).parallelType === 'count' || + (config as Parallel).parallelType === 'collection' + ? (config as Parallel).parallelType + : 'count', + enabled: blocksMap[subflow.id]?.enabled ?? true, + } + parallels[subflow.id] = parallel + } else { + logger.warn(`Unknown subflow type: ${subflow.type} for subflow ${subflow.id}`) + } + }) + + return { + blocks: blocksMap, + edges: edgesArray, + loops, + parallels, + isFromNormalizedTables: true, + workspaceId: workflowRow.workspaceId, + } + } catch (error) { + logger.error(`Error loading workflow ${workflowId} from normalized tables:`, error) + return null + } +} + +export async function persistMigratedBlocks( + workflowId: string, + originalBlocks: Record, + migratedBlocks: Record +): Promise { + try { + for (const [blockId, block] of Object.entries(migratedBlocks)) { + if (block !== originalBlocks[blockId]) { + await db + .update(workflowBlocks) + .set({ + subBlocks: block.subBlocks, + data: block.data, + updatedAt: new Date(), + }) + .where(eq(workflowBlocks.id, blockId)) + } + } + } catch (err) { + logger.warn('Failed to persist block migrations', { workflowId, error: err }) + } +} diff --git a/packages/workflow-persistence/src/save.ts b/packages/workflow-persistence/src/save.ts new file mode 100644 index 00000000000..fefe1d354d6 --- /dev/null +++ b/packages/workflow-persistence/src/save.ts @@ -0,0 +1,108 @@ +import { db, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { SUBFLOW_TYPES } from '@sim/workflow-types/workflow' +import type { InferInsertModel } from 'drizzle-orm' +import { eq } from 'drizzle-orm' +import { generateLoopBlocks, generateParallelBlocks } from './subflow-helpers' +import type { DbOrTx } from './types' + +const logger = createLogger('WorkflowPersistenceSave') + +type SubflowInsert = InferInsertModel + +export async function saveWorkflowToNormalizedTables( + workflowId: string, + state: WorkflowState, + externalTx?: DbOrTx +): Promise<{ success: boolean; error?: string }> { + const blockRecords = state.blocks as Record + const canonicalLoops = generateLoopBlocks(blockRecords) + const canonicalParallels = generateParallelBlocks(blockRecords) + + const execute = async (tx: DbOrTx) => { + await Promise.all([ + tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), + tx.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), + tx.delete(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), + ]) + + if (Object.keys(state.blocks).length > 0) { + const blockInserts = Object.values(state.blocks).map((block) => ({ + id: block.id, + workflowId, + type: block.type, + name: block.name || '', + positionX: String(block.position?.x || 0), + positionY: String(block.position?.y || 0), + enabled: block.enabled ?? true, + horizontalHandles: block.horizontalHandles ?? true, + advancedMode: block.advancedMode ?? false, + triggerMode: block.triggerMode ?? false, + height: String(block.height || 0), + subBlocks: block.subBlocks || {}, + outputs: block.outputs || {}, + data: block.data || {}, + parentId: block.data?.parentId || null, + extent: block.data?.extent || null, + locked: block.locked ?? false, + })) + + await tx.insert(workflowBlocks).values(blockInserts) + } + + if (state.edges.length > 0) { + const edgeInserts = state.edges.map((edge) => ({ + id: edge.id, + workflowId, + sourceBlockId: edge.source, + targetBlockId: edge.target, + sourceHandle: edge.sourceHandle || null, + targetHandle: edge.targetHandle || null, + })) + + await tx.insert(workflowEdges).values(edgeInserts) + } + + const subflowInserts: SubflowInsert[] = [] + + Object.values(canonicalLoops).forEach((loop) => { + subflowInserts.push({ + id: loop.id, + workflowId, + type: SUBFLOW_TYPES.LOOP, + config: loop, + }) + }) + + Object.values(canonicalParallels).forEach((parallel) => { + subflowInserts.push({ + id: parallel.id, + workflowId, + type: SUBFLOW_TYPES.PARALLEL, + config: parallel, + }) + }) + + if (subflowInserts.length > 0) { + await tx.insert(workflowSubflows).values(subflowInserts) + } + } + + if (externalTx) { + await execute(externalTx) + return { success: true } + } + + try { + await db.transaction(execute) + return { success: true } + } catch (error) { + logger.error(`Error saving workflow ${workflowId} to normalized tables:`, error) + return { + success: false, + error: toError(error).message, + } + } +} diff --git a/apps/sim/lib/workflows/subblocks.ts b/packages/workflow-persistence/src/subblocks.ts similarity index 96% rename from apps/sim/lib/workflows/subblocks.ts rename to packages/workflow-persistence/src/subblocks.ts index 6f5bdb92222..549fe7c303c 100644 --- a/apps/sim/lib/workflows/subblocks.ts +++ b/packages/workflow-persistence/src/subblocks.ts @@ -1,4 +1,4 @@ -import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types' +import type { BlockState, SubBlockState } from '@sim/workflow-types/workflow' export const DEFAULT_SUBBLOCK_TYPE = 'short-input' diff --git a/packages/workflow-persistence/src/subflow-helpers.ts b/packages/workflow-persistence/src/subflow-helpers.ts new file mode 100644 index 00000000000..b0f552f1977 --- /dev/null +++ b/packages/workflow-persistence/src/subflow-helpers.ts @@ -0,0 +1,94 @@ +import type { BlockState, Loop, Parallel } from '@sim/workflow-types/workflow' + +const DEFAULT_LOOP_ITERATIONS = 5 + +export function findChildNodes(containerId: string, blocks: Record): string[] { + return Object.values(blocks) + .filter((block) => block.data?.parentId === containerId) + .map((block) => block.id) +} + +export function convertLoopBlockToLoop( + loopBlockId: string, + blocks: Record +): Loop | undefined { + const loopBlock = blocks[loopBlockId] + if (!loopBlock || loopBlock.type !== 'loop') return undefined + + const loopType = loopBlock.data?.loopType || 'for' + + const loop: Loop = { + id: loopBlockId, + nodes: findChildNodes(loopBlockId, blocks), + iterations: loopBlock.data?.count || DEFAULT_LOOP_ITERATIONS, + loopType, + enabled: loopBlock.enabled, + } + + loop.forEachItems = loopBlock.data?.collection || '' + loop.whileCondition = loopBlock.data?.whileCondition || '' + loop.doWhileCondition = loopBlock.data?.doWhileCondition || '' + + return loop +} + +export function convertParallelBlockToParallel( + parallelBlockId: string, + blocks: Record +): Parallel | undefined { + const parallelBlock = blocks[parallelBlockId] + if (!parallelBlock || parallelBlock.type !== 'parallel') return undefined + + const parallelType = parallelBlock.data?.parallelType || 'count' + + const validParallelTypes = ['collection', 'count'] as const + const validatedParallelType = validParallelTypes.includes(parallelType as any) + ? parallelType + : 'collection' + + const distribution = + validatedParallelType === 'collection' ? parallelBlock.data?.collection || '' : undefined + + const count = parallelBlock.data?.count || 5 + + return { + id: parallelBlockId, + nodes: findChildNodes(parallelBlockId, blocks), + distribution, + count, + parallelType: validatedParallelType, + enabled: parallelBlock.enabled, + } +} + +export function generateLoopBlocks(blocks: Record): Record { + const loops: Record = {} + + Object.entries(blocks) + .filter(([_, block]) => block.type === 'loop') + .forEach(([id, block]) => { + const loop = convertLoopBlockToLoop(id, blocks) + if (loop) { + loops[id] = loop + } + }) + + return loops +} + +export function generateParallelBlocks( + blocks: Record +): Record { + const parallels: Record = {} + + Object.entries(blocks) + .filter(([_, block]) => block.type === 'parallel') + .forEach(([id, block]) => { + const parallel = convertParallelBlockToParallel(id, blocks) + if (parallel) { + parallels[id] = parallel + } + }) + + return parallels +} diff --git a/packages/workflow-persistence/src/types.ts b/packages/workflow-persistence/src/types.ts new file mode 100644 index 00000000000..4cbc4728582 --- /dev/null +++ b/packages/workflow-persistence/src/types.ts @@ -0,0 +1,23 @@ +import type { db } from '@sim/db' +import type * as schema from '@sim/db/schema' +import type { BlockState, Loop, Parallel } from '@sim/workflow-types/workflow' +import type { ExtractTablesWithRelations } from 'drizzle-orm' +import type { PgTransaction } from 'drizzle-orm/pg-core' +import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js' +import type { Edge } from 'reactflow' + +export type DbOrTx = + | typeof db + | PgTransaction< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + > + +export interface NormalizedWorkflowData { + blocks: Record + edges: Edge[] + loops: Record + parallels: Record + isFromNormalizedTables: boolean +} diff --git a/packages/workflow-persistence/tsconfig.json b/packages/workflow-persistence/tsconfig.json new file mode 100644 index 00000000000..1ffa3d2e844 --- /dev/null +++ b/packages/workflow-persistence/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/workflow-types/package.json b/packages/workflow-types/package.json new file mode 100644 index 00000000000..cc041b0caaf --- /dev/null +++ b/packages/workflow-types/package.json @@ -0,0 +1,37 @@ +{ + "name": "@sim/workflow-types", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + "./blocks": { + "types": "./src/blocks.ts", + "default": "./src/blocks.ts" + }, + "./workflow": { + "types": "./src/workflow.ts", + "default": "./src/workflow.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "peerDependencies": { + "reactflow": "^11.11.4" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "reactflow": "^11.11.4", + "typescript": "^5.7.3" + } +} diff --git a/packages/workflow-types/src/blocks.ts b/packages/workflow-types/src/blocks.ts new file mode 100644 index 00000000000..4374dbfd587 --- /dev/null +++ b/packages/workflow-types/src/blocks.ts @@ -0,0 +1,88 @@ +export type PrimitiveValueType = + | 'string' + | 'number' + | 'boolean' + | 'json' + | 'array' + | 'file' + | 'file[]' + | 'any' + +export type SubBlockType = + | 'short-input' + | 'long-input' + | 'dropdown' + | 'combobox' + | 'slider' + | 'table' + | 'code' + | 'switch' + | 'tool-input' + | 'skill-input' + | 'checkbox-list' + | 'grouped-checkbox-list' + | 'condition-input' + | 'eval-input' + | 'time-input' + | 'oauth-input' + | 'webhook-config' + | 'schedule-info' + | 'file-selector' + | 'sheet-selector' + | 'project-selector' + | 'channel-selector' + | 'user-selector' + | 'folder-selector' + | 'knowledge-base-selector' + | 'knowledge-tag-filters' + | 'document-selector' + | 'document-tag-entry' + | 'mcp-server-selector' + | 'mcp-tool-selector' + | 'mcp-dynamic-args' + | 'input-format' + | 'response-format' + | 'filter-builder' + | 'sort-builder' + | 'file-upload' + | 'input-mapping' + | 'variables-input' + | 'messages-input' + | 'workflow-selector' + | 'workflow-input-mapper' + | 'text' + | 'router-input' + | 'table-selector' + | 'modal' + +export interface OutputCondition { + field: string + value: string | number | boolean | Array + not?: boolean + and?: { + field: string + value: + | string + | number + | boolean + | Array + | undefined + | null + not?: boolean + } +} + +export type OutputFieldDefinition = + | PrimitiveValueType + | { + type: PrimitiveValueType + description?: string + condition?: OutputCondition + hiddenFromDisplay?: boolean + } + +export function isHiddenFromDisplay(def: unknown): boolean { + return Boolean( + def && typeof def === 'object' && 'hiddenFromDisplay' in def && def.hiddenFromDisplay + ) +} diff --git a/packages/workflow-types/src/workflow.ts b/packages/workflow-types/src/workflow.ts new file mode 100644 index 00000000000..fb3c35e51b5 --- /dev/null +++ b/packages/workflow-types/src/workflow.ts @@ -0,0 +1,165 @@ +import type { Edge } from 'reactflow' +import type { OutputFieldDefinition, SubBlockType } from './blocks' + +export const SUBFLOW_TYPES = { + LOOP: 'loop', + PARALLEL: 'parallel', +} as const + +export type SubflowType = (typeof SUBFLOW_TYPES)[keyof typeof SUBFLOW_TYPES] + +export function isValidSubflowType(type: string): type is SubflowType { + return Object.values(SUBFLOW_TYPES).includes(type as SubflowType) +} + +export interface LoopConfig { + nodes: string[] + iterations: number + loopType: 'for' | 'forEach' | 'while' | 'doWhile' + forEachItems?: unknown[] | Record | string + whileCondition?: string + doWhileCondition?: string +} + +export interface ParallelConfig { + nodes: string[] + distribution?: unknown[] | Record | string + parallelType?: 'count' | 'collection' +} + +export interface Subflow { + id: string + workflowId: string + type: SubflowType + config: LoopConfig | ParallelConfig + createdAt: Date + updatedAt: Date +} + +export interface Position { + x: number + y: number +} + +export interface BlockData { + parentId?: string + extent?: 'parent' + width?: number + height?: number + collection?: any + count?: number + loopType?: 'for' | 'forEach' | 'while' | 'doWhile' + whileCondition?: string + doWhileCondition?: string + parallelType?: 'collection' | 'count' + type?: string + canonicalModes?: Record +} + +export interface BlockLayoutState { + measuredWidth?: number + measuredHeight?: number +} + +export interface BlockState { + id: string + type: string + name: string + position: Position + subBlocks: Record + outputs: Record + enabled: boolean + horizontalHandles?: boolean + height?: number + advancedMode?: boolean + triggerMode?: boolean + data?: BlockData + layout?: BlockLayoutState + locked?: boolean +} + +export interface SubBlockState { + id: string + type: SubBlockType + value: string | number | string[][] | null +} + +export interface LoopBlock { + id: string + loopType: 'for' | 'forEach' + count: number + collection: string + width: number + height: number + executionState: { + isExecuting: boolean + startTime: null | number + endTime: null | number + } +} + +export interface ParallelBlock { + id: string + collection: string + width: number + height: number + executionState: { + currentExecution: number + isExecuting: boolean + startTime: null | number + endTime: null | number + } +} + +export interface Loop { + id: string + nodes: string[] + iterations: number + loopType: 'for' | 'forEach' | 'while' | 'doWhile' + forEachItems?: any[] | Record | string + whileCondition?: string + doWhileCondition?: string + enabled: boolean + locked?: boolean +} + +export interface Parallel { + id: string + nodes: string[] + distribution?: any[] | Record | string + count?: number + parallelType?: 'count' | 'collection' + enabled: boolean + locked?: boolean +} + +export interface Variable { + id: string + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + value: unknown +} + +export interface DragStartPosition { + id: string + x: number + y: number + parentId?: string | null +} + +export interface WorkflowState { + currentWorkflowId?: string | null + blocks: Record + edges: Edge[] + lastSaved?: number + loops: Record + parallels: Record + lastUpdate?: number + metadata?: { + name?: string + description?: string + exportedAt?: string + } + variables?: Record + dragStartPosition?: DragStartPosition | null +} diff --git a/packages/workflow-types/tsconfig.json b/packages/workflow-types/tsconfig.json new file mode 100644 index 00000000000..1ffa3d2e844 --- /dev/null +++ b/packages/workflow-types/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/scripts/check-monorepo-boundaries.ts b/scripts/check-monorepo-boundaries.ts new file mode 100644 index 00000000000..c3623ee6fdc --- /dev/null +++ b/scripts/check-monorepo-boundaries.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env bun +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dir, '..') +const PACKAGES_DIR = path.join(ROOT, 'packages') + +const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + { pattern: /from\s+['"]@\/(?!\*)/g, description: "'@/' path alias (apps/sim-only)" }, + { pattern: /from\s+['"]\.\.\/\.\.\/apps\//g, description: 'relative import into apps/' }, + { pattern: /from\s+['"]apps\//g, description: "bare 'apps/' import" }, +] + +const SKIP_DIRS = new Set(['node_modules', 'dist', '.next', '.turbo', 'coverage']) + +async function walk(dir: string, results: string[] = []): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(full, results) + } else if (/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(entry.name)) { + results.push(full) + } + } + return results +} + +async function main() { + const packagesEntries = await readdir(PACKAGES_DIR, { withFileTypes: true }) + const packageDirs = packagesEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(PACKAGES_DIR, entry.name)) + + const offenders: Array<{ file: string; line: number; description: string; snippet: string }> = [] + + for (const dir of packageDirs) { + const files = await walk(dir) + for (const file of files) { + const content = await readFile(file, 'utf8') + const lines = content.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + for (const { pattern, description } of FORBIDDEN_PATTERNS) { + pattern.lastIndex = 0 + if (pattern.test(line)) { + offenders.push({ + file: path.relative(ROOT, file), + line: i + 1, + description, + snippet: line.trim(), + }) + } + } + } + } + } + + if (offenders.length === 0) { + console.log('āœ… Monorepo boundaries OK: no package imports from apps/*') + return + } + + console.error('āŒ Monorepo boundary violations found:') + for (const offender of offenders) { + console.error( + ` ${offender.file}:${offender.line} — ${offender.description}\n ${offender.snippet}` + ) + } + process.exit(1) +} + +void main().catch((error) => { + console.error('Monorepo boundary check failed:', error) + process.exit(1) +}) diff --git a/scripts/check-realtime-prune-graph.ts b/scripts/check-realtime-prune-graph.ts new file mode 100644 index 00000000000..c12fc9b7642 --- /dev/null +++ b/scripts/check-realtime-prune-graph.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env bun +import { mkdtemp, readdir, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { $ } from 'bun' + +const MAX_PRUNED_PACKAGE_COUNT = 25 + +async function listPackages(root: string): Promise { + try { + const entries = await readdir(root) + const result: string[] = [] + for (const entry of entries) { + const full = path.join(root, entry) + const s = await stat(full) + if (s.isDirectory()) { + result.push(entry) + } + } + return result + } catch { + return [] + } +} + +async function main() { + const scratch = await mkdtemp(path.join(tmpdir(), 'sim-realtime-prune-')) + try { + console.log(`Pruning @sim/realtime into ${scratch}`) + await $`bunx turbo prune @sim/realtime --docker --out-dir=${scratch}`.quiet() + + const apps = await listPackages(path.join(scratch, 'json', 'apps')) + const packages = await listPackages(path.join(scratch, 'json', 'packages')) + const total = apps.length + packages.length + + console.log(`Pruned apps (${apps.length}): ${apps.join(', ') || '(none)'}`) + console.log(`Pruned packages (${packages.length}): ${packages.join(', ') || '(none)'}`) + console.log(`Total pruned workspaces: ${total}`) + + if (total > MAX_PRUNED_PACKAGE_COUNT) { + console.error( + `\nāŒ Pruned realtime dep graph has ${total} workspaces (limit: ${MAX_PRUNED_PACKAGE_COUNT}).` + ) + console.error( + 'A new package was pulled into @sim/realtime. Ensure only pure, single-purpose packages are in its dep graph.' + ) + process.exit(1) + } + + const unexpectedApps = apps.filter((name) => name !== 'realtime') + if (unexpectedApps.length > 0) { + console.error( + `\nāŒ Pruned realtime tree pulled in unexpected apps/: ${unexpectedApps.join(', ')}` + ) + process.exit(1) + } + + console.log('\nāœ… Realtime prune size within expected bounds') + } finally { + await rm(scratch, { recursive: true, force: true }) + } +} + +void main().catch((error) => { + console.error('Realtime prune check failed:', error) + process.exit(1) +})