Skip to content

Commit 3d651ec

Browse files
committed
fix(files): authorize joins before subscribing to document frames
1 parent 404986c commit 3d651ec

4 files changed

Lines changed: 114 additions & 22 deletions

File tree

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

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ vi.mock('@/handlers/file-doc-app', () => ({
3838
import {
3939
applyMarkdownToLiveFileDoc,
4040
cleanupFileDocForSocket,
41+
fileDocAdmissionRoom,
4142
flushAllFileDocRooms,
4243
invalidateLiveFileDocument,
4344
setupWorkspaceFileDocHandlers,
@@ -1006,6 +1007,76 @@ describe('setupWorkspaceFileDocHandlers', () => {
10061007
}
10071008
)
10081009

1010+
it.each(['write', 'read', null] as const)(
1011+
'withholds document and presence broadcasts until final authorization resolves to %s',
1012+
async (permission) => {
1013+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Private', 'doc-private'))
1014+
const memberships = new Set<string>()
1015+
let pending: ReturnType<typeof setup>
1016+
const { io } = createIo(({ target, event, payload }) => {
1017+
if (memberships.has(target)) pending.socket.emit(event, payload)
1018+
})
1019+
pending = setup('socket-pending-authorization', io, {
1020+
join: vi.fn((name: string) => {
1021+
memberships.add(name)
1022+
}),
1023+
leave: vi.fn((name: string) => {
1024+
memberships.delete(name)
1025+
}),
1026+
})
1027+
let resolvePermission!: (value: 'write' | 'read' | null) => void
1028+
const authorization = new Promise<'write' | 'read' | null>((resolve) => {
1029+
resolvePermission = resolve
1030+
})
1031+
const guard = vi
1032+
.spyOn(permissions, 'resolveCurrentRoomPermission')
1033+
.mockResolvedValueOnce('write')
1034+
.mockImplementationOnce(() => authorization)
1035+
const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
1036+
try {
1037+
await vi.waitFor(() => expect(guard).toHaveBeenCalledTimes(2))
1038+
const content = frame(FILE_DOC_MESSAGE_TYPE.SYNC, (encoder) =>
1039+
syncProtocol.writeUpdate(encoder, seedResult('# Private update').update)
1040+
)
1041+
io.local.to(ROOM_NAME).emit(FILE_DOC_EVENTS.MESSAGE, content)
1042+
io.to(ROOM_NAME).emit(
1043+
FILE_DOC_EVENTS.MESSAGE,
1044+
new Uint8Array([FILE_DOC_MESSAGE_TYPE.AWARENESS])
1045+
)
1046+
io.to(ROOM_NAME).emit(FILE_DOC_EVENTS.PRESENCE, [{ userId: 'private-peer' }])
1047+
expect(pending.socket.emit).not.toHaveBeenCalledWith(
1048+
FILE_DOC_EVENTS.MESSAGE,
1049+
expect.anything()
1050+
)
1051+
expect(pending.socket.emit).not.toHaveBeenCalledWith(
1052+
FILE_DOC_EVENTS.PRESENCE,
1053+
expect.anything()
1054+
)
1055+
expect(memberships.has(ROOM_NAME)).toBe(false)
1056+
resolvePermission(permission)
1057+
await joining
1058+
expect(memberships.has(ROOM_NAME)).toBe(permission === 'write')
1059+
if (permission === 'write') {
1060+
expect(joinSuccessFileId(pending.socket)).toBe('file-1')
1061+
expect(pending.socket.emit).toHaveBeenCalledWith(
1062+
FILE_DOC_EVENTS.MESSAGE,
1063+
expect.any(Uint8Array)
1064+
)
1065+
} else {
1066+
expect(joinSuccessFileId(pending.socket)).toBeUndefined()
1067+
expect(pending.socket.emit).toHaveBeenCalledWith(
1068+
FILE_DOC_EVENTS.JOIN_ERROR,
1069+
expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false })
1070+
)
1071+
}
1072+
} finally {
1073+
resolvePermission(permission)
1074+
await joining
1075+
guard.mockRestore()
1076+
}
1077+
}
1078+
)
1079+
10091080
it('receives invalidation while the subscribed generation check is pending', async () => {
10101081
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Old', 'doc-old'))
10111082
const memberships = new Set<string>()
@@ -1031,9 +1102,10 @@ describe('setupWorkspaceFileDocHandlers', () => {
10311102
try {
10321103
const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
10331104
await vi.waitFor(() => expect(guard).toHaveBeenCalledOnce())
1034-
expect(memberships.has(ROOM_NAME)).toBe(true)
1105+
expect(memberships.has(ROOM_NAME)).toBe(false)
1106+
expect(memberships.has(fileDocAdmissionRoom('file-1'))).toBe(true)
10351107
await getFileDocStore().invalidateDocument(ROOM_NAME, 2)
1036-
io.to(ROOM_NAME).emit(FILE_DOC_EVENTS.INVALIDATED, { fileId: 'file-1' })
1108+
io.to(fileDocAdmissionRoom('file-1')).emit(FILE_DOC_EVENTS.INVALIDATED, { fileId: 'file-1' })
10371109
expect(pending.socket.emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.INVALIDATED, {
10381110
fileId: 'file-1',
10391111
})
@@ -1043,6 +1115,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
10431115

10441116
expect(joinSuccessFileId(pending.socket)).toBeUndefined()
10451117
expect(memberships.has(ROOM_NAME)).toBe(false)
1118+
expect(memberships.has(fileDocAdmissionRoom('file-1'))).toBe(false)
10461119
} finally {
10471120
resolveGeneration(true)
10481121
guard.mockRestore()
@@ -1104,7 +1177,8 @@ describe('setupWorkspaceFileDocHandlers', () => {
11041177
FILE_DOC_EVENTS.JOIN_SUCCESS,
11051178
expect.objectContaining({ clientId: 2, docId: 'doc-shared' })
11061179
)
1107-
expect(pending.socket.leave).not.toHaveBeenCalled()
1180+
expect(pending.socket.leave).not.toHaveBeenCalledWith(ROOM_NAME)
1181+
expect(pending.socket.leave).toHaveBeenCalledWith(fileDocAdmissionRoom('file-1'))
11081182
} finally {
11091183
for (const resolve of checks) resolve(true)
11101184
guard.mockRestore()
@@ -1121,7 +1195,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
11211195
.mockRejectedValueOnce(new Error('Temporary generation read failure'))
11221196
try {
11231197
await current.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 })
1124-
expect(current.socket.leave).not.toHaveBeenCalled()
1198+
expect(current.socket.leave).not.toHaveBeenCalledWith(ROOM_NAME)
11251199
current.socket.emit.mockClear()
11261200
current.handlers[FILE_DOC_EVENTS.MESSAGE](
11271201
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (encoder) =>

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,11 @@ const fileDocRoom = (fileId: string): RoomRef => ({
227227
id: fileId,
228228
})
229229

230+
/** Pending admissions receive invalidations here, never document or presence frames. */
231+
export function fileDocAdmissionRoom(fileId: string): string {
232+
return `file-doc-admission:${fileId}`
233+
}
234+
230235
/**
231236
* A `y-protocols` transaction/awareness origin is the emitting socket id (a
232237
* string) when it came from a client, and something else (`null` / `'local'` /
@@ -1344,7 +1349,7 @@ export function setupWorkspaceFileDocHandlers(
13441349
// awaiting authorization can't complete after the client left and register a ghost owner. A
13451350
// leave for a DIFFERENT file must NOT cancel it (a document switch), mirroring workspace-files.
13461351
let currentFileId: string | null = null
1347-
/** Co-mounted providers share transport membership while their admissions are still pending. */
1352+
/** Co-mounted providers share invalidation membership until their last admission settles. */
13481353
const pendingMemberships = new Map<string, number>()
13491354

13501355
socket.on(FILE_DOC_EVENTS.JOIN, async (payload: JoinFileDocPayload) => {
@@ -1413,6 +1418,7 @@ export function setupWorkspaceFileDocHandlers(
14131418

14141419
const room = fileDocRoom(fileId)
14151420
const name = roomName(room)
1421+
const admissionName = fileDocAdmissionRoom(fileId)
14161422

14171423
const authorized = await resolveRoomJoinAuth({
14181424
userId,
@@ -1485,13 +1491,12 @@ export function setupWorkspaceFileDocHandlers(
14851491
if (!isCurrentJoin()) return
14861492

14871493
/**
1488-
* Subscribe after hydration, before checking the generation: an invalidation either fails
1489-
* this check or reaches the subscribed client, including one broadcast by another replica.
1490-
* Count concurrent same-socket admissions so one failed attempt cannot unsubscribe another.
1494+
* Watch invalidations before checking the generation, including broadcasts from another
1495+
* replica. Pending clients must not receive document or presence frames before authorization.
14911496
*/
14921497
pendingMemberships.set(name, (pendingMemberships.get(name) ?? 0) + 1)
14931498
subscribed = true
1494-
await socket.join(name)
1499+
await socket.join(admissionName)
14951500
const joinedVersion =
14961501
Math.max(entry.syncedVersion ?? 0, (await store.getSyncedVersion(name)) ?? 0) || undefined
14971502
const currentDocument = await store.isDocumentGenerationCurrent(name, docIdOf(entry.doc))
@@ -1514,11 +1519,10 @@ export function setupWorkspaceFileDocHandlers(
15141519
return
15151520
}
15161521

1517-
// Abort a JOIN superseded while the room was being prepared: the socket disconnected, a newer
1518-
// JOIN (a document switch) bumped the generation, or the room was dropped and re-created.
1519-
// Registering here would leak a dead socket's room, bind the socket to the wrong document, or
1520-
// attach it to a doc no longer registered. Last await before the commit, so nothing can
1521-
// interleave between the access re-check above and the registration below.
1522+
/** Commit content membership only after the final authorization decision. */
1523+
if (!isCurrentJoin()) return
1524+
await socket.join(name)
1525+
/** An asynchronous adapter join can be superseded by a leave, switch, or disconnect. */
15221526
if (!isCurrentJoin()) return
15231527

15241528
// A client id must be owned by at most one user, or a peer could bind an active
@@ -1631,6 +1635,7 @@ export function setupWorkspaceFileDocHandlers(
16311635
if (remaining > 0) pendingMemberships.set(name, remaining)
16321636
else {
16331637
pendingMemberships.delete(name)
1638+
await socket.leave(admissionName)
16341639
if (socketToRoomName.get(socket.id) !== name) await socket.leave(name)
16351640
}
16361641
}

‎apps/realtime/src/routes/http.test.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const { mockInvalidateDocument } = vi.hoisted(() => ({ mockInvalidateDocument: v
66

77
vi.mock('@/handlers/file-doc', () => ({
88
applyMarkdownToLiveFileDoc: vi.fn(),
9+
fileDocAdmissionRoom: (fileId: string) => `file-doc-admission:${fileId}`,
910
invalidateLiveFileDocument: mockInvalidateDocument,
1011
}))
1112

@@ -16,7 +17,10 @@ function createMocks(req: Partial<IncomingMessage>) {
1617
const writeHead = vi.fn()
1718
const end = vi.fn()
1819
const logger = { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }
20+
const emit = vi.fn()
21+
const to = vi.fn(() => ({ emit }))
1922
const roomManager = {
23+
io: { to },
2024
getTotalActiveConnections: vi.fn().mockResolvedValue(0),
2125
isReady: vi.fn().mockReturnValue(true),
2226
emitToRoom: vi.fn(),
@@ -30,6 +34,8 @@ function createMocks(req: Partial<IncomingMessage>) {
3034
writeHead,
3135
end,
3236
roomManager,
37+
to,
38+
emit,
3339
}
3440
}
3541

@@ -86,31 +92,32 @@ describe('createHttpHandler', () => {
8692

8793
it('invalidates the shared generation before notifying every open editor', async () => {
8894
mockInvalidateDocument.mockResolvedValueOnce({ status: 'applied', docId: 'old-document' })
89-
const { handler, req, res, writeHead, roomManager } = createMocks(
95+
const { handler, req, res, writeHead, to, emit } = createMocks(
9096
requestWithBody('/api/file-doc/invalidate', { fileId: 'file-1', version: 100 })
9197
)
9298

9399
await handler(req, res)
94100

95101
expect(mockInvalidateDocument).toHaveBeenCalledWith('file-1', 100)
96-
expect(roomManager.emitToRoom).toHaveBeenCalledWith(
97-
{ type: 'workspace-file-doc', id: 'file-1' },
102+
expect(to).toHaveBeenCalledWith(['workspace-file-doc:file-1', 'file-doc-admission:file-1'])
103+
expect(emit).toHaveBeenCalledWith(
98104
'file-doc-invalidated',
99105
expect.objectContaining({ fileId: 'file-1', version: 100, docId: 'old-document' })
100106
)
101107
expect(mockInvalidateDocument.mock.invocationCallOrder[0]).toBeLessThan(
102-
vi.mocked(roomManager.emitToRoom).mock.invocationCallOrder[0]
108+
emit.mock.invocationCallOrder[0]
103109
)
104110
expect(writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' })
105111
})
106112

107113
it('does not evict editors for a superseded invalidation', async () => {
108114
mockInvalidateDocument.mockResolvedValueOnce({ status: 'stale' })
109-
const { handler, req, res, end, roomManager } = createMocks(
115+
const { handler, req, res, end, roomManager, to } = createMocks(
110116
requestWithBody('/api/file-doc/invalidate', { fileId: 'file-1', version: 100 })
111117
)
112118
await handler(req, res)
113119
expect(roomManager.emitToRoom).not.toHaveBeenCalled()
120+
expect(to).not.toHaveBeenCalled()
114121
expect(end).toHaveBeenCalledWith(JSON.stringify({ status: 'stale' }))
115122
})
116123

‎apps/realtime/src/routes/http.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import type { IncomingMessage, ServerResponse } from 'http'
22
import { FILE_DOC_EVENTS, type FileDocInvalidated } from '@sim/realtime-protocol/file-doc'
3-
import { ROOM_TYPES, WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms'
3+
import { ROOM_TYPES, roomName, WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms'
44
import { safeCompare } from '@sim/security/compare'
55
import { env } from '@/env'
6-
import { applyMarkdownToLiveFileDoc, invalidateLiveFileDocument } from '@/handlers/file-doc'
6+
import {
7+
applyMarkdownToLiveFileDoc,
8+
fileDocAdmissionRoom,
9+
invalidateLiveFileDocument,
10+
} from '@/handlers/file-doc'
711
import { type IRoomManager, WorkflowRoomService } from '@/rooms'
812

913
interface Logger {
@@ -233,7 +237,9 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) {
233237
message: 'This file changed outside the editor. Reload to continue editing.',
234238
}
235239
if (result.status === 'applied')
236-
roomManager.emitToRoom(room, FILE_DOC_EVENTS.INVALIDATED, payload)
240+
roomManager.io
241+
.to([roomName(room), fileDocAdmissionRoom(fileId)])
242+
.emit(FILE_DOC_EVENTS.INVALIDATED, payload)
237243
res.writeHead(200, { 'Content-Type': 'application/json' })
238244
res.end(JSON.stringify({ status: result.status }))
239245
} catch (error) {

0 commit comments

Comments
 (0)