Skip to content

Commit 553d1aa

Browse files
committed
refactor(realtime): one shared eviction path for revoked room access
The sweep and both per-frame gates each open-coded emit + leave + local-state cleanup. Route them all through evictSocketFromRoom so they cannot diverge on what eviction means; workflow keeps its historical access-revoked payload.
1 parent 498f38b commit 553d1aa

4 files changed

Lines changed: 55 additions & 62 deletions

File tree

apps/realtime/src/access-revalidation.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform-authz/room-policy'
3-
import {
4-
type AccessRevokedBroadcast,
5-
ROOM_ACCESS_REVOKED_EVENT,
6-
type RoomAccessRevokedBroadcast,
7-
} from '@sim/realtime-protocol/events'
3+
import type { AccessRevokedBroadcast } from '@sim/realtime-protocol/events'
84
import {
95
parseRoomName,
106
ROOM_TYPES,
@@ -13,7 +9,7 @@ import {
139
roomName,
1410
} from '@sim/realtime-protocol/rooms'
1511
import { sleep } from '@sim/utils/helpers'
16-
import { runRoomEvictionHandler } from '@/handlers/room-eviction'
12+
import { evictSocketFromRoom, runRoomEvictionHandler } from '@/handlers/room-eviction'
1713
import type { AuthenticatedSocket } from '@/middleware/auth'
1814
import { ROLE_REVALIDATION_TTL_MS, resolveCurrentRoomPermission } from '@/middleware/permissions'
1915
import type { IRoomManager } from '@/rooms'
@@ -266,24 +262,20 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
266262
// gates its document writes). Redis presence cleanup is only ENQUEUED here —
267263
// the cleanup lane performs that work, so eviction never blocks on it.
268264
if (room.type === ROOM_TYPES.WORKFLOW) {
265+
// Workflow keeps its historical wire event and payload shape, which existing
266+
// clients key off `workflowId`; every other type shares the generic path.
269267
const payload: AccessRevokedBroadcast = {
270268
workflowId: room.id,
271269
message: 'Your access to this workflow has been revoked',
272270
timestamp: Date.now(),
273271
}
274272
socket.emit('access-revoked', payload)
273+
socket.leave(name)
274+
runRoomEvictionHandler(socket.id, room, io)
275+
logger.info(`Revoked live access for user ${socket.userId} on ${name} (socket ${socket.id})`)
275276
} else {
276-
const payload: RoomAccessRevokedBroadcast = {
277-
room,
278-
message: 'Your access to this resource has been revoked',
279-
timestamp: Date.now(),
280-
}
281-
socket.emit(ROOM_ACCESS_REVOKED_EVENT, payload)
277+
evictSocketFromRoom(socket, room, 'Your access to this resource has been revoked', io)
282278
}
283-
socket.leave(name)
284-
runRoomEvictionHandler(socket.id, room, io)
285-
286-
logger.info(`Revoked live access for user ${socket.userId} on ${name} (socket ${socket.id})`)
287279

288280
if (PRESENCE_ROOM_TYPES.has(room.type)) {
289281
pendingCleanups.set(`${socket.id}:${name}`, { socketId: socket.id, room })

apps/realtime/src/handlers/file-doc.ts

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@
2525
*/
2626
import { createLogger } from '@sim/logger'
2727
import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform-authz/room-policy'
28-
import {
29-
ROOM_ACCESS_REVOKED_EVENT,
30-
type RoomAccessRevokedBroadcast,
31-
} from '@sim/realtime-protocol/events'
3228
import {
3329
FILE_DOC_EVENTS,
3430
FILE_DOC_MESSAGE_TYPE,
@@ -56,7 +52,7 @@ import {
5652
REDIS_ORIGIN,
5753
REDIS_SNAPSHOT_ORIGIN,
5854
} from '@/handlers/file-doc-store'
59-
import { registerRoomEvictionHandler } from '@/handlers/room-eviction'
55+
import { evictSocketFromRoom, registerRoomEvictionHandler } from '@/handlers/room-eviction'
6056
import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
6157
import type { AuthenticatedSocket } from '@/middleware/auth'
6258
import { peekRoomPermission, resolveCurrentRoomPermission } from '@/middleware/permissions'
@@ -851,29 +847,6 @@ function emitJoinError(
851847
*/
852848
const FILE_DOC_ACTION = ROOM_MEMBERSHIP_ACTIONS[ROOM_TYPES.WORKSPACE_FILE_DOC]
853849

854-
/**
855-
* Evicts a socket from its file-doc room: emit the revocation, leave the Socket.IO
856-
* room, and drop the pod-local binding + presence. Dropping `socketToRoomName` is
857-
* the load-bearing part — {@link handleMessage} gates every inbound frame on it, so
858-
* once it is gone the socket cannot apply another document update, even if it keeps
859-
* sending them.
860-
*/
861-
function evictFromFileDoc(
862-
socket: AuthenticatedSocket,
863-
io: Server,
864-
name: string,
865-
fileId: string
866-
): void {
867-
const payload: RoomAccessRevokedBroadcast = {
868-
room: fileDocRoom(fileId),
869-
message: 'Your access to this document has been revoked',
870-
timestamp: Date.now(),
871-
}
872-
socket.emit(ROOM_ACCESS_REVOKED_EVENT, payload)
873-
socket.leave(name)
874-
cleanupFileDocForSocket(socket.id, io)
875-
}
876-
877850
/**
878851
* Per-frame authorization for a socket's inbound document/awareness frames.
879852
*
@@ -909,7 +882,11 @@ function isFileDocWriteAllowed(socket: AuthenticatedSocket, io: Server, name: st
909882
logger.warn(
910883
`Dropping file-doc frame from user ${userId} whose access to file ${fileId} no longer permits writing`
911884
)
912-
evictFromFileDoc(socket, io, name, fileId)
885+
// Evicting (not just dropping the frame) is what makes this stick: the registered
886+
// eviction handler clears `socketToRoomName`, and every inbound frame is gated on
887+
// that binding — so the socket cannot apply another document update even if it
888+
// keeps sending them.
889+
evictSocketFromRoom(socket, room, 'Your access to this document has been revoked', io)
913890
return false
914891
}
915892

apps/realtime/src/handlers/room-eviction.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { createLogger } from '@sim/logger'
2-
import type { RoomRef, RoomType } from '@sim/realtime-protocol/rooms'
2+
import {
3+
ROOM_ACCESS_REVOKED_EVENT,
4+
type RoomAccessRevokedBroadcast,
5+
} from '@sim/realtime-protocol/events'
6+
import { type RoomRef, type RoomType, roomName } from '@sim/realtime-protocol/rooms'
37
import type { Server } from 'socket.io'
8+
import type { AuthenticatedSocket } from '@/middleware/auth'
49

510
const logger = createLogger('RoomEviction')
611

@@ -40,3 +45,32 @@ export function runRoomEvictionHandler(socketId: string, room: RoomRef, io: Serv
4045
logger.warn(`Room eviction cleanup failed for socket ${socketId} on ${room.type}`, error)
4146
}
4247
}
48+
49+
/**
50+
* Evicts one socket from one non-workflow room after a confirmed loss of access:
51+
* tell the client, stop it receiving room broadcasts, and drop the handler-local
52+
* state that would otherwise still accept its frames.
53+
*
54+
* The single eviction path shared by both enforcement points — the periodic
55+
* re-validation sweep and the per-frame gates that catch a revocation first — so
56+
* they cannot diverge on what "evicted" means. Everything here is synchronous and
57+
* pod-local; any Redis presence removal is the caller's own follow-up (the sweep
58+
* owns a retrying cleanup lane for it).
59+
*
60+
* Workflow rooms keep their own `access-revoked` wire event for client
61+
* compatibility and are deliberately not routed through here.
62+
*/
63+
export function evictSocketFromRoom(
64+
socket: AuthenticatedSocket,
65+
room: RoomRef,
66+
message: string,
67+
io: Server
68+
): void {
69+
const payload: RoomAccessRevokedBroadcast = { room, message, timestamp: Date.now() }
70+
socket.emit(ROOM_ACCESS_REVOKED_EVENT, payload)
71+
socket.leave(roomName(room))
72+
runRoomEvictionHandler(socket.id, room, io)
73+
logger.info(
74+
`Revoked live access for user ${socket.userId} on ${roomName(room)} (socket ${socket.id})`
75+
)
76+
}

apps/realtime/src/handlers/tables.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import { createLogger } from '@sim/logger'
22
import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform-authz/room-policy'
3-
import {
4-
ROOM_ACCESS_REVOKED_EVENT,
5-
type RoomAccessRevokedBroadcast,
6-
} from '@sim/realtime-protocol/events'
73
import { ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms'
84
import {
95
type JoinTablePayload,
@@ -12,6 +8,7 @@ import {
128
type TableCellSelection,
139
} from '@sim/realtime-protocol/table-presence'
1410
import { resolveAvatarUrl } from '@/handlers/avatar'
11+
import { evictSocketFromRoom } from '@/handlers/room-eviction'
1512
import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
1613
import type { AuthenticatedSocket } from '@/middleware/auth'
1714
import { peekRoomPermission, resolveCurrentRoomPermission } from '@/middleware/permissions'
@@ -92,24 +89,17 @@ function normalizeCellSelection(cell: unknown): TableCellSelection | undefined {
9289
}
9390

9491
/**
95-
* Evicts a socket from a table room after a confirmed loss of access: emit the
96-
* revocation, leave the Socket.IO room, and drop its presence so peers stop seeing
97-
* its selection. Best-effort on the presence half — the socket has already left the
98-
* room, and the sweep's cleanup lane retries any removal that fails here.
92+
* Evicts a socket from a table room after a confirmed loss of access, then drops
93+
* its presence so peers stop seeing its selection. The presence half is
94+
* best-effort — the socket has already left the room, and the sweep's cleanup lane
95+
* retries any removal that fails here.
9996
*/
10097
async function evictFromTable(
10198
socket: AuthenticatedSocket,
10299
roomManager: IRoomManager,
103100
room: RoomRef
104101
): Promise<void> {
105-
const payload: RoomAccessRevokedBroadcast = {
106-
room,
107-
message: 'Your access to this table has been revoked',
108-
timestamp: Date.now(),
109-
}
110-
socket.emit(ROOM_ACCESS_REVOKED_EVENT, payload)
111-
socket.leave(roomName(room))
112-
logger.warn(`Evicted user ${socket.userId} from table room ${room.id}: access revoked`)
102+
evictSocketFromRoom(socket, room, 'Your access to this table has been revoked', roomManager.io)
113103
try {
114104
await roomManager.removeUserFromRoom(room, socket.id)
115105
await roomManager.broadcastPresenceUpdate(room, socket.id)

0 commit comments

Comments
 (0)