Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/scripts/http-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# mobile, which serves the production build the `e2e-build` job uploads with `next start` (the
# job extracts it into apps/sim/.next first).
#
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|mobile> (run from apps/sim)
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|project-files|mobile> (run from apps/sim)
#
# The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of
# its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e.
Expand All @@ -20,7 +20,7 @@
# telemetry flush runs detached and still writes .next/dev).
set -euo pipefail

group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|mobile>}
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|project-files|mobile>}
report_dir="$RUNNER_TEMP/e2e"
ready_timeout_seconds=600
mkdir -p "$report_dir"
Expand Down Expand Up @@ -212,6 +212,15 @@ case "$group" in
bun run test:desktop-inbox:e2e
;;

project-files)
PROJECT_FILES_E2E_ADMIN_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres \
PROJECT_FILES_E2E_REDIS_URL=redis://127.0.0.1:6379 \
PROJECT_FILES_E2E_BASE_URL=http://127.0.0.1:3050 \
PROJECT_FILES_E2E_RELAY_URL=http://127.0.0.1:3052 \
PROJECT_FILES_E2E_REPORT_PATH="$report_dir/project-files/orchestration.json" \
bun --no-env-file scripts/test-project-files-e2e.ts
;;

# MOBILE_E2E_BROWSER (chromium or webkit) narrows the job to one browser; unset runs both.
mobile)
app_server=start
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ jobs:
runner: blacksmith-4vcpu-ubuntu-2404
- group: stop-after
runner: blacksmith-4vcpu-ubuntu-2404
- group: project-files
runner: blacksmith-8vcpu-ubuntu-2404
- group: desktop-inbox
runner: blacksmith-4vcpu-ubuntu-2404
services:
Expand All @@ -212,7 +214,7 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 10
# Only the desktop executor's app is given REDIS_URL: its doorbell and presence live there.
# Project file subscriptions and the desktop executor use Redis.
redis:
image: redis:8.2-alpine
ports:
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/openapi-v2-files-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -6243,7 +6243,7 @@
],
"additionalProperties": false,
"title": "File share",
"description": "Public-safe share configuration for a workspace file."
"description": "Public-safe share configuration for a file."
},
"V2FileMetadata": {
"type": "object",
Expand Down
34 changes: 10 additions & 24 deletions apps/realtime/src/access-revalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,43 +130,29 @@ describe('access-revalidation sweep', () => {
expect(tableSocket.leave).not.toHaveBeenCalled()
})

it('evicts a file-doc socket downgraded to read, and keeps its table room', async () => {
// A file-doc room IS the editor and requires `write`; a table room requires only
// `read`. One downgraded user in both rooms must lose exactly the document.
it('keeps a downgraded reader subscribed and reports the loss of write access', async () => {
const socket = makeSocket('sock-1', 'user-1', 'workspace-file-doc:file-1')
socket.rooms.add('table:t-1')
const manager = makeManager([socket])
mockResolveRole.mockResolvedValue('read')

const sweep = startAccessRevalidationSweep(manager)
await sweep.runOnce()
sweep.stop()

expect(socket.leave).toHaveBeenCalledWith('workspace-file-doc:file-1')
expect(socket.leave).not.toHaveBeenCalledWith('table:t-1')
expect(socket.emit).toHaveBeenCalledWith(
'room-access-revoked',
expect.objectContaining({ room: { type: 'workspace-file-doc', id: 'file-1' } })
)
expect(socket.leave).not.toHaveBeenCalled()
expect(socket.emit).toHaveBeenCalledWith('file-doc-permission', {
fileId: 'file-1',
canWrite: false,
})
})

it('falls back to the room type own membership level on a cold-cache failure', async () => {
// A static 'read' fallback would have evicted every file-doc socket (which needs
// `write`) the first time the DB blipped with a cold cache.
it('retains a document subscription for retry when authorization is unavailable', async () => {
const socket = makeSocket('sock-1', 'user-1', 'workspace-file-doc:file-1')
const manager = makeManager([socket])
mockResolveRole.mockResolvedValue('write')

const sweep = startAccessRevalidationSweep(manager)
mockResolveRole.mockRejectedValue(new Error('database unavailable'))
const sweep = startAccessRevalidationSweep(makeManager([socket]))
await sweep.runOnce()
sweep.stop()

expect(mockResolveRole).toHaveBeenCalledWith(
'user-1',
{ type: 'workspace-file-doc', id: 'file-1' },
'write'
)
expect(socket.leave).not.toHaveBeenCalled()
expect(socket.emit).not.toHaveBeenCalledWith('file-doc-permission', expect.anything())
})

it('evicts only the revoked socket, not co-members of the room', async () => {
Expand Down
26 changes: 12 additions & 14 deletions apps/realtime/src/access-revalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform-
import type { AccessRevokedBroadcast } from '@sim/realtime-protocol/events'
import { FILE_DOC_EVENTS, type FileDocPermission } from '@sim/realtime-protocol/file-doc'
import {
isProjectRoom,
parseRoomName,
projectFileDocTarget,
ROOM_TYPES,
Expand Down Expand Up @@ -35,14 +36,8 @@ const logger = createLogger('AccessRevalidation')
export const ACCESS_REVALIDATION_SWEEP_INTERVAL_MS = ROLE_REVALIDATION_TTL_MS

/**
* Fallback consumed by {@link resolveCurrentRoomPermission} only on a transient DB
* failure with a cold cache, where resolving to a permission that KEEPS the socket
* (never eviction) is the safe outcome during an outage. It is the room's own
* membership level, so it satisfies that room's requirement exactly — a static
* `'read'` would have evicted every file-doc socket (which needs `write`) on a cold
* cache blip. The scan deliberately does not read presence for a per-socket
* join-time role: that would put a Redis dependency inside the security-critical
* scan lane, and the fallback's only job is to be permissive.
* Preserves membership for room types that allow a cold-cache outage fallback.
* Document rooms throw instead; the sweep retains their subscriptions for retry.
*/
function fallbackRoleFor(type: RoomType): string {
return ROOM_MEMBERSHIP_ACTIONS[type]
Expand Down Expand Up @@ -329,15 +324,21 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
// resolution keeps running in the background and is re-raced when the
// rotation returns to this socket, so it is acted on once it settles.
const role = await Promise.race([
room.type === ROOM_TYPES.PROJECT_FILE_DOC
isProjectRoom(room)
? resolveCurrentRoomPermission(userId, room, fallbackRoleFor(room.type), socket.id)
: resolveCurrentRoomPermission(userId, room, fallbackRoleFor(room.type)),
sleep(Math.min(SCAN_SOCKET_TIMEOUT_MS, remainingBudget)).then(() => SCAN_TIMED_OUT),
])
// {@link SCAN_TIMED_OUT} is the only symbol this race can yield; matching on
// the type narrows it out of the permission comparison below.
if (typeof role !== 'symbol' && room.type === ROOM_TYPES.PROJECT_FILE_DOC) {
const target = projectFileDocTarget(room)
if (
typeof role !== 'symbol' &&
(room.type === ROOM_TYPES.PROJECT_FILE_DOC || room.type === ROOM_TYPES.WORKSPACE_FILE_DOC)
) {
const target =
room.type === ROOM_TYPES.PROJECT_FILE_DOC
? projectFileDocTarget(room)
: { fileId: room.id }
if (target)
socket.emit(FILE_DOC_EVENTS.PERMISSION, {
...target,
Expand All @@ -349,9 +350,6 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
`Authorization check timed out for user ${userId} on ${name}; skipping this pass`
)
} else if (!satisfiesRoomMembership(role, room.type)) {
// Covers both revocation (`null`) and downgrade below the level this room
// requires — a member dropped to `read` may keep a table room but not the
// collaborative document editor, exactly as at join time.
revokeSocket(socket, room, name)
}
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion apps/realtime/src/handlers/file-doc-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,14 @@ export async function fetchProjectFileDocAccess(
/** Fetch a seed under the joining socket's current Project read access. */
export async function fetchProjectFileDocSeed(
target: ProjectDocumentRequest
): Promise<{ update: Uint8Array; version: number }> {
): Promise<{ update: Uint8Array; version: number } | null> {
const response = await postToApp(
projectDocumentPath(target, 'seed'),
{},
FILE_DOC_TIMEOUTS.seedRequestMs,
target
)
if (response.status === 404) return null
if (!response.ok) throw new Error(`Project document seed failed: ${response.status}`)
const body = (await response.json()) as { update?: unknown; version?: unknown }
if (typeof body.update !== 'string' || typeof body.version !== 'number')
Expand Down
Loading
Loading