Skip to content

Commit 8b6f795

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/secrets-sanitization-trace-spans
2 parents 254945f + 10bfb5d commit 8b6f795

164 files changed

Lines changed: 33786 additions & 2146 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.

apps/realtime/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@
3333
"@sim/workflow-types": "workspace:*",
3434
"@socket.io/redis-adapter": "8.3.0",
3535
"drizzle-orm": "^0.45.2",
36+
"lib0": "0.2.117",
3637
"postgres": "^3.4.5",
3738
"redis": "5.10.0",
3839
"socket.io": "^4.8.1",
40+
"y-protocols": "1.0.7",
41+
"yjs": "13.6.31",
3942
"zod": "4.3.6"
4043
},
4144
"devDependencies": {

apps/realtime/src/access-revalidation.test.ts

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,14 @@ function makeManager(sockets: FakeSocket[], presence: Partial<UserPresence>[] =
5050
const manager = {
5151
io: { sockets: { sockets: socketMap } },
5252
isReady: () => true,
53-
getWorkflowUsers: vi.fn().mockResolvedValue(presence),
54-
getWorkflowIdForSocket: vi.fn().mockResolvedValue(null),
55-
removeUserFromRoom: vi
56-
.fn()
57-
.mockImplementation(async (_socketId: string, workflowId?: string) => workflowId ?? null),
53+
getRoomUsers: vi.fn().mockResolvedValue(presence),
54+
getRoomForSocket: vi.fn().mockResolvedValue(null),
55+
removeUserFromRoom: vi.fn().mockResolvedValue(true),
5856
broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined),
5957
}
6058
return manager as unknown as IRoomManager & {
61-
getWorkflowUsers: ReturnType<typeof vi.fn>
62-
getWorkflowIdForSocket: ReturnType<typeof vi.fn>
59+
getRoomUsers: ReturnType<typeof vi.fn>
60+
getRoomForSocket: ReturnType<typeof vi.fn>
6361
removeUserFromRoom: ReturnType<typeof vi.fn>
6462
broadcastPresenceUpdate: ReturnType<typeof vi.fn>
6563
}
@@ -84,8 +82,11 @@ describe('access-revalidation sweep', () => {
8482
expect.objectContaining({ workflowId: 'wf-1' })
8583
)
8684
expect(socket.leave).toHaveBeenCalledWith('wf-1')
87-
expect(manager.removeUserFromRoom).toHaveBeenCalledWith('sock-1', 'wf-1')
88-
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1')
85+
expect(manager.removeUserFromRoom).toHaveBeenCalledWith(
86+
{ type: 'workflow', id: 'wf-1' },
87+
'sock-1'
88+
)
89+
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith({ type: 'workflow', id: 'wf-1' })
8990
})
9091

9192
it('keeps a socket whose access is still valid', async () => {
@@ -141,7 +142,30 @@ describe('access-revalidation sweep', () => {
141142

142143
expect(mockResolveRole).toHaveBeenCalledWith('user-1', 'wf-1', 'read')
143144
// The security scan must stay Redis-free — presence is never consulted.
144-
expect(manager.getWorkflowUsers).not.toHaveBeenCalled()
145+
expect(manager.getRoomUsers).not.toHaveBeenCalled()
146+
})
147+
148+
it('never evicts a socket joined only to a non-workflow room (files/tables/file-doc)', async () => {
149+
// The sweep shares one io with the files/tables/file-doc handlers. Those rooms are
150+
// namespaced (`workspace-files:ws-1`, `table:t-1`), so treating every socket.rooms
151+
// entry as a workflow id would resolve a bogus permission → null → evict the socket
152+
// from its files/table room every pass. Non-workflow rooms must be filtered out.
153+
const filesSocket = makeSocket('sock-1', 'user-1', 'workspace-files:ws-1')
154+
const tableSocket = makeSocket('sock-2', 'user-2', 'table:t-1')
155+
const manager = makeManager([filesSocket, tableSocket])
156+
// Even if the role resolver would say "no access", these must never be swept.
157+
mockResolveRole.mockResolvedValue(null)
158+
159+
const sweep = startAccessRevalidationSweep(manager)
160+
await sweep.runOnce()
161+
sweep.stop()
162+
163+
expect(mockResolveRole).not.toHaveBeenCalled()
164+
expect(filesSocket.leave).not.toHaveBeenCalled()
165+
expect(filesSocket.emit).not.toHaveBeenCalled()
166+
expect(tableSocket.leave).not.toHaveBeenCalled()
167+
expect(tableSocket.emit).not.toHaveBeenCalled()
168+
expect(manager.removeUserFromRoom).not.toHaveBeenCalled()
145169
})
146170

147171
it('evicts only the revoked socket, not co-members of the room', async () => {
@@ -199,37 +223,37 @@ describe('access-revalidation sweep', () => {
199223
sweep.stop()
200224

201225
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2)
202-
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1')
226+
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith({ type: 'workflow', id: 'wf-1' })
203227
})
204228

205-
it('defers cleanup when removal fails with expired socket mappings', async () => {
229+
it('drops eviction cleanup when the socket is no longer mapped to the room (no infinite retry)', async () => {
206230
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
207231
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
208-
// Mapping keys already expired (lookup resolves null) AND the removal fails
209-
// (the Redis manager swallows the transport error into null) — the failed
210-
// removal must still defer instead of reading as success.
211-
manager.removeUserFromRoom.mockResolvedValueOnce(null)
232+
// A healthy lookup shows the socket is no longer mapped to any workflow room (its presence
233+
// is already gone), and removeUserFromRoom reports a no-op `false`. This is "already clean",
234+
// not a deferrable failure — the cleanup must drop it, never re-enqueue a still-connected
235+
// socket forever. (A genuine failure — still mapped + false — is covered by the next test.)
236+
manager.getRoomForSocket.mockResolvedValue(null)
237+
manager.removeUserFromRoom.mockResolvedValue(false)
212238
mockResolveRole.mockResolvedValue(null)
213239

214240
const sweep = startAccessRevalidationSweep(manager)
215241
await sweep.runOnce()
216-
217-
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
218-
219242
await sweep.runOnce()
220243
sweep.stop()
221244

222-
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2)
223-
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1')
245+
// Attempted once, then dropped — not re-enqueued across passes, and no broadcast.
246+
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(1)
247+
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
224248
})
225249

226250
it('defers cleanup when the manager swallows a removal failure into null', async () => {
227251
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
228252
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
229253
// Live mapping but the removal reports nothing removed — the Redis manager
230254
// swallows transport errors into null, so this is the only failure signal.
231-
manager.getWorkflowIdForSocket.mockResolvedValue('wf-1')
232-
manager.removeUserFromRoom.mockResolvedValueOnce(null)
255+
manager.getRoomForSocket.mockResolvedValue({ type: 'workflow', id: 'wf-1' })
256+
manager.removeUserFromRoom.mockResolvedValueOnce(false)
233257
mockResolveRole.mockResolvedValue(null)
234258

235259
const sweep = startAccessRevalidationSweep(manager)
@@ -243,15 +267,15 @@ describe('access-revalidation sweep', () => {
243267
sweep.stop()
244268

245269
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2)
246-
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1')
270+
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith({ type: 'workflow', id: 'wf-1' })
247271
})
248272

249273
it('skips removal when the socket has since moved to a different workflow', async () => {
250274
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
251275
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
252276
// Between the membership snapshot and cleanup, the socket switched to a
253277
// workflow it can still access — removal must not touch its new presence.
254-
manager.getWorkflowIdForSocket.mockResolvedValue('wf-2')
278+
manager.getRoomForSocket.mockResolvedValue({ type: 'workflow', id: 'wf-2' })
255279
mockResolveRole.mockResolvedValue(null)
256280

257281
const sweep = startAccessRevalidationSweep(manager)
@@ -357,7 +381,7 @@ describe('access-revalidation sweep', () => {
357381
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
358382
// A Redis outage where commands hang in the offline queue instead of
359383
// failing: the cleanup lane stalls, but scans must keep running.
360-
manager.getWorkflowIdForSocket.mockReturnValue(new Promise(() => {}))
384+
manager.getRoomForSocket.mockReturnValue(new Promise(() => {}))
361385
mockResolveRole.mockResolvedValue(null)
362386

363387
const sweep = startAccessRevalidationSweep(manager)

apps/realtime/src/access-revalidation.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { createLogger } from '@sim/logger'
22
import type { AccessRevokedBroadcast } from '@sim/realtime-protocol/events'
3+
import { parseRoomName, ROOM_TYPES } from '@sim/realtime-protocol/rooms'
34
import { sleep } from '@sim/utils/helpers'
45
import type { AuthenticatedSocket } from '@/middleware/auth'
56
import { ROLE_REVALIDATION_TTL_MS, resolveCurrentWorkflowRole } from '@/middleware/permissions'
6-
import type { IRoomManager } from '@/rooms'
7+
import { type IRoomManager, workflowRoom as wf } from '@/rooms'
78

89
const logger = createLogger('AccessRevalidation')
910

@@ -65,9 +66,14 @@ interface ScanTarget {
6566
* Collects this pod's authenticated sockets with the workflow room each has
6667
* joined, in stable socket order.
6768
*
68-
* The workflow room is derived from the socket's own `rooms` set (pod-local, no
69-
* Redis round-trips): a socket joins exactly one workflow room, so its rooms are
70-
* `{ ownSocketId, workflowId }`. Only local sockets are evaluated — sockets are
69+
* Rooms are derived from the socket's own `rooms` set (pod-local, no Redis
70+
* round-trips). A socket may occupy several rooms of different types at once
71+
* (workflow canvas, workspace-files browser, table, file-doc), all on the same
72+
* io — so each name is decoded with {@link parseRoomName} and only **workflow**
73+
* rooms are swept here. Non-workflow room names are namespaced (`type:id`) and
74+
* resolve to a non-workflow type; sweeping them as workflow ids would resolve a
75+
* bogus permission, come back `null`, and spuriously evict the socket from its
76+
* files/table room every pass. Only local sockets are evaluated — sockets are
7177
* sticky to a pod, so every socket is swept by exactly one pod using that pod's
7278
* warm role cache (mirroring the per-pod reasoning of the write-path cache).
7379
*/
@@ -78,7 +84,9 @@ function collectScanTargets(io: IRoomManager['io']): ScanTarget[] {
7884
if (!authed.userId) continue
7985
for (const room of socket.rooms) {
8086
if (room === socket.id) continue
81-
targets.push({ workflowId: room, socket: authed, userId: authed.userId })
87+
const ref = parseRoomName(room)
88+
if (ref?.type !== ROOM_TYPES.WORKFLOW) continue
89+
targets.push({ workflowId: ref.id, socket: authed, userId: authed.userId })
8290
}
8391
}
8492
return targets
@@ -129,9 +137,20 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
129137
async function cleanupEvictedSocket(socketId: string, workflowId: string): Promise<void> {
130138
const key = `${socketId}:${workflowId}`
131139
try {
140+
// A fully-disconnected socket already had its presence removed by the
141+
// disconnect handler (removeSocketFromAllRooms), so there is nothing left to
142+
// clean. Dropping here also keeps the boolean removeUserFromRoom below from
143+
// reporting a false "not a member" for an already-gone entry and retrying it
144+
// forever (the pre-generalization manager returned the target on a no-op).
145+
if (!io.sockets.sockets.get(socketId)) {
146+
pendingCleanups.delete(key)
147+
return
148+
}
149+
132150
// Unlike removeUserFromRoom, this read does not swallow transport errors,
133151
// so a Redis outage lands in the catch below and defers the cleanup.
134-
const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socketId)
152+
const currentRoom = await roomManager.getRoomForSocket(socketId, ROOM_TYPES.WORKFLOW)
153+
const currentWorkflowId = currentRoom?.id ?? null
135154
if (currentWorkflowId !== null && currentWorkflowId !== workflowId) {
136155
// The socket has since moved to a different workflow it can still
137156
// access; that join's room switch already removed this room's presence
@@ -148,16 +167,26 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
148167
return
149168
}
150169

151-
const removed = await roomManager.removeUserFromRoom(socketId, workflowId)
152-
if (removed === null) {
153-
// The sweep always passes the target room, and both managers report a
154-
// performed removal by returning it — the Redis manager swallows
155-
// transport errors into null, so null means the removal did not happen
156-
// (even when the socket's mapping keys have already expired).
157-
throw new Error('room-state removal not confirmed')
170+
// A null mapping here is the normal case (the socket's mapping key may have
171+
// expired) and does NOT mean "skip" — the eviction still removes the presence
172+
// entry from the known target room via the explicit ref below.
173+
const removed = await roomManager.removeUserFromRoom(wf(workflowId), socketId)
174+
if (!removed) {
175+
// `false` conflates two outcomes: the entry was already gone (a no-op), or a
176+
// transport error the manager swallowed. Only retry when the socket is still mapped
177+
// to THIS room — then a false result is a genuine, deferrable failure. When a healthy
178+
// getRoomForSocket above returned no workflow mapping (`currentWorkflowId === null`),
179+
// the presence entry is already gone, so the cleanup is complete: dropping it avoids
180+
// re-enqueuing a still-connected socket forever. (A real Redis outage throws at
181+
// getRoomForSocket and is deferred by the outer catch, never reaching here.)
182+
if (currentWorkflowId === workflowId) {
183+
throw new Error('room-state removal not confirmed')
184+
}
185+
pendingCleanups.delete(key)
186+
return
158187
}
159188

160-
await roomManager.broadcastPresenceUpdate(workflowId)
189+
await roomManager.broadcastPresenceUpdate(wf(workflowId))
161190
pendingCleanups.delete(key)
162191
} catch (error) {
163192
pendingCleanups.set(key, { socketId, workflowId })
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { db, user } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { eq } from 'drizzle-orm'
4+
import type { AuthenticatedSocket } from '@/middleware/auth'
5+
6+
const logger = createLogger('PresenceAvatar')
7+
8+
/**
9+
* The avatar URL for a presence entry: the socket's authenticated image when
10+
* present, otherwise a single lookup of the user's stored image. Never throws —
11+
* presence must not fail on an avatar lookup, so a DB error resolves to `null`.
12+
*/
13+
export async function resolveAvatarUrl(
14+
socket: AuthenticatedSocket,
15+
userId: string
16+
): Promise<string | null> {
17+
if (socket.userImage) return socket.userImage
18+
try {
19+
const [record] = await db
20+
.select({ image: user.image })
21+
.from(user)
22+
.where(eq(user.id, userId))
23+
.limit(1)
24+
return record?.image ?? null
25+
} catch (error) {
26+
logger.warn('Failed to load user avatar for presence', { userId, error })
27+
return null
28+
}
29+
}

0 commit comments

Comments
 (0)