Skip to content

Commit 25d7280

Browse files
committed
feat: establish durable file ownership and rollout compatibility
Add canonical entity ownership while preserving legacy workspace writers. Make storage accounting, lifecycle cleanup, search, document artifacts, and realtime transport tolerate project-owned data before feature activation. Keep Project file user/API/tool entry points in the stacked feature change.
1 parent 8f36f2d commit 25d7280

168 files changed

Lines changed: 193448 additions & 2338 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/test-build.yml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ permissions:
1010
jobs:
1111
postgres-integration:
1212
# Runs the real-infrastructure test layer: every `*.integration.ts` in packages/db and
13-
# apps/sim, discovered by glob (`vitest run --mode integration`), against the database each
13+
# apps/sim and apps/realtime, discovered by glob (`vitest run --mode integration`), against the database each
1414
# provisioning path produces. A new integration suite needs no workflow change.
1515
name: PostgreSQL integration (${{ matrix.provision }})
1616
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
@@ -94,6 +94,10 @@ jobs:
9494
TZ: America/Los_Angeles
9595
run: bun run test --mode integration
9696

97+
- name: Run apps/realtime integration tests
98+
working-directory: apps/realtime
99+
run: bun run test --mode integration
100+
97101
- name: Verify cumulative billing timeout recovery on PostgreSQL 16
98102
if: matrix.provision == 'push'
99103
working-directory: apps/sim
@@ -111,6 +115,7 @@ jobs:
111115
path: |
112116
packages/db/test-results/*.json
113117
apps/sim/test-results/*.json
118+
apps/realtime/test-results/*.json
114119
if-no-files-found: warn
115120
retention-days: 14
116121

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
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'
45
import {
56
parseRoomName,
7+
projectFileDocTarget,
68
ROOM_TYPES,
79
type RoomRef,
810
type RoomType,
@@ -327,11 +329,21 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
327329
// resolution keeps running in the background and is re-raced when the
328330
// rotation returns to this socket, so it is acted on once it settles.
329331
const role = await Promise.race([
330-
resolveCurrentRoomPermission(userId, room, fallbackRoleFor(room.type)),
332+
room.type === ROOM_TYPES.PROJECT_FILE_DOC
333+
? resolveCurrentRoomPermission(userId, room, fallbackRoleFor(room.type), socket.id)
334+
: resolveCurrentRoomPermission(userId, room, fallbackRoleFor(room.type)),
331335
sleep(Math.min(SCAN_SOCKET_TIMEOUT_MS, remainingBudget)).then(() => SCAN_TIMED_OUT),
332336
])
333337
// {@link SCAN_TIMED_OUT} is the only symbol this race can yield; matching on
334338
// the type narrows it out of the permission comparison below.
339+
if (typeof role !== 'symbol' && room.type === ROOM_TYPES.PROJECT_FILE_DOC) {
340+
const target = projectFileDocTarget(room)
341+
if (target)
342+
socket.emit(FILE_DOC_EVENTS.PERMISSION, {
343+
...target,
344+
canWrite: role === 'write' || role === 'admin',
345+
} satisfies FileDocPermission)
346+
}
335347
if (typeof role === 'symbol') {
336348
logger.warn(
337349
`Authorization check timed out for user ${userId} on ${name}; skipping this pass`

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

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc'
1+
import type { RoomAuthorizationResult } from '@sim/platform-authz/rooms'
2+
import { FILE_DOC_INTERNAL_HEADERS, FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc'
23
import { env, getBaseUrl } from '@/env'
34

45
/**
@@ -8,10 +9,24 @@ import { env, getBaseUrl } from '@/env'
89
* timeouts (and their ordering vs. the app-side bounds) live in the shared `FILE_DOC_TIMEOUTS`.
910
*/
1011

11-
function postToApp(path: string, payload: unknown, timeoutMs: number): Promise<Response> {
12+
function postToApp(
13+
path: string,
14+
payload: unknown,
15+
timeoutMs: number,
16+
actor?: { userId: string; connectionId: string }
17+
): Promise<Response> {
1218
return fetch(`${getBaseUrl()}${path}`, {
1319
method: 'POST',
14-
headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET },
20+
headers: {
21+
'Content-Type': 'application/json',
22+
'x-api-key': env.INTERNAL_API_SECRET,
23+
...(actor
24+
? {
25+
[FILE_DOC_INTERNAL_HEADERS.userId]: actor.userId,
26+
[FILE_DOC_INTERNAL_HEADERS.connectionId]: actor.connectionId,
27+
}
28+
: {}),
29+
},
1530
body: JSON.stringify(payload),
1631
signal: AbortSignal.timeout(timeoutMs),
1732
})
@@ -123,3 +138,92 @@ export async function fetchFileDocPersist(
123138
}
124139
return body
125140
}
141+
142+
interface ProjectDocumentRequest {
143+
projectId: string
144+
fileId: string
145+
userId: string
146+
connectionId: string
147+
}
148+
149+
function projectDocumentPath(
150+
target: ProjectDocumentRequest,
151+
action: 'access' | 'seed' | 'persist'
152+
) {
153+
return `/api/internal/project-file-doc/${encodeURIComponent(target.projectId)}/${encodeURIComponent(target.fileId)}/${action}`
154+
}
155+
156+
/** Resolve current Project membership without manufacturing a workspace permission or identity. */
157+
export async function fetchProjectFileDocAccess(
158+
target: ProjectDocumentRequest
159+
): Promise<RoomAuthorizationResult & { docId?: string | null }> {
160+
const response = await postToApp(
161+
projectDocumentPath(target, 'access'),
162+
{},
163+
FILE_DOC_TIMEOUTS.seedRequestMs,
164+
target
165+
)
166+
if (response.status === 403 || response.status === 404) {
167+
return { allowed: false, status: response.status, workspaceId: null, workspacePermission: null }
168+
}
169+
if (!response.ok) throw new Error(`Project document authorization failed: ${response.status}`)
170+
const body = (await response.json()) as {
171+
projectId?: unknown
172+
fileId?: unknown
173+
canRead?: unknown
174+
canWrite?: unknown
175+
docId?: unknown
176+
}
177+
if (
178+
body.projectId !== target.projectId ||
179+
body.fileId !== target.fileId ||
180+
body.canRead !== true ||
181+
typeof body.canWrite !== 'boolean' ||
182+
(body.docId !== null &&
183+
(typeof body.docId !== 'string' || !body.docId || body.docId.length > 128))
184+
)
185+
throw new Error('Malformed Project document authorization')
186+
return {
187+
allowed: true,
188+
status: 200,
189+
workspaceId: null,
190+
workspacePermission: body.canWrite ? 'write' : 'read',
191+
docId: body.docId,
192+
}
193+
}
194+
195+
/** Fetch a seed under the joining socket's current Project read access. */
196+
export async function fetchProjectFileDocSeed(
197+
target: ProjectDocumentRequest
198+
): Promise<{ update: Uint8Array; version: number }> {
199+
const response = await postToApp(
200+
projectDocumentPath(target, 'seed'),
201+
{},
202+
FILE_DOC_TIMEOUTS.seedRequestMs,
203+
target
204+
)
205+
if (!response.ok) throw new Error(`Project document seed failed: ${response.status}`)
206+
const body = (await response.json()) as { update?: unknown; version?: unknown }
207+
if (typeof body.update !== 'string' || typeof body.version !== 'number')
208+
throw new Error('Malformed Project document seed')
209+
return { update: new Uint8Array(Buffer.from(body.update, 'base64')), version: body.version }
210+
}
211+
212+
/** Persist as the authenticated author captured with the accepted stream snapshot. */
213+
export async function fetchProjectFileDocPersist(
214+
target: ProjectDocumentRequest,
215+
docState: Uint8Array,
216+
expectedVersion?: number
217+
): Promise<PersistResult> {
218+
const response = await postToApp(
219+
projectDocumentPath(target, 'persist'),
220+
{ docState: Buffer.from(docState).toString('base64'), expectedVersion },
221+
FILE_DOC_TIMEOUTS.persistRequestMs,
222+
target
223+
)
224+
if (!response.ok) throw new Error(`Project document persist failed: ${response.status}`)
225+
const body = (await response.json()) as PersistResult
226+
if (!['persisted', 'missing', 'conflict', 'deferred'].includes(body?.status))
227+
throw new Error('Malformed Project document persist')
228+
return body
229+
}

0 commit comments

Comments
 (0)