Skip to content

Commit 3ba393d

Browse files
authored
fix(file-doc): stop the permission resend loop and restore the 10 MB proxy body limit (#9057)
1 parent 93249e5 commit 3ba393d

30 files changed

Lines changed: 553 additions & 392 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.redis-store.test.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,18 @@ import * as syncProtocol from 'y-protocols/sync'
2020
import * as Y from 'yjs'
2121
import type { IRoomManager } from '@/rooms'
2222

23-
const { mockAuthorizeRoom, mockFetchFileDocSeed } = vi.hoisted(() => ({
23+
const { mockAuthorizeRoom, mockFetchFileDocSeed, mockFetchFileDocPersist } = vi.hoisted(() => ({
2424
mockAuthorizeRoom: vi.fn(),
2525
mockFetchFileDocSeed: vi.fn(),
26+
mockFetchFileDocPersist: vi.fn(),
2627
}))
2728

2829
vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: mockAuthorizeRoom }))
2930

3031
vi.mock('@/handlers/file-doc-app', () => ({
3132
fetchFileDocSeed: mockFetchFileDocSeed,
3233
fetchFileDocMerge: vi.fn(),
33-
fetchFileDocPersist: vi.fn().mockResolvedValue({ status: 'persisted', version: 1 }),
34+
fetchFileDocPersist: mockFetchFileDocPersist,
3435
}))
3536

3637
/** One in-memory Redis backing per test — only the stream/lock ops the store actually uses. */
@@ -269,6 +270,7 @@ describe('file-doc join readiness (shared store enabled)', () => {
269270
workspacePermission: 'write',
270271
})
271272
mockFetchFileDocSeed.mockResolvedValue(null)
273+
mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 2 })
272274
})
273275

274276
afterEach(() => {
@@ -326,4 +328,49 @@ describe('file-doc join readiness (shared store enabled)', () => {
326328
expect(mockFetchFileDocSeed).not.toHaveBeenCalled()
327329
expect(statesDeliveredTo(socket)).toEqual(['seeded by the writer that held the lock'])
328330
})
331+
332+
it('persists unattributed stream state under the local editor when the last collaborator leaves', async () => {
333+
backing.kv.set(`filedoc:generation:${ROOM_NAME}`, 'doc-1')
334+
backing.kv.set(`filedoc:syncver:${ROOM_NAME}`, '1')
335+
const doc = new Y.Doc()
336+
doc.getText(FIELD).insert(0, 'AAA')
337+
doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
338+
doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1')
339+
backing.streams.set(STREAM_KEY, [
340+
{
341+
id: `${++backing.seq}-0`,
342+
message: { u: Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64'), g: 'doc-1' },
343+
},
344+
])
345+
const afterSeed = Y.encodeStateVector(doc)
346+
doc.getText(FIELD).insert(0, 'BBB')
347+
const edit = Y.encodeStateAsUpdate(doc, afterSeed)
348+
doc.destroy()
349+
350+
const { handlers } = setup('socket-1', sockets)
351+
await handlers[FILE_DOC_EVENTS.JOIN]({
352+
fileId: FILE_ID,
353+
clientId: 1,
354+
schemaVersion: FILE_DOC_SCHEMA_VERSION,
355+
})
356+
const acknowledge = vi.fn()
357+
await (handlers[FILE_DOC_EVENTS.UPDATE] as (...args: unknown[]) => Promise<void>)(
358+
{ fileId: FILE_ID, docId: 'doc-1', updateId: 'update-1', update: edit },
359+
acknowledge
360+
)
361+
await vi.waitFor(() =>
362+
expect(acknowledge).toHaveBeenCalledWith({ status: 'accepted', updateId: 'update-1' })
363+
)
364+
365+
cleanupFileDocForSocket('socket-1', createIo(sockets), true)
366+
await vi.waitFor(() =>
367+
expect(mockFetchFileDocPersist).toHaveBeenCalledWith(
368+
'ws-1',
369+
FILE_ID,
370+
'user-1',
371+
expect.any(Uint8Array),
372+
1
373+
)
374+
)
375+
})
329376
})

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

Lines changed: 88 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,
@@ -642,6 +643,93 @@ describe('setupWorkspaceFileDocHandlers', () => {
642643
}
643644
})
644645

646+
it('publishes write access to a socket only when it changes, not on every frame', async () => {
647+
// A distinct user/file: role decisions recorded under fake timers outlive this test.
648+
vi.useFakeTimers()
649+
try {
650+
const { io } = createIo()
651+
const { socket, handlers } = setup('socket-capability', io, { userId: 'user-capability' })
652+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-capability', clientId: 7 })
653+
await vi.advanceTimersByTimeAsync(0)
654+
const presence = awarenessFrame(7, 'Capability').frame
655+
const sendFrames = async () => {
656+
for (let sent = 0; sent < 3; sent++) {
657+
void handlers[FILE_DOC_EVENTS.MESSAGE](presence)
658+
await vi.advanceTimersByTimeAsync(0)
659+
}
660+
}
661+
const published = () =>
662+
socket.emit.mock.calls
663+
.filter(([event]) => event === FILE_DOC_EVENTS.PERMISSION)
664+
.map(([, payload]) => (payload as { canWrite: boolean }).canWrite)
665+
const changeRole = async (workspacePermission: 'read' | 'write') => {
666+
mockAuthorizeRoom.mockResolvedValue({
667+
allowed: true,
668+
status: 200,
669+
workspaceId: 'ws-1',
670+
workspacePermission,
671+
})
672+
await vi.advanceTimersByTimeAsync(ROLE_REVALIDATION_TTL_MS + 1_000)
673+
}
674+
675+
await sendFrames()
676+
expect(published()).toEqual([true])
677+
678+
await changeRole('read')
679+
await sendFrames()
680+
expect(published()).toEqual([true, false])
681+
682+
await changeRole('write')
683+
await sendFrames()
684+
expect(published()).toEqual([true, false, true])
685+
} finally {
686+
vi.useRealTimers()
687+
}
688+
})
689+
690+
it('tells an admitted writer it can write even if its cached role lapses before the reply', async () => {
691+
// A distinct user/file: role decisions recorded under fake timers outlive this test.
692+
vi.useFakeTimers({ toFake: ['Date'] })
693+
try {
694+
const { io } = createIo()
695+
const data: Record<string, unknown> = {}
696+
// Registration records presence just before the reply; expire the role cache right there.
697+
Object.defineProperty(data, 'fileDocPresence', {
698+
set: () => vi.setSystemTime(Date.now() + ROLE_REVALIDATION_TTL_MS + 1),
699+
})
700+
const { socket, handlers } = setup('socket-lapse', io, { userId: 'user-lapse', data })
701+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-lapse', clientId: 9 })
702+
703+
expect(socket.emit).toHaveBeenCalledWith(
704+
FILE_DOC_EVENTS.JOIN_SUCCESS,
705+
expect.objectContaining({ fileId: 'file-lapse', canWrite: true })
706+
)
707+
} finally {
708+
vi.useRealTimers()
709+
}
710+
})
711+
712+
it('publishes a re-validated permission only when it changes what the socket last heard', async () => {
713+
const { io } = createIo()
714+
const { socket, handlers } = setup('socket-sweep', io)
715+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 8 })
716+
await handlers[FILE_DOC_EVENTS.MESSAGE](awarenessFrame(8, 'Sweep').frame)
717+
const room = { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-1' } as const
718+
const subscriber = socket as unknown as Parameters<typeof publishRoomPermission>[0]
719+
const published = () =>
720+
socket.emit.mock.calls
721+
.filter(([event]) => event === FILE_DOC_EVENTS.PERMISSION)
722+
.map(([, payload]) => (payload as { canWrite: boolean }).canWrite)
723+
724+
publishRoomPermission(subscriber, room, 'write')
725+
publishRoomPermission(subscriber, room, 'write')
726+
expect(published()).toEqual([true])
727+
728+
publishRoomPermission(subscriber, room, 'read')
729+
publishRoomPermission(subscriber, room, 'read')
730+
expect(published()).toEqual([true, false])
731+
})
732+
645733
it('applies + fans out an agent-streamed frame (SYNC_NO_PERSIST) but never persists it', async () => {
646734
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
647735
const { io, sent } = createIo()

0 commit comments

Comments
 (0)