Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .claude/rules/sim-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` |
Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/sim-testing.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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)` |
Expand Down
14 changes: 13 additions & 1 deletion .devcontainer/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,26 @@ 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
echo "DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio" > apps/sim/.env
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..."
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
45 changes: 34 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
30 changes: 30 additions & 0 deletions apps/realtime/.env.example
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions apps/realtime/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
17 changes: 17 additions & 0 deletions apps/realtime/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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,
})
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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')

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions apps/realtime/src/env.ts
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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')

Expand Down
17 changes: 17 additions & 0 deletions apps/realtime/src/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading