44 FILE_DOC_EVENTS ,
55 FILE_DOC_MESSAGE_TYPE ,
66 FILE_DOC_SEED ,
7+ type FileDocPresenceUser ,
78 type JoinFileDocPayload ,
89 type LeaveFileDocPayload ,
910 toFileDocBytes ,
@@ -15,6 +16,7 @@ import type { Server } from 'socket.io'
1516import * as awarenessProtocol from 'y-protocols/awareness'
1617import * as syncProtocol from 'y-protocols/sync'
1718import * as Y from 'yjs'
19+ import { resolveAvatarUrl } from '@/handlers/avatar'
1820import type { AuthenticatedSocket } from '@/middleware/auth'
1921import type { IRoomManager } from '@/rooms'
2022
@@ -57,6 +59,10 @@ interface FileDocOwner {
5759 /** The owning user — used to tell a reconnect (same user reusing its Yjs client
5860 * id) from a spoof (a different user binding a peer's id). */
5961 userId : string
62+ /** Server-authenticated display identity for the presence roster (from the socket's
63+ * session, never the client-set awareness — so a peer cannot spoof it). */
64+ userName : string
65+ avatarUrl : string | null
6066}
6167
6268interface FileDocRoom {
@@ -116,6 +122,28 @@ function broadcast(io: Server, name: string, payload: Uint8Array, exceptSocketId
116122 channel . emit ( FILE_DOC_EVENTS . MESSAGE , payload )
117123}
118124
125+ /**
126+ * Broadcast the room's collaborator roster to everyone in it, for the avatar stack. One entry
127+ * PER SESSION (socket) — the client excludes its own socket and dedupes the remainder per user
128+ * for display, so a second tab of the same account still registers as present (mirroring the
129+ * canvas presence model). Deduping here instead would drop the current user's other sessions
130+ * asymmetrically (only one socket survives), so each client could never reliably self-exclude.
131+ * Identity comes from each owner's server-authenticated session — never the client-set awareness
132+ * — so a peer cannot spoof or suppress an entry.
133+ */
134+ function broadcastFileDocPresence ( io : Server , name : string , room : FileDocRoom ) {
135+ const users : FileDocPresenceUser [ ] = [ ]
136+ for ( const [ socketId , owner ] of room . owners ) {
137+ users . push ( {
138+ socketId,
139+ userId : owner . userId ,
140+ userName : owner . userName ,
141+ avatarUrl : owner . avatarUrl ,
142+ } )
143+ }
144+ io . to ( name ) . emit ( FILE_DOC_EVENTS . PRESENCE , { fileId : room . fileId , users } )
145+ }
146+
119147/** Whether the client has recorded that it seeded the document's initial content. */
120148function isDocSeeded ( doc : Y . Doc ) : boolean {
121149 return doc . getMap ( FILE_DOC_SEED . configMap ) . get ( FILE_DOC_SEED . flag ) === true
@@ -316,10 +344,15 @@ function handleMessage(socket: AuthenticatedSocket, data: unknown) {
316344 * seeding, and drop the room's document when the last collaborator leaves.
317345 * Exported for the disconnect handler; safe to call for a socket in no room.
318346 */
319- export function cleanupFileDocForSocket ( socketId : string , io : Server ) : void {
320- // Drop the join generation so an in-flight JOIN for this socket aborts after
321- // its authorize resolves, and the map never leaks across the socket's life.
322- joinGeneration . delete ( socketId )
347+ export function cleanupFileDocForSocket ( socketId : string , io : Server , endOfLife = false ) : void {
348+ // The join-generation counter is monotonic for the socket's WHOLE life and must survive a room
349+ // switch/leave: resetting it here would let the next join reuse a low number that a still
350+ // in-flight earlier join also holds, so that stale join passes the generation guard and rebinds
351+ // the socket to the wrong document. Drop it ONLY when the socket is truly gone (disconnect),
352+ // which is also the only place the map would otherwise leak. An in-flight join is already
353+ // aborted on disconnect by the `socket.disconnected` check, and on a switch by a newer join
354+ // bumping the generation — neither needs this delete.
355+ if ( endOfLife ) joinGeneration . delete ( socketId )
323356
324357 const name = socketToRoomName . get ( socketId )
325358 if ( ! name ) return
@@ -334,6 +367,8 @@ export function cleanupFileDocForSocket(socketId: string, io: Server): void {
334367 // Fires the awareness `update` handler with a non-socket origin → the removal
335368 // is broadcast to every remaining client, so the departed caret vanishes.
336369 awarenessProtocol . removeAwarenessStates ( room . awareness , [ owner . clientId ] , null )
370+ // Refresh the roster for whoever remains (server-authenticated identity).
371+ broadcastFileDocPresence ( io , name , room )
337372 }
338373
339374 // Hand off the seeder role: if the elected seeder left before it seeded, elect
@@ -351,12 +386,22 @@ export function cleanupFileDocForSocket(socketId: string, io: Server): void {
351386 * file id; joining requires workspace `write` (editing a document). Mirrors the
352387 * workspace-files join shape (auth → readiness → validate → authorize → join),
353388 * then runs the Yjs sync/awareness handshake.
389+ *
390+ * The avatar roster is derived from this room's own `owners` map and broadcast as
391+ * `FILE_DOC_EVENTS.PRESENCE` — NOT the Redis-backed room-manager presence the workflow /
392+ * table rooms use — because the file-doc room already owns an authoritative in-memory Y.Doc
393+ * pinned to a single replica, so the session identity is right here with no extra store.
354394 */
355395export function setupWorkspaceFileDocHandlers (
356396 socket : AuthenticatedSocket ,
357397 roomManager : IRoomManager
358398) {
359399 const io = roomManager . io
400+ // The file this socket currently intends to edit (set when a join starts). A leave targeting it
401+ // — or an unscoped leave — advances the join generation to cancel an in-flight join, so a join
402+ // awaiting authorization can't complete after the client left and register a ghost owner. A
403+ // leave for a DIFFERENT file must NOT cancel it (a document switch), mirroring workspace-files.
404+ let currentFileId : string | null = null
360405
361406 socket . on ( FILE_DOC_EVENTS . JOIN , async ( { fileId, clientId } : JoinFileDocPayload ) => {
362407 try {
@@ -376,9 +421,11 @@ export function setupWorkspaceFileDocHandlers(
376421 return
377422 }
378423
379- // Claim this JOIN's generation before the async authorize below.
424+ // Claim this JOIN's generation before the async authorize below, and record the file the
425+ // socket now intends to edit so a leave for it can cancel this join if it's still in-flight.
380426 const generation = ( joinGeneration . get ( socket . id ) ?? 0 ) + 1
381427 joinGeneration . set ( socket . id , generation )
428+ currentFileId = fileId
382429
383430 const room = fileDocRoom ( fileId )
384431 const name = roomName ( room )
@@ -408,9 +455,13 @@ export function setupWorkspaceFileDocHandlers(
408455 return
409456 }
410457
411- // Abort a JOIN superseded during authorization: the socket disconnected, or
412- // a newer JOIN (a document switch) bumped the generation. Registering here
413- // would leak a dead socket's room or bind the socket to the wrong document.
458+ // Server-authenticated identity for the presence roster (never trusts the client-set
459+ // awareness). Resolved here so the generation guard below also covers this await.
460+ const avatarUrl = await resolveAvatarUrl ( socket , userId )
461+
462+ // Abort a JOIN superseded during authorization/identity resolution: the socket
463+ // disconnected, or a newer JOIN (a document switch) bumped the generation. Registering
464+ // here would leak a dead socket's room or bind the socket to the wrong document.
414465 if ( socket . disconnected || joinGeneration . get ( socket . id ) !== generation ) return
415466
416467 // Switched documents on the same socket — leave the previous one first (a
@@ -449,11 +500,13 @@ export function setupWorkspaceFileDocHandlers(
449500 awarenessProtocol . removeAwarenessStates ( entry . awareness , [ previous . clientId ] , null )
450501 }
451502
452- entry . owners . set ( socket . id , { clientId, userId } )
503+ entry . owners . set ( socket . id , { clientId, userId, userName , avatarUrl } )
453504 socketToRoomName . set ( socket . id , name )
454505 socket . join ( name )
455506
456507 socket . emit ( FILE_DOC_EVENTS . JOIN_SUCCESS , { fileId } )
508+ // Server-authenticated roster → everyone in the room, including this joiner.
509+ broadcastFileDocPresence ( io , name , entry )
457510
458511 // Begin the sync handshake: send the server's state (sync step 1). The
459512 // client replies with its updates and requests the server's in return.
@@ -496,10 +549,17 @@ export function setupWorkspaceFileDocHandlers(
496549
497550 socket . on ( FILE_DOC_EVENTS . LEAVE , ( payload ?: LeaveFileDocPayload ) => {
498551 try {
499- // Only affect a REGISTERED room; never touch the join generation here. A
500- // leave that raced ahead of an in-flight join (no room registered yet) is a
501- // no-op — bumping the generation would silently abort an unrelated join for
502- // a different file (a document switch), leaving the socket bound to nothing.
552+ // Cancel an in-flight join whose file the client is now leaving (or an unscoped leave): a
553+ // join still awaiting authorization would otherwise complete after the client left, register
554+ // as an owner, and broadcast a ghost collaborator until disconnect. Guard on the current
555+ // file intent so a stale/deferred leave for a DIFFERENT file can't abort the join the client
556+ // has since switched to (bumping the generation blindly caused that regression in #5941).
557+ if ( ! payload ?. fileId || payload . fileId === currentFileId ) {
558+ joinGeneration . set ( socket . id , ( joinGeneration . get ( socket . id ) ?? 0 ) + 1 )
559+ currentFileId = null
560+ }
561+ // Tear down membership only for a REGISTERED room; a leave that raced ahead of an in-flight
562+ // join (nothing registered yet) has already cancelled it above.
503563 const name = socketToRoomName . get ( socket . id )
504564 if ( ! name ) return
505565 // Scope the leave to the named file when provided: a deferred leave from a
0 commit comments