@@ -75,6 +75,7 @@ import {
7575} from '@/handlers/file-doc-store'
7676import { evictSocketFromRoom , registerRoomEvictionHandler } from '@/handlers/room-eviction'
7777import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
78+ import { registerRoomPermissionPublisher } from '@/handlers/room-permission'
7879import type { AuthenticatedSocket } from '@/middleware/auth'
7980import { peekRoomPermission , resolveCurrentRoomPermission } from '@/middleware/permissions'
8081import type { IRoomManager } from '@/rooms'
@@ -404,7 +405,10 @@ async function persistRoom(name: string, room: FileDocRoom, final: boolean): Pro
404405 }
405406 try {
406407 const snapshot = await store . getStreamSnapshot ( name , generation )
407- snapshotEditor = snapshot ?. editor ?? null
408+ /** Entries without an author (older relays, durable merges) keep workspace attribution local. */
409+ snapshotEditor =
410+ snapshot ?. editor ??
411+ ( fileDocOwnerAdapter ( room . owner ) . allowLocalSnapshotFallback ? localEditor : null )
408412 return snapshot ?. docState ?? null
409413 } catch ( streamError ) {
410414 if (
@@ -1152,6 +1156,22 @@ function emitJoinError(
11521156 } )
11531157}
11541158
1159+ /** Tells a socket its write capability only when it differs from what the socket last heard. */
1160+ function publishWriteAccess (
1161+ socket : AuthenticatedSocket ,
1162+ document : FileDocRoom ,
1163+ permission : string | null
1164+ ) : void {
1165+ const canWrite = permission === 'write' || permission === 'admin'
1166+ if ( document . publishedCanWrite . get ( socket . id ) === canWrite ) return
1167+ document . publishedCanWrite . set ( socket . id , canWrite )
1168+ socket . emit ( FILE_DOC_EVENTS . PERMISSION , {
1169+ fileId : document . fileId ,
1170+ ...fileDocOwnerWireFields ( document . owner ) ,
1171+ canWrite,
1172+ } satisfies FileDocPermission )
1173+ }
1174+
11551175/** Resolve read membership and publish the separate editing capability after authorization. */
11561176function resolveFileDocPermission (
11571177 socket : AuthenticatedSocket ,
@@ -1164,15 +1184,7 @@ function resolveFileDocPermission(
11641184 const room = fileDocRoom ( document )
11651185 const accept = ( permission : string | null ) => {
11661186 if ( socketToRoomName . get ( socket . id ) !== name || fileDocRooms . get ( name ) !== document ) return null
1167- const canWrite = permission === 'write' || permission === 'admin'
1168- if ( document . publishedCanWrite . get ( socket . id ) !== canWrite ) {
1169- document . publishedCanWrite . set ( socket . id , canWrite )
1170- socket . emit ( FILE_DOC_EVENTS . PERMISSION , {
1171- fileId : document . fileId ,
1172- ...fileDocOwnerWireFields ( document . owner ) ,
1173- canWrite,
1174- } satisfies FileDocPermission )
1175- }
1187+ publishWriteAccess ( socket , document , permission )
11761188 if ( permission === null )
11771189 evictSocketFromRoom ( socket , room , 'Your access to this document has been revoked' , io )
11781190 return permission
@@ -1189,11 +1201,18 @@ function resolveFileDocPermission(
11891201 * revocation and left the Socket.IO room). Scoped to the evicted room so a socket
11901202 * that has since switched documents keeps the one it legitimately holds.
11911203 */
1192- for ( const type of [ ROOM_TYPES . WORKSPACE_FILE_DOC , ROOM_TYPES . PROJECT_FILE_DOC ] )
1204+ for ( const type of [ ROOM_TYPES . WORKSPACE_FILE_DOC , ROOM_TYPES . PROJECT_FILE_DOC ] ) {
11931205 registerRoomEvictionHandler ( type , ( socketId , room , io ) => {
11941206 if ( socketToRoomName . get ( socketId ) !== roomName ( room ) ) return
11951207 cleanupFileDocForSocket ( socketId , io )
11961208 } )
1209+ registerRoomPermissionPublisher ( type , ( socket , room , permission ) => {
1210+ const name = roomName ( room )
1211+ const document = fileDocRooms . get ( name )
1212+ if ( document && socketToRoomName . get ( socket . id ) === name )
1213+ publishWriteAccess ( socket , document , permission )
1214+ } )
1215+ }
11971216
11981217async function handleMessage ( socket : AuthenticatedSocket , io : Server , data : unknown ) {
11991218 const name = socketToRoomName . get ( socket . id )
@@ -1229,7 +1248,7 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn
12291248 } catch ( error ) {
12301249 logger . warn ( 'Dropping file-doc frame while its authorization is unavailable' , {
12311250 socketId : socket . id ,
1232- error,
1251+ error : getErrorMessage ( error ) ,
12331252 } )
12341253 return
12351254 }
@@ -1673,10 +1692,11 @@ export function setupWorkspaceFileDocHandlers(
16731692 ! socket . disconnected &&
16741693 joinGeneration . get ( socket . id ) === generation &&
16751694 fileDocRooms . get ( name ) === entry
1676- const canRegisterJoin = ( ) => {
1677- if ( ! isCurrentJoin ( ) ) return false
1695+ /** The permission this admission is still current under, or `null` once it is not. */
1696+ const canRegisterJoin = ( ) : string | null => {
1697+ if ( ! isCurrentJoin ( ) ) return null
16781698 const permission = peekRoomPermission ( userId , room )
1679- if ( satisfiesRoomMembership ( permission ?? null , room . type ) ) return true
1699+ if ( permission && satisfiesRoomMembership ( permission , room . type ) ) return permission
16801700 emitJoinError (
16811701 socket ,
16821702 fileId ,
@@ -1685,7 +1705,7 @@ export function setupWorkspaceFileDocHandlers(
16851705 permission === undefined ? 'JOIN_FAILED' : 'ACCESS_DENIED' ,
16861706 permission === undefined
16871707 )
1688- return false
1708+ return null
16891709 }
16901710 try {
16911711 // A client is attached to a WHOLE document or to nothing. A room assembles itself from the
@@ -1838,7 +1858,8 @@ export function setupWorkspaceFileDocHandlers(
18381858 socketToRoomName . set ( socket . id , name )
18391859 registered = true
18401860 await changeMembership ( ( ) => socket . leave ( `${ name } :pending` ) )
1841- if ( ! canRegisterJoin ( ) ) {
1861+ const admittedPermission = canRegisterJoin ( )
1862+ if ( ! admittedPermission ) {
18421863 if ( isCurrentJoin ( ) && socketToRoomName . get ( socket . id ) === name ) {
18431864 cleanupFileDocForSocket ( socket . id , io )
18441865 await leaveUnclaimedRoom ( name )
@@ -1864,9 +1885,7 @@ export function setupWorkspaceFileDocHandlers(
18641885 schemaVersion : FILE_DOC_SCHEMA_VERSION ,
18651886 ...fileDocOwnerWireFields ( entry . owner ) ,
18661887 acknowledgedUpdates : true as const ,
1867- canWrite :
1868- peekRoomPermission ( userId , room ) === 'write' ||
1869- peekRoomPermission ( userId , room ) === 'admin' ,
1888+ canWrite : admittedPermission === 'write' || admittedPermission === 'admin' ,
18701889 } )
18711890 // Server-authenticated roster → everyone in the room, including this joiner.
18721891 broadcastFileDocPresence ( io , name , entry )
0 commit comments