Skip to content

Commit 72dee6c

Browse files
committed
fix(file-doc): admit joins on their validated permission and route sweep capability through the per-socket de-dup
1 parent 0df7b5c commit 72dee6c

7 files changed

Lines changed: 128 additions & 51 deletions

File tree

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ACCESS_REVALIDATION_SWEEP_INTERVAL_MS,
1414
startAccessRevalidationSweep,
1515
} from '@/access-revalidation'
16+
import { registerRoomPermissionPublisher } from '@/handlers/room-permission'
1617
import type { IRoomManager, UserPresence } from '@/rooms'
1718

1819
interface FakeSocket {
@@ -131,6 +132,8 @@ describe('access-revalidation sweep', () => {
131132
})
132133

133134
it('keeps a downgraded reader subscribed and reports the loss of write access', async () => {
135+
const publish = vi.fn()
136+
registerRoomPermissionPublisher('workspace-file-doc', publish)
134137
const socket = makeSocket('sock-1', 'user-1', 'workspace-file-doc:file-1')
135138
socket.rooms.add('table:t-1')
136139
const manager = makeManager([socket])
@@ -139,20 +142,23 @@ describe('access-revalidation sweep', () => {
139142
await sweep.runOnce()
140143
sweep.stop()
141144
expect(socket.leave).not.toHaveBeenCalled()
142-
expect(socket.emit).toHaveBeenCalledWith('file-doc-permission', {
143-
fileId: 'file-1',
144-
canWrite: false,
145-
})
145+
expect(publish).toHaveBeenCalledWith(
146+
socket,
147+
{ type: 'workspace-file-doc', id: 'file-1' },
148+
'read'
149+
)
146150
})
147151

148152
it('retains a document subscription for retry when authorization is unavailable', async () => {
153+
const publish = vi.fn()
154+
registerRoomPermissionPublisher('workspace-file-doc', publish)
149155
const socket = makeSocket('sock-1', 'user-1', 'workspace-file-doc:file-1')
150156
mockResolveRole.mockRejectedValue(new Error('database unavailable'))
151157
const sweep = startAccessRevalidationSweep(makeManager([socket]))
152158
await sweep.runOnce()
153159
sweep.stop()
154160
expect(socket.leave).not.toHaveBeenCalled()
155-
expect(socket.emit).not.toHaveBeenCalledWith('file-doc-permission', expect.anything())
161+
expect(publish).not.toHaveBeenCalled()
156162
})
157163

158164
it('evicts only the revoked socket, not co-members of the room', async () => {

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

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { createLogger } from '@sim/logger'
22
import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform-authz/room-policy'
33
import type { AccessRevokedBroadcast } from '@sim/realtime-protocol/events'
4-
import { FILE_DOC_EVENTS, type FileDocPermission } from '@sim/realtime-protocol/file-doc'
54
import {
65
isProjectRoom,
76
parseRoomName,
8-
projectFileDocTarget,
97
ROOM_TYPES,
108
type RoomRef,
119
type RoomType,
@@ -17,6 +15,7 @@ import {
1715
runRoomEvictionHandler,
1816
setEvictionCleanupSink,
1917
} from '@/handlers/room-eviction'
18+
import { publishRoomPermission } from '@/handlers/room-permission'
2019
import type { AuthenticatedSocket } from '@/middleware/auth'
2120
import { ROLE_REVALIDATION_TTL_MS, resolveCurrentRoomPermission } from '@/middleware/permissions'
2221
import type { IRoomManager } from '@/rooms'
@@ -331,20 +330,7 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
331330
])
332331
// {@link SCAN_TIMED_OUT} is the only symbol this race can yield; matching on
333332
// the type narrows it out of the permission comparison below.
334-
if (
335-
typeof role !== 'symbol' &&
336-
(room.type === ROOM_TYPES.PROJECT_FILE_DOC || room.type === ROOM_TYPES.WORKSPACE_FILE_DOC)
337-
) {
338-
const target =
339-
room.type === ROOM_TYPES.PROJECT_FILE_DOC
340-
? projectFileDocTarget(room)
341-
: { fileId: room.id }
342-
if (target)
343-
socket.emit(FILE_DOC_EVENTS.PERMISSION, {
344-
...target,
345-
canWrite: role === 'write' || role === 'admin',
346-
} satisfies FileDocPermission)
347-
}
333+
if (typeof role !== 'symbol') publishRoomPermission(socket, room, role)
348334
if (typeof role === 'symbol') {
349335
logger.warn(
350336
`Authorization check timed out for user ${userId} on ${name}; skipping this pass`

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
setupWorkspaceFileDocHandlers,
4343
} from '@/handlers/file-doc'
4444
import { FileDocInvalidatedError, getFileDocStore } from '@/handlers/file-doc-store'
45+
import { publishRoomPermission } from '@/handlers/room-permission'
4546
import * as permissions from '@/middleware/permissions'
4647
import {
4748
beginRoomPermissionRead,
@@ -686,6 +687,27 @@ describe('setupWorkspaceFileDocHandlers', () => {
686687
}
687688
})
688689

690+
it('publishes a re-validated permission only when it changes what the socket last heard', async () => {
691+
const { io } = createIo()
692+
const { socket, handlers } = setup('socket-sweep', io)
693+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 8 })
694+
await handlers[FILE_DOC_EVENTS.MESSAGE](awarenessFrame(8, 'Sweep').frame)
695+
const room = { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-1' } as const
696+
const subscriber = socket as unknown as Parameters<typeof publishRoomPermission>[0]
697+
const published = () =>
698+
socket.emit.mock.calls
699+
.filter(([event]) => event === FILE_DOC_EVENTS.PERMISSION)
700+
.map(([, payload]) => (payload as { canWrite: boolean }).canWrite)
701+
702+
publishRoomPermission(subscriber, room, 'write')
703+
publishRoomPermission(subscriber, room, 'write')
704+
expect(published()).toEqual([true])
705+
706+
publishRoomPermission(subscriber, room, 'read')
707+
publishRoomPermission(subscriber, room, 'read')
708+
expect(published()).toEqual([true, false])
709+
})
710+
689711
it('applies + fans out an agent-streamed frame (SYNC_NO_PERSIST) but never persists it', async () => {
690712
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
691713
const { io, sent } = createIo()

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

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
} from '@/handlers/file-doc-store'
7676
import { evictSocketFromRoom, registerRoomEvictionHandler } from '@/handlers/room-eviction'
7777
import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
78+
import { registerRoomPermissionPublisher } from '@/handlers/room-permission'
7879
import type { AuthenticatedSocket } from '@/middleware/auth'
7980
import { peekRoomPermission, resolveCurrentRoomPermission } from '@/middleware/permissions'
8081
import 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. */
11561176
function 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

11981217
async 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)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { RoomRef, RoomType } from '@sim/realtime-protocol/rooms'
3+
import type { AuthenticatedSocket } from '@/middleware/auth'
4+
5+
const logger = createLogger('RoomPermission')
6+
7+
/** Tells a socket, in its room type's own wire form, what a re-validated permission now allows. */
8+
export type RoomPermissionPublisher = (
9+
socket: AuthenticatedSocket,
10+
room: RoomRef,
11+
permission: string | null
12+
) => void
13+
14+
const publishers = new Map<RoomType, RoomPermissionPublisher>()
15+
16+
/**
17+
* Registers how a room type announces a re-validated permission. A registry for the same reason
18+
* as the eviction handlers: the access re-validation sweep stays domain-neutral, and the room's
19+
* handler keeps its own record of what each socket was last told.
20+
*/
21+
export function registerRoomPermissionPublisher(
22+
type: RoomType,
23+
publisher: RoomPermissionPublisher
24+
): void {
25+
publishers.set(type, publisher)
26+
}
27+
28+
/** Publishes a re-validated permission through the room type's publisher, if it has one. */
29+
export function publishRoomPermission(
30+
socket: AuthenticatedSocket,
31+
room: RoomRef,
32+
permission: string | null
33+
): void {
34+
const publisher = publishers.get(room.type)
35+
if (!publisher) return
36+
try {
37+
publisher(socket, room, permission)
38+
} catch (error) {
39+
logger.warn(`Room permission publish failed for socket ${socket.id} on ${room.type}`, error)
40+
}
41+
}
Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { NextResponse } from 'next/server'
12
import { projectFileDocPersistContract } from '@/lib/api/contracts/project-file-doc'
23
import { isOffAppHost } from '@/lib/api/mcp/host-routing'
34
import {
@@ -7,24 +8,19 @@ import {
78
} from '@/lib/api/server/routes'
89
import { realtimeProjectFileAuth } from '@/lib/auth/realtime-file-delegation'
910
import { MAX_COLLAB_DOC_STATE_BYTES } from '@/lib/collab-doc/state'
10-
import { OrchestrationError } from '@/lib/core/orchestration/types'
1111
import { persistProjectFileDoc } from '@/lib/projects/files/application/documents'
1212

1313
/** A base64-encoded maximum snapshot plus 1 MiB for its JSON envelope (17 MiB). */
1414
const PERSIST_MAX_BODY_BYTES = Math.ceil(MAX_COLLAB_DOC_STATE_BYTES / 3) * 4 + 1024 * 1024
1515

16-
export const POST = defineInternalJsonRoute({
16+
const persist = defineInternalJsonRoute({
1717
contract: projectFileDocPersistContract,
1818
operation: persistProjectFileDoc.operation,
1919
auth: realtimeProjectFileAuth,
2020
rateLimit: internalRateLimits.none({
2121
reason: 'Authenticated realtime relay; socket frame admission bounds collaborative traffic',
2222
}),
2323
errorPolicy: internalOrchestrationErrorPolicy,
24-
/** Excluded from the proxy matcher, so the route refuses the dedicated MCP host itself. */
25-
beforeParse: ({ request }) => {
26-
if (isOffAppHost(request)) throw new OrchestrationError('not_found', 'Not found')
27-
},
2824
mapInput: ({ params, body }) => ({
2925
...params,
3026
docState: new Uint8Array(Buffer.from(body.docState, 'base64')),
@@ -33,3 +29,9 @@ export const POST = defineInternalJsonRoute({
3329
useCase: persistProjectFileDoc,
3430
parseOptions: { maxBodyBytes: PERSIST_MAX_BODY_BYTES, bypassesProxy: true },
3531
})
32+
33+
/** Excluded from the proxy matcher, so the route refuses the dedicated MCP host before auth itself. */
34+
export const POST: typeof persist = (request, context) =>
35+
isOffAppHost(request)
36+
? Promise.resolve(new NextResponse(null, { status: 404 }))
37+
: persist(request, context)

‎apps/sim/lib/api/server/validation.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ import {
2323
/**
2424
* Next.js buffers the client body for the proxy and *silently truncates* anything
2525
* past `experimental.proxyClientMaxBodySize` (default 10 MB), and `apps/sim/proxy.ts`
26-
* matches `/api/:path*`. A larger body therefore reaches the handler as a truncated
27-
* prefix, which fails JSON parsing — so an oversized request has to be rejected on
28-
* its declared size before it is read, or the truncation gets misreported as a
29-
* malformed body.
26+
* matches every `/api` route except the few its matcher excludes (see
27+
* {@link ParseRequestOptions.bypassesProxy}). A larger body therefore reaches the
28+
* handler as a truncated prefix, which fails JSON parsing — so an oversized request
29+
* has to be rejected on its declared size before it is read, or the truncation gets
30+
* misreported as a malformed body.
3031
*/
3132
const PROXY_CLIENT_MAX_BODY_BYTES = 10 * 1024 * 1024
3233

0 commit comments

Comments
 (0)