Skip to content

Commit 7ea0c43

Browse files
Merge remote-tracking branch 'origin/main' into feat/atlassian-service-account
# Conflicts: # scripts/check-api-validation-contracts.ts
2 parents 9b143fa + 5be12f8 commit 7ea0c43

320 files changed

Lines changed: 49651 additions & 5668 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,15 @@ jobs:
9090
9191
echo "✅ All feature flags are properly configured"
9292
93-
- name: Check subblock ID stability
93+
- name: Check block registry invariants
9494
run: |
9595
if [ "${{ github.event_name }}" = "pull_request" ]; then
9696
BASE_REF="origin/${{ github.base_ref }}"
9797
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
9898
else
9999
BASE_REF="HEAD~1"
100100
fi
101-
bun run apps/sim/scripts/check-subblock-id-stability.ts "$BASE_REF"
101+
bun run apps/sim/scripts/check-block-registry.ts "$BASE_REF"
102102
103103
- name: Lint code
104104
run: bun run lint:check

apps/docs/content/docs/en/tools/stt.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ Transcribe audio and video files to text using leading AI providers. Supports mu
120120
| --------- | ---- | -------- | ----------- |
121121
| `provider` | string | Yes | STT provider \(elevenlabs\) |
122122
| `apiKey` | string | Yes | ElevenLabs API key |
123-
| `model` | string | No | ElevenLabs model to use \(scribe_v1, scribe_v1_experimental\) |
123+
| `model` | string | No | ElevenLabs model to use \(scribe_v2\) |
124124
| `audioFile` | file | No | Audio or video file to transcribe \(e.g., MP3, WAV, M4A, WEBM\) |
125125
| `audioFileReference` | file | No | Reference to audio/video file from previous blocks |
126126
| `audioUrl` | string | No | URL to audio or video file |

apps/realtime/src/handlers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { setupConnectionHandlers } from '@/handlers/connection'
22
import { setupOperationsHandlers } from '@/handlers/operations'
33
import { setupPresenceHandlers } from '@/handlers/presence'
44
import { setupSubblocksHandlers } from '@/handlers/subblocks'
5+
import { setupTableHandlers } from '@/handlers/tables'
56
import { setupVariablesHandlers } from '@/handlers/variables'
67
import { setupWorkflowHandlers } from '@/handlers/workflow'
78
import type { AuthenticatedSocket } from '@/middleware/auth'
@@ -13,5 +14,6 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom
1314
setupSubblocksHandlers(socket, roomManager)
1415
setupVariablesHandlers(socket, roomManager)
1516
setupPresenceHandlers(socket, roomManager)
17+
setupTableHandlers(socket, roomManager)
1618
setupConnectionHandlers(socket, roomManager)
1719
}

apps/realtime/src/handlers/operations.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@sim/realtime-protocol/constants'
1111
import { WorkflowOperationSchema } from '@sim/realtime-protocol/schemas'
1212
import { generateId } from '@sim/utils/id'
13+
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
1314
import { ZodError } from 'zod'
1415
import { persistWorkflowOperation } from '@/database/operations'
1516
import type { AuthenticatedSocket } from '@/middleware/auth'
@@ -139,6 +140,24 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager
139140
}
140141
}
141142

143+
try {
144+
await assertWorkflowMutable(workflowId)
145+
} catch (error) {
146+
if (error instanceof WorkflowLockedError) {
147+
emitOperationError(
148+
{
149+
type: 'WORKFLOW_LOCKED',
150+
message: error.message,
151+
operation,
152+
target,
153+
},
154+
{ error: error.message, retryable: false }
155+
)
156+
return
157+
}
158+
throw error
159+
}
160+
142161
// Broadcast first for position updates to minimize latency, then persist
143162
// For other operations, persist first for consistency
144163
if (isPositionUpdate) {

apps/realtime/src/handlers/subblocks.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { workflow, workflowBlocks } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
5+
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
56
import { and, eq } from 'drizzle-orm'
67
import type { AuthenticatedSocket } from '@/middleware/auth'
78
import { checkRolePermission } from '@/middleware/permissions'
@@ -151,6 +152,28 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
151152
return
152153
}
153154

155+
try {
156+
await assertWorkflowMutable(workflowId)
157+
} catch (error) {
158+
if (error instanceof WorkflowLockedError) {
159+
socket.emit('operation-forbidden', {
160+
type: 'WORKFLOW_LOCKED',
161+
message: error.message,
162+
operation: SUBBLOCK_OPERATIONS.UPDATE,
163+
target: 'subblock',
164+
})
165+
if (operationId) {
166+
socket.emit('operation-failed', {
167+
operationId,
168+
error: error.message,
169+
retryable: false,
170+
})
171+
}
172+
return
173+
}
174+
throw error
175+
}
176+
154177
// Update user activity
155178
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
156179

@@ -231,6 +254,22 @@ async function flushSubblockUpdate(
231254
return
232255
}
233256

257+
try {
258+
await assertWorkflowMutable(workflowId)
259+
} catch (error) {
260+
if (error instanceof WorkflowLockedError) {
261+
pending.opToSocket.forEach((socketId, opId) => {
262+
io.to(socketId).emit('operation-failed', {
263+
operationId: opId,
264+
error: error.message,
265+
retryable: false,
266+
})
267+
})
268+
return
269+
}
270+
throw error
271+
}
272+
234273
let updateSuccessful = false
235274
let blockLocked = false
236275
await db.transaction(async (tx) => {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { AuthenticatedSocket } from '@/middleware/auth'
3+
import { verifyTableAccess } from '@/middleware/permissions'
4+
import { type IRoomManager, tableRoomName } from '@/rooms/types'
5+
6+
const logger = createLogger('TableHandlers')
7+
8+
/**
9+
* Wires `join-table` / `leave-table` socket events. Tables don't track presence
10+
* or last-modified state — joining is a thin wrapper around `socket.join` so the
11+
* Sim API → Realtime HTTP bridge can broadcast row updates back to subscribed clients.
12+
*/
13+
export function setupTableHandlers(socket: AuthenticatedSocket, _roomManager: IRoomManager) {
14+
socket.on('join-table', async ({ tableId }: { tableId?: string }) => {
15+
try {
16+
if (!tableId || typeof tableId !== 'string') {
17+
socket.emit('join-table-error', {
18+
tableId: tableId ?? null,
19+
error: 'tableId required',
20+
code: 'INVALID_TABLE_ID',
21+
retryable: false,
22+
})
23+
return
24+
}
25+
26+
const userId = socket.userId
27+
if (!userId) {
28+
socket.emit('join-table-error', {
29+
tableId,
30+
error: 'Authentication required',
31+
code: 'AUTHENTICATION_REQUIRED',
32+
retryable: false,
33+
})
34+
return
35+
}
36+
37+
const { hasAccess } = await verifyTableAccess(userId, tableId)
38+
if (!hasAccess) {
39+
socket.emit('join-table-error', {
40+
tableId,
41+
error: 'Access denied to table',
42+
code: 'ACCESS_DENIED',
43+
retryable: false,
44+
})
45+
return
46+
}
47+
48+
const room = tableRoomName(tableId)
49+
socket.join(room)
50+
socket.emit('join-table-success', { tableId, socketId: socket.id })
51+
logger.debug(`Socket ${socket.id} (user ${userId}) joined ${room}`)
52+
} catch (error) {
53+
logger.error(`Error joining table room:`, error)
54+
socket.emit('join-table-error', {
55+
tableId: null,
56+
error: 'Failed to join table',
57+
code: 'JOIN_TABLE_FAILED',
58+
retryable: true,
59+
})
60+
}
61+
})
62+
63+
socket.on('leave-table', async ({ tableId }: { tableId?: string }) => {
64+
try {
65+
if (!tableId || typeof tableId !== 'string') return
66+
const room = tableRoomName(tableId)
67+
socket.leave(room)
68+
logger.debug(`Socket ${socket.id} left ${room}`)
69+
} catch (error) {
70+
logger.error(`Error leaving table room:`, error)
71+
}
72+
})
73+
}

apps/realtime/src/handlers/variables.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { workflow } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { VARIABLE_OPERATIONS } from '@sim/realtime-protocol/constants'
5+
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
56
import { eq } from 'drizzle-orm'
67
import type { AuthenticatedSocket } from '@/middleware/auth'
78
import { checkRolePermission } from '@/middleware/permissions'
@@ -140,6 +141,28 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
140141
return
141142
}
142143

144+
try {
145+
await assertWorkflowMutable(workflowId)
146+
} catch (error) {
147+
if (error instanceof WorkflowLockedError) {
148+
socket.emit('operation-forbidden', {
149+
type: 'WORKFLOW_LOCKED',
150+
message: error.message,
151+
operation: VARIABLE_OPERATIONS.UPDATE,
152+
target: 'variable',
153+
})
154+
if (operationId) {
155+
socket.emit('operation-failed', {
156+
operationId,
157+
error: error.message,
158+
retryable: false,
159+
})
160+
}
161+
return
162+
}
163+
throw error
164+
}
165+
143166
// Update user activity
144167
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
145168

@@ -218,6 +241,22 @@ async function flushVariableUpdate(
218241
return
219242
}
220243

244+
try {
245+
await assertWorkflowMutable(workflowId)
246+
} catch (error) {
247+
if (error instanceof WorkflowLockedError) {
248+
pending.opToSocket.forEach((socketId, opId) => {
249+
io.to(socketId).emit('operation-failed', {
250+
operationId: opId,
251+
error: error.message,
252+
retryable: false,
253+
})
254+
})
255+
return
256+
}
257+
throw error
258+
}
259+
221260
let updateSuccessful = false
222261
await db.transaction(async (tx) => {
223262
const [workflowRecord] = await tx

apps/realtime/src/middleware/permissions.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,51 @@ export async function verifyWorkflowAccess(
131131
return { hasAccess: false }
132132
}
133133
}
134+
135+
/**
136+
* Verify a user has read access to a table by virtue of workspace permission.
137+
* Mirrors `verifyWorkflowAccess` for the table-room socket join check.
138+
*/
139+
export async function verifyTableAccess(
140+
userId: string,
141+
tableId: string
142+
): Promise<{ hasAccess: boolean; workspaceId?: string }> {
143+
try {
144+
const { userTableDefinitions, permissions } = await import('@sim/db')
145+
const tableData = await db
146+
.select({ workspaceId: userTableDefinitions.workspaceId })
147+
.from(userTableDefinitions)
148+
.where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt)))
149+
.limit(1)
150+
151+
if (!tableData.length) {
152+
logger.warn(`Table ${tableId} not found`)
153+
return { hasAccess: false }
154+
}
155+
const { workspaceId } = tableData[0]
156+
if (!workspaceId) return { hasAccess: false }
157+
158+
const [permissionRow] = await db
159+
.select({ permissionType: permissions.permissionType })
160+
.from(permissions)
161+
.where(
162+
and(
163+
eq(permissions.userId, userId),
164+
eq(permissions.entityType, 'workspace'),
165+
eq(permissions.entityId, workspaceId)
166+
)
167+
)
168+
.limit(1)
169+
170+
if (!permissionRow?.permissionType) {
171+
logger.warn(
172+
`User ${userId} has no permission for workspace ${workspaceId} (table ${tableId})`
173+
)
174+
return { hasAccess: false }
175+
}
176+
return { hasAccess: true, workspaceId }
177+
} catch (error) {
178+
logger.error(`Error verifying table access for user ${userId}, table ${tableId}:`, error)
179+
return { hasAccess: false }
180+
}
181+
}

apps/realtime/src/rooms/memory-manager.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { createLogger } from '@sim/logger'
22
import type { Server } from 'socket.io'
3-
import type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types'
3+
import {
4+
type IRoomManager,
5+
type TableRowUpdatedPayload,
6+
tableRoomName,
7+
type UserPresence,
8+
type UserSession,
9+
type WorkflowRoom,
10+
} from '@/rooms/types'
411

512
const logger = createLogger('MemoryRoomManager')
613

@@ -255,4 +262,23 @@ export class MemoryRoomManager implements IRoomManager {
255262

256263
logger.info(`Notified ${room.users.size} users about workflow deployment change: ${workflowId}`)
257264
}
265+
266+
emitToTable<T = unknown>(tableId: string, event: string, payload: T): void {
267+
this._io.to(tableRoomName(tableId)).emit(event, payload)
268+
}
269+
270+
async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise<void> {
271+
this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload })
272+
}
273+
274+
async handleTableRowDeleted(tableId: string, rowId: string): Promise<void> {
275+
this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId })
276+
}
277+
278+
async handleTableDeleted(tableId: string): Promise<void> {
279+
logger.info(`Handling table deletion notification for ${tableId}`)
280+
this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() })
281+
// Eject sockets so they don't hold a stale room. Cross-pod safe via socket.io.
282+
await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId))
283+
}
258284
}

apps/realtime/src/rooms/redis-manager.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { createLogger } from '@sim/logger'
22
import { createClient, type RedisClientType } from 'redis'
33
import type { Server } from 'socket.io'
4-
import type { IRoomManager, UserPresence, UserSession } from '@/rooms/types'
4+
import {
5+
type IRoomManager,
6+
type TableRowUpdatedPayload,
7+
tableRoomName,
8+
type UserPresence,
9+
type UserSession,
10+
} from '@/rooms/types'
511

612
const logger = createLogger('RedisRoomManager')
713

@@ -457,4 +463,23 @@ export class RedisRoomManager implements IRoomManager {
457463
const userCount = await this.getUniqueUserCount(workflowId)
458464
logger.info(`Notified ${userCount} users about workflow deployment change: ${workflowId}`)
459465
}
466+
467+
emitToTable<T = unknown>(tableId: string, event: string, payload: T): void {
468+
this._io.to(tableRoomName(tableId)).emit(event, payload)
469+
}
470+
471+
async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise<void> {
472+
this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload })
473+
}
474+
475+
async handleTableRowDeleted(tableId: string, rowId: string): Promise<void> {
476+
this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId })
477+
}
478+
479+
async handleTableDeleted(tableId: string): Promise<void> {
480+
logger.info(`Handling table deletion notification for ${tableId}`)
481+
this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() })
482+
// Eject sockets across all pods via socket.io's Redis adapter.
483+
await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId))
484+
}
460485
}

0 commit comments

Comments
 (0)