diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index 9ebd7fc19b4..a1d62419ca5 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -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 (run from apps/sim) +# Usage: http-e2e.sh (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. @@ -20,7 +20,7 @@ # telemetry flush runs detached and still writes .next/dev). set -euo pipefail -group=${1:?usage: http-e2e.sh } +group=${1:?usage: http-e2e.sh } report_dir="$RUNNER_TEMP/e2e" ready_timeout_seconds=600 mkdir -p "$report_dir" @@ -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 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2c94730d06d..fce5f4d359c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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: @@ -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: diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index e3e14dbb77a..74208acff1b 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -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", diff --git a/apps/realtime/src/access-revalidation.test.ts b/apps/realtime/src/access-revalidation.test.ts index 8d7c3ee583c..577b634cdb8 100644 --- a/apps/realtime/src/access-revalidation.test.ts +++ b/apps/realtime/src/access-revalidation.test.ts @@ -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 () => { diff --git a/apps/realtime/src/access-revalidation.ts b/apps/realtime/src/access-revalidation.ts index 95fb65ff1db..c31b1ed914b 100644 --- a/apps/realtime/src/access-revalidation.ts +++ b/apps/realtime/src/access-revalidation.ts @@ -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, @@ -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] @@ -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, @@ -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) { diff --git a/apps/realtime/src/handlers/file-doc-app.ts b/apps/realtime/src/handlers/file-doc-app.ts index 85372d7cafa..546c032acfb 100644 --- a/apps/realtime/src/handlers/file-doc-app.ts +++ b/apps/realtime/src/handlers/file-doc-app.ts @@ -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') diff --git a/apps/realtime/src/handlers/file-doc-capabilities.integration.ts b/apps/realtime/src/handlers/file-doc-capabilities.integration.ts new file mode 100644 index 00000000000..bd5b5a0b56b --- /dev/null +++ b/apps/realtime/src/handlers/file-doc-capabilities.integration.ts @@ -0,0 +1,425 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { createServer, type Server as HttpServer } from 'node:http' +import { dirname, resolve } from 'node:path' +import { db } from '@sim/db' +import { permissions, user, workspace, workspaceFiles } from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import * as roomAuthorization from '@sim/platform-authz/rooms' +import { + FILE_DOC_EVENTS, + FILE_DOC_MESSAGE_TYPE, + FILE_DOC_SCHEMA_VERSION, + FILE_DOC_SEED, + type JoinFileDocSuccess, +} from '@sim/realtime-protocol/file-doc' +import { type FileDocOwner, fileDocRoom } from '@sim/realtime-protocol/file-doc-target' +import { roomName } from '@sim/realtime-protocol/rooms' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import * as decoding from 'lib0/decoding' +import * as encoding from 'lib0/encoding' +import { Server } from 'socket.io' +import { io as connect, type Socket } from 'socket.io-client' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as syncProtocol from 'y-protocols/sync' +import * as Y from 'yjs' +import { startAccessRevalidationSweep } from '@/access-revalidation' +import { env } from '@/env' +import { + cleanupFileDocForSocket, + flushAllFileDocRooms, + setupWorkspaceFileDocHandlers, +} from '@/handlers/file-doc' +import { getFileDocStore } from '@/handlers/file-doc-store' +import type { AuthenticatedSocket } from '@/middleware/auth' +import { + beginRoomPermissionRead, + commitRoomPermission, + ROLE_REVALIDATION_TTL_MS, +} from '@/middleware/permissions' +import { MemoryRoomManager } from '@/rooms' + +const workspaceId = generateId() +const projectId = generateId() +const readerId = generateId() +const writerId = generateId() +const clients: Socket[] = [] +const documents: Y.Doc[] = [] +const files: string[] = [] +const seeds = new Map() +const persisted: { fileId: string; userId: string; body: string }[] = [] +const checks: { name: string; status: string; durationMs: number; error?: string }[] = [] +let app: HttpServer +let http: HttpServer +let io: Server +let manager: MemoryRoomManager +let socketUrl: string +let failProjectAccess = false + +async function listen(server: HttpServer) { + await new Promise((done) => server.listen(0, '127.0.0.1', done)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture port') + return `http://127.0.0.1:${address.port}` +} + +beforeAll(async () => { + await db.insert(user).values( + [readerId, writerId].map((id) => ({ + id, + name: 'Fixture', + email: `${id}@example.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Document capability fixture', + ownerId: writerId, + billedAccountUserId: writerId, + }) + await db.insert(permissions).values([ + { + id: generateId(), + userId: readerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'read', + }, + { + id: generateId(), + userId: writerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'write', + }, + ]) + await getFileDocStore().init() + app = createServer(async (request, response) => { + response.setHeader('Content-Type', 'application/json') + if (request.headers['x-api-key'] !== env.INTERNAL_API_SECRET) { + response.writeHead(401).end('{}') + return + } + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + const body = JSON.parse(Buffer.concat(chunks).toString() || '{}') + const path = request.url ?? '' + const project = path.includes('/project-file-doc/') + const fileId = project ? path.split('/')[5] : body.fileId + const actor = project ? request.headers['x-sim-subject-user-id'] : body.userId + if (path.endsWith('/access')) { + if (failProjectAccess) { + response.writeHead(503).end('{}') + return + } + response.end( + JSON.stringify({ + projectId, + fileId, + canRead: true, + canWrite: actor === writerId, + docId: generation(fileId), + }) + ) + } else if (path.endsWith('/seed')) { + response.end( + JSON.stringify({ + update: Buffer.from(seeds.get(fileId) ?? []).toString('base64'), + version: 1, + }) + ) + } else if (path.endsWith('/persist')) { + const doc = new Y.Doc() + Y.applyUpdate(doc, Buffer.from(body.docState, 'base64')) + persisted.push({ fileId, userId: actor, body: doc.getText('body').toString() }) + doc.destroy() + response.end(JSON.stringify({ status: 'persisted', version: 1 })) + } else response.writeHead(404).end('{}') + }) + env.NEXT_PUBLIC_APP_URL = await listen(app) + http = createServer() + io = new Server(http) + manager = new MemoryRoomManager(io) + io.on('connection', (socket) => { + const authed = socket as AuthenticatedSocket + authed.userId = socket.handshake.auth.actor + authed.userName = 'Fixture' + authed.userImage = 'https://example.invalid/avatar.png' + setupWorkspaceFileDocHandlers(authed, manager) + const handler = socket.listeners(FILE_DOC_EVENTS.MESSAGE)[0] + if (!handler) throw new Error('Missing message handler') + socket.off(FILE_DOC_EVENTS.MESSAGE, handler) + socket.on(FILE_DOC_EVENTS.MESSAGE, async (frame, done) => { + await handler(frame) + done?.() + }) + socket.on('disconnect', () => cleanupFileDocForSocket(socket.id, io)) + }) + socketUrl = await listen(http) +}) + +function generation(fileId: string) { + return `doc-${fileId}` +} + +async function fixture(entityType: FileDocOwner['entityType']) { + const fileId = generateId() + files.push(fileId) + const owner = { entityType, entityId: entityType === 'workspace' ? workspaceId : projectId } + if (entityType === 'workspace') + await db.insert(workspaceFiles).values({ + id: fileId, + workspaceId, + userId: writerId, + key: fileId, + context: 'workspace', + originalName: `${fileId}.md`, + contentType: 'text/markdown', + }) + const doc = new Y.Doc() + documents.push(doc) + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, generation(fileId)) + seeds.set(fileId, Y.encodeStateAsUpdate(doc)) + return { fileId, owner } +} + +async function join(target: Awaited>, actor: string) { + const socket = connect(socketUrl, { transports: ['websocket'], auth: { actor } }) + clients.push(socket) + await new Promise((done) => socket.once('connect', done)) + const doc = new Y.Doc() + documents.push(doc) + socket.on(FILE_DOC_EVENTS.MESSAGE, (data: Uint8Array) => { + const decoder = decoding.createDecoder(new Uint8Array(data)) + if (decoding.readVarUint(decoder) !== FILE_DOC_MESSAGE_TYPE.SYNC) return + const reply = encoding.createEncoder() + syncProtocol.readSyncMessage(decoder, reply, doc, 'remote') + }) + const joined = new Promise((done, reject) => { + socket.once(FILE_DOC_EVENTS.JOIN_SUCCESS, done) + socket.once(FILE_DOC_EVENTS.JOIN_ERROR, (error) => reject(new Error(error.code))) + }) + socket.emit(FILE_DOC_EVENTS.JOIN, { + ...target, + clientId: doc.clientID, + schemaVersion: FILE_DOC_SCHEMA_VERSION, + }) + return { socket, doc, joined: await joined } +} + +function frame(type: number, subtype: number, update: Uint8Array) { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, type) + encoding.writeVarUint(encoder, subtype) + encoding.writeVarUint8Array(encoder, update) + return encoding.toUint8Array(encoder) +} + +function edit(value: string) { + const doc = new Y.Doc() + doc.getText('body').insert(0, value) + const update = Y.encodeStateAsUpdate(doc) + doc.destroy() + return update +} + +function check(name: string, run: () => Promise) { + it(name, async () => { + const start = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +for (const scope of ['workspace', 'project'] as const) + describe(`${scope} document capabilities over Socket.IO, Redis and authorization`, () => { + check( + `${scope} readers receive edits and presence but cannot write through any protocol variant`, + async () => { + const target = await fixture(scope) + const reader = await join(target, readerId) + const writer = await join(target, writerId) + expect(reader.joined.canWrite).toBe(false) + expect(writer.joined.canWrite).toBe(true) + const update = edit('forged') + for (const type of [FILE_DOC_MESSAGE_TYPE.SYNC, FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST]) { + for (const subtype of [syncProtocol.messageYjsSyncStep2, syncProtocol.messageYjsUpdate]) + await reader.socket + .timeout(3000) + .emitWithAck(FILE_DOC_EVENTS.MESSAGE, frame(type, subtype, update)) + } + expect( + await reader.socket.timeout(3000).emitWithAck(FILE_DOC_EVENTS.UPDATE, { + ...target, + docId: generation(target.fileId), + updateId: generateId(), + update, + }) + ).toMatchObject({ status: 'rejected', code: 'ACCESS_REVOKED' }) + const awareness = new awarenessProtocol.Awareness(reader.doc) + awareness.setLocalState({ cursor: { position: 1 } }) + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(awareness, [reader.doc.clientID]) + ) + const presence = new Promise((done) => + writer.socket.on(FILE_DOC_EVENTS.MESSAGE, (data: Uint8Array) => { + if ( + decoding.readVarUint(decoding.createDecoder(new Uint8Array(data))) === + FILE_DOC_MESSAGE_TYPE.AWARENESS + ) + done() + }) + ) + await reader.socket + .timeout(3000) + .emitWithAck(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + await presence + awareness.destroy() + expect( + await writer.socket.timeout(3000).emitWithAck(FILE_DOC_EVENTS.UPDATE, { + ...target, + docId: generation(target.fileId), + updateId: generateId(), + update: edit('accepted'), + }) + ).toMatchObject({ status: 'accepted' }) + await expect.poll(() => reader.doc.getText('body').toString()).toBe('accepted') + await flushAllFileDocRooms() + expect(persisted.filter((entry) => entry.fileId === target.fileId).at(-1)).toEqual({ + fileId: target.fileId, + userId: writerId, + body: 'accepted', + }) + } + ) + + check( + `${scope} legacy editors persist with their authenticated identity after a viewer joins`, + async () => { + const target = await fixture(scope) + const writer = await join(target, writerId) + await join(target, readerId) + for (const subtype of [syncProtocol.messageYjsSyncStep2, syncProtocol.messageYjsUpdate]) + await writer.socket + .timeout(3000) + .emitWithAck( + FILE_DOC_EVENTS.MESSAGE, + frame(FILE_DOC_MESSAGE_TYPE.SYNC, subtype, edit('legacy')) + ) + await flushAllFileDocRooms() + expect(persisted.filter((entry) => entry.fileId === target.fileId).at(-1)).toEqual({ + fileId: target.fileId, + userId: writerId, + body: 'legacylegacy', + }) + } + ) + + check(`${scope} downgrade retains live reads and a later revocation evicts`, async () => { + const target = await fixture(scope) + const writer = await join(target, writerId) + const room = fileDocRoom(target) + commitRoomPermission(writerId, room, 'read', beginRoomPermissionRead()) + const sweep = startAccessRevalidationSweep(manager) + try { + const permission: { canWrite: boolean }[] = [] + writer.socket.on(FILE_DOC_EVENTS.PERMISSION, (value) => permission.push(value)) + await sweep.runOnce() + await expect.poll(() => permission.at(-1)?.canWrite, { timeout: 1000 }).toBe(false) + expect(io.sockets.sockets.get(writer.socket.id ?? '')?.rooms.has(roomName(room))).toBe(true) + expect( + await writer.socket.timeout(3000).emitWithAck(FILE_DOC_EVENTS.UPDATE, { + ...target, + docId: generation(target.fileId), + updateId: generateId(), + update: edit('denied'), + }) + ).toMatchObject({ status: 'rejected', code: 'ACCESS_REVOKED' }) + commitRoomPermission(writerId, room, 'write', beginRoomPermissionRead()) + expect( + await writer.socket.timeout(3000).emitWithAck(FILE_DOC_EVENTS.UPDATE, { + ...target, + docId: generation(target.fileId), + updateId: generateId(), + update: edit('restored'), + }) + ).toMatchObject({ status: 'accepted' }) + commitRoomPermission(writerId, room, null, beginRoomPermissionRead()) + await sweep.runOnce() + expect(io.sockets.sockets.get(writer.socket.id ?? '')?.rooms.has(roomName(room))).toBe( + false + ) + } finally { + sweep.stop() + } + }) + + check( + `${scope} expired authorization plus service failure retains membership without granting writes`, + async () => { + const target = await fixture(scope) + const writer = await join(target, writerId) + const now = Date.now() + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + ROLE_REVALIDATION_TTL_MS + 1) + const authorization = + scope === 'workspace' + ? vi + .spyOn(roomAuthorization, 'authorizeRoom') + .mockRejectedValue(new Error('Fixture authorization outage')) + : null + failProjectAccess = true + try { + expect( + await writer.socket.timeout(3000).emitWithAck(FILE_DOC_EVENTS.UPDATE, { + ...target, + docId: generation(target.fileId), + updateId: generateId(), + update: edit('uncertain'), + }) + ).toMatchObject({ status: 'rejected', code: 'TEMPORARY_FAILURE', retryable: true }) + expect( + io.sockets.sockets.get(writer.socket.id ?? '')?.rooms.has(roomName(fileDocRoom(target))) + ).toBe(true) + } finally { + authorization?.mockRestore() + clock.mockRestore() + failProjectAccess = false + } + } + ) + }) + +afterAll(async () => { + for (const socket of clients) socket.disconnect() + if (io) await new Promise((done) => io.close(() => done())) + await flushAllFileDocRooms() + await getFileDocStore().shutdown() + if (app) await new Promise((done) => app.close(() => done())) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + for (const id of [readerId, writerId]) await db.delete(user).where(eq(user.id, id)) + for (const doc of documents) doc.destroy() + const report = + process.env.FILE_DOC_CAPABILITIES_REPORT_PATH ?? + resolve('test-results/file-doc-capabilities.json') + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks }, null, 2)) +}) diff --git a/apps/realtime/src/handlers/file-doc-editor.integration.ts b/apps/realtime/src/handlers/file-doc-editor.integration.ts index 4d7c4ee267d..2be2cf58c87 100644 --- a/apps/realtime/src/handlers/file-doc-editor.integration.ts +++ b/apps/realtime/src/handlers/file-doc-editor.integration.ts @@ -22,8 +22,11 @@ beforeAll(async () => { await store.init() }) -async function fixture() { - const name = `project-file-doc:${generateId()}/${generateId()}` +async function fixture(scope: 'project' | 'workspace' = 'project') { + const name = + scope === 'project' + ? `project-file-doc:${generateId()}/${generateId()}` + : `workspace-file-doc:${generateId()}` const doc = new Y.Doc() const generation = generateId() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) @@ -75,16 +78,15 @@ describe('Project document editor attribution over real Redis', () => { } ) - check( - 'Project updates without an authenticated editor are rejected before acceptance', - async () => { - const f = await fixture() + for (const scope of ['workspace', 'project'] as const) { + check(`${scope} acknowledged updates require an authenticated editor`, async () => { + const f = await fixture(scope) await expect( store.publishClientUpdateAndWait(f.name, 'unknown', update(f.doc, 'X'), f.generation) - ).rejects.toThrow('Project document updates require an authenticated editor') + ).rejects.toThrow('Document updates require an authenticated editor') expect(await redis.xLen(`filedoc:stream:${f.name}`)).toBe(1) - } - ) + }) + } check( 'real compaction preserves the editor for a headless replica reconstructing accepted bytes', @@ -200,7 +202,7 @@ describe('Project document editor attribution over real Redis', () => { ) check( - 'a late prefix snapshot cannot replace a newer editor and unknown newer edits erase attribution', + 'a late prefix snapshot and durable merge preserve the accepted editor without carrying it into a new generation', async () => { const f = await fixture() await store.publishClientUpdateAndWait( @@ -224,10 +226,35 @@ describe('Project document editor attribution over real Redis', () => { ei: entry.id, }) expect((await store.getStreamSnapshot(f.name, f.generation))?.editor).toEqual(bob) - await redis.xAdd(`filedoc:stream:${f.name}`, '*', { - u: Buffer.from(update(f.doc, '?')).toString('base64'), - }) - expect((await store.getStreamSnapshot(f.name, f.generation))?.editor).toBeNull() + await store.publishAndWait(f.name, update(f.doc, '-durable'), f.generation) + const merged = await store.getStreamSnapshot(f.name, f.generation) + expect(merged?.editor).toEqual(bob) + const restored = new Y.Doc() + const replacement = new Y.Doc() + try { + if (!merged) throw new Error('Missing merged snapshot') + Y.applyUpdate(restored, merged.docState) + expect(restored.getText('body').toString()).toBe('AB-durable') + await store.invalidateDocument(f.name, 2) + const replacementId = generateId() + replacement.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + replacement.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, replacementId) + await store.seedIfEmpty(f.name, Y.encodeStateAsUpdate(replacement), 2) + await store.publishAndWait(f.name, update(replacement, 'only-durable'), replacementId) + const next = await store.getStreamSnapshot(f.name, replacementId) + expect(next?.editor).toBeNull() + const nextDoc = new Y.Doc() + try { + if (!next) throw new Error('Missing replacement snapshot') + Y.applyUpdate(nextDoc, next.docState) + expect(nextDoc.getText('body').toString()).toBe('only-durable') + } finally { + nextDoc.destroy() + } + } finally { + restored.destroy() + replacement.destroy() + } } ) }) diff --git a/apps/realtime/src/handlers/file-doc-owner.ts b/apps/realtime/src/handlers/file-doc-owner.ts new file mode 100644 index 00000000000..2cdd68e9417 --- /dev/null +++ b/apps/realtime/src/handlers/file-doc-owner.ts @@ -0,0 +1,76 @@ +import type { FileDocOwner, FileDocTarget } from '@sim/realtime-protocol/file-doc-target' +import { + fetchFileDocPersist, + fetchFileDocSeed, + fetchProjectFileDocPersist, + fetchProjectFileDocSeed, + type PersistResult, +} from '@/handlers/file-doc-app' +import type { FileDocEditor } from '@/handlers/file-doc-store' + +interface PersistenceActor { + userId: string + connectionId: string | null +} + +interface FileDocOwnerAdapter { + allowLocalSnapshotFallback: boolean + tracksLifecycle: boolean + seed( + target: FileDocTarget, + actor?: FileDocEditor + ): Promise<{ update: Uint8Array; version: number } | null> + persist( + target: FileDocTarget, + actor: PersistenceActor, + state: Uint8Array, + version?: number + ): Promise +} + +function requireOwner(target: FileDocTarget, entityType: FileDocOwner['entityType']): string { + if (!target.owner || target.owner.entityType !== entityType || !target.owner.entityId) + throw new Error('Document has no canonical owner context') + return target.owner.entityId +} + +function projectRequest(target: FileDocTarget, actor?: PersistenceActor) { + if (!actor?.userId || !actor.connectionId) + throw new Error('Document callback requires an authenticated editor') + return { + projectId: requireOwner(target, 'project'), + fileId: target.fileId, + userId: actor.userId, + connectionId: actor.connectionId, + } +} + +const OWNER_ADAPTERS: Record = { + workspace: { + allowLocalSnapshotFallback: true, + tracksLifecycle: false, + seed: (target) => fetchFileDocSeed(requireOwner(target, 'workspace'), target.fileId), + persist: (target, actor, state, version) => + fetchFileDocPersist( + requireOwner(target, 'workspace'), + target.fileId, + actor.userId, + state, + version + ), + }, + project: { + allowLocalSnapshotFallback: false, + tracksLifecycle: true, + seed: (target, actor) => fetchProjectFileDocSeed(projectRequest(target, actor)), + persist: (target, actor, state, version) => + fetchProjectFileDocPersist(projectRequest(target, actor), state, version), + }, +} + +/** Select callbacks only after the document's canonical owner has been resolved. */ +export function fileDocOwnerAdapter(owner: FileDocOwner): FileDocOwnerAdapter { + const type = owner.entityType + if (!Object.hasOwn(OWNER_ADAPTERS, type)) throw new Error('Unsupported document owner') + return OWNER_ADAPTERS[type] +} diff --git a/apps/realtime/src/handlers/file-doc-project.integration.ts b/apps/realtime/src/handlers/file-doc-project.integration.ts index b51d5e0528d..4bbe641af25 100644 --- a/apps/realtime/src/handlers/file-doc-project.integration.ts +++ b/apps/realtime/src/handlers/file-doc-project.integration.ts @@ -1,6 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { createServer, type Server as HttpServer } from 'node:http' import { dirname, resolve } from 'node:path' +import { createLogger } from '@sim/logger' import { ROOM_ACCESS_REVOKED_EVENT, type RoomAccessRevokedBroadcast, @@ -39,6 +40,7 @@ import { ROLE_REVALIDATION_TTL_MS, } from '@/middleware/permissions' import { MemoryRoomManager } from '@/rooms' +import { createHttpHandler } from '@/routes/http' const projectId = generateId() const fileId = generateId() @@ -66,6 +68,7 @@ let http: HttpServer let io: Server let socketUrl: string const capturedActors: string[] = [] +const seedFailures = new Map() const accessGates = new Map< string, { @@ -136,6 +139,11 @@ beforeAll(async () => { return } if (request.url.endsWith('/seed')) { + const failure = seedFailures.get(actor) + if (failure) { + response.writeHead(failure).end('{}') + return + } response.end(JSON.stringify({ update: seed, version })) return } @@ -155,6 +163,7 @@ beforeAll(async () => { http = createServer() io = new Server(http) const manager = new MemoryRoomManager(io) + http.on('request', createHttpHandler(manager, createLogger('OwnerProtocolFixture'))) io.on('connection', (socket) => { const authed = socket as AuthenticatedSocket authed.userId = socket.handshake.auth.actor @@ -166,7 +175,7 @@ beforeAll(async () => { socketUrl = await listen(http) }) -async function startJoin(actor: string, assertedProject = projectId) { +async function startJoin(actor: string, assertedProject = projectId, owner?: unknown) { const socket = connect(socketUrl, { transports: ['websocket'], auth: { actor } }) clients.push(socket) await new Promise((resolve) => socket.once('connect', resolve)) @@ -179,15 +188,15 @@ async function startJoin(actor: string, assertedProject = projectId) { client.destroy() socket.emit(FILE_DOC_EVENTS.JOIN, { fileId, - projectId: assertedProject, + ...(owner === undefined ? { projectId: assertedProject } : { owner }), clientId, schemaVersion: FILE_DOC_SCHEMA_VERSION, }) return { socket, joined } } -async function join(actor: string, assertedProject = projectId) { - const pending = await startJoin(actor, assertedProject) +async function join(actor: string, assertedProject = projectId, owner?: unknown) { + const pending = await startJoin(actor, assertedProject, owner) return { socket: pending.socket, joined: await pending.joined } } @@ -210,6 +219,29 @@ function check(name: string, run: () => Promise) { } describe('Project documents across the Socket.IO and internal HTTP boundary', () => { + for (const status of [404, 503]) { + check( + `a seed response of ${status} distinguishes a missing file from a failed join`, + async () => { + const actor = `seed-reader-${generateId()}` + actors.set(actor, 'read') + seedFailures.set(actor, status) + const pending = await startJoin(actor) + try { + await expect(pending.joined).rejects.toThrow(status === 404 ? 'NOT_FOUND' : 'JOIN_FAILED') + expect( + io.sockets.sockets + .get(pending.socket.id ?? '') + ?.rooms.has(`project-file-doc:${projectId}/${fileId}`) + ).toBe(false) + } finally { + seedFailures.delete(actor) + pending.socket.disconnect() + } + } + ) + } + check('readers subscribe but cannot submit edits or become the persistence actor', async () => { const reader = await join('reader') expect(reader.joined.canWrite).toBe(false) @@ -373,12 +405,15 @@ describe('Project documents across the Socket.IO and internal HTTP boundary', () try { socket.emit(FILE_DOC_EVENTS.JOIN, { fileId, - projectId, + owner: { entityType: 'project', entityId: projectId }, clientId: provider.clientID, schemaVersion: FILE_DOC_SCHEMA_VERSION, }) await gate.entered.promise - const otherOwner = { fileId, projectId: generateId() } + const otherOwner = { + fileId, + owner: { entityType: 'project' as const, entityId: generateId() }, + } if (action === 'join') socket.emit(FILE_DOC_EVENTS.JOIN, { ...otherOwner, @@ -524,6 +559,44 @@ describe('Project documents across the Socket.IO and internal HTTP boundary', () } ) + check( + 'owner-shaped clients share the existing room and preserve editor attribution', + async () => { + const owner = { entityType: 'project', entityId: projectId } + const writer = await join('writer', projectId, owner) + expect(writer.joined.docId).toBe(docId) + const edit = new Y.Doc() + Y.applyUpdate(edit, Buffer.from(seed, 'base64')) + edit.getText('body').insert(0, 'owner-shaped edit') + const reply = await writer.socket.timeout(1500).emitWithAck(FILE_DOC_EVENTS.UPDATE, { + fileId, + owner, + docId, + updateId: generateId(), + update: Y.encodeStateAsUpdate(edit), + }) + expect(reply).toMatchObject({ status: 'accepted' }) + await flushAllFileDocRooms() + expect(capturedActors.at(-1)).toBe('writer') + edit.destroy() + await expect(join('reader', projectId, { ...owner, entityId: generateId() })).rejects.toThrow( + 'NOT_FOUND' + ) + await expect( + join('reader', projectId, { entityType: 'organization', entityId: projectId }) + ).rejects.toThrow('INVALID_PAYLOAD') + const mismatched = await writer.socket.timeout(1500).emitWithAck(FILE_DOC_EVENTS.UPDATE, { + fileId, + owner, + projectId: generateId(), + docId, + updateId: generateId(), + update: Y.encodeStateAsUpdate(document), + }) + expect(mismatched).toMatchObject({ status: 'rejected', code: 'INVALID_UPDATE' }) + } + ) + check( 'callback outages preserve membership and acknowledge writes as retryable; revocations still evict', async () => { @@ -700,6 +773,52 @@ describe('Project documents across the Socket.IO and internal HTTP boundary', () expect(restored.joined.docId).toBe(docId) } ) + + check( + 'HTTP owner targeting fences the same generation and refuses unsupported or conflicting owners', + async () => { + const owner = { entityType: 'project', entityId: projectId } + async function post(action: string, body: object) { + return fetch(`${socketUrl}/api/file-doc/${action}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify(body), + }) + } + for (const action of ['apply-edit', 'invalidate', 'retire']) { + const body = { + fileId, + owner, + markdown: '', + version: 100, + retiredDocId: docId, + replacementDocId: generateId(), + } + expect((await post(action, { ...body, projectId: generateId() })).status).toBe(400) + expect( + ( + await post(action, { + ...body, + owner: { entityType: 'organization', entityId: projectId }, + }) + ).status + ).toBe(400) + } + const joined = await join('reader', projectId, owner) + const invalidation = new Promise<{ docId: string }>((resolve) => + joined.socket.once(FILE_DOC_EVENTS.INVALIDATED, resolve) + ) + const response = await post('retire', { + fileId, + owner, + retiredDocId: docId, + replacementDocId: generateId(), + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ status: 'applied' }) + expect((await invalidation).docId).toBe(docId) + } + ) check('a delayed authorization cannot retire a replacement with acknowledged edits', async () => { await join('reader') const actor = `delayed-${generateId()}` diff --git a/apps/realtime/src/handlers/file-doc-store.test.ts b/apps/realtime/src/handlers/file-doc-store.test.ts index e188371a6bf..96a2e32a94b 100644 --- a/apps/realtime/src/handlers/file-doc-store.test.ts +++ b/apps/realtime/src/handlers/file-doc-store.test.ts @@ -224,7 +224,7 @@ function makeClient(): TestRedisClient { if (members.includes(member)) return 0 const id = nextId() const arr = b().streams.get(key) ?? [] - arr.push({ id, message: { [field]: value } }) + arr.push({ id, message: { [field]: value, eu: opts.arguments[6], ec: opts.arguments[7] } }) b().streams.set(key, arr) members.push(member) const capacity = Number(capacityText) @@ -263,6 +263,9 @@ function makeClient(): TestRedisClient { [marker]: '1', [generationField]: expectedGeneration, ...(compactionField ? { [compactionField]: '1' } : {}), + eu: opts.arguments[7], + ec: opts.arguments[8], + ei: opts.arguments[9], }, }) b().streams.set(key, arr) @@ -393,6 +396,60 @@ async function newStore(): Promise { } describe('FileDocStore', () => { + it('retains only an accepted editor across durable merges, compaction, and generation replacement', async () => { + const store = await newStore() + const source = docWithText('base') + source.getMap('config').set('docId', 'generation-1') + source.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + const replica = new Y.Doc() + try { + await store.seedIfEmpty(NAME, Y.encodeStateAsUpdate(source), 10) + let vector = Y.encodeStateVector(source) + source.getText('body').insert(4, '-durable') + await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, vector), 'generation-1') + expect((await store.getStreamSnapshot(NAME, 'generation-1'))?.editor).toBeNull() + vector = Y.encodeStateVector(source) + source.getText('body').insert(source.getText('body').length, '-writer') + const actor = { userId: 'actual-editor', connectionId: 'authenticated-socket' } + await store.publishClientUpdateAndWait( + NAME, + 'edit-1', + Y.encodeStateAsUpdate(source, vector), + 'generation-1', + actor + ) + vector = Y.encodeStateVector(source) + source.getText('body').insert(source.getText('body').length, '-external') + await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, vector), 'generation-1') + const snapshot = await store.getStreamSnapshot(NAME, 'generation-1') + expect(snapshot?.editor).toEqual(actor) + if (!snapshot) throw new Error('Expected accepted snapshot') + Y.applyUpdate(replica, snapshot.docState) + expect(replica.getText('body').toString()).toBe('base-durable-writer-external') + await store.attachRoom(NAME, replica) + const room = storeInternals(store).rooms.get(NAME) + if (!room) throw new Error('Expected attached room') + room.uncompactedDeltaBytes = 12 * 1024 * 1024 + await storeInternals(store).maybeCompact(NAME) + expect((await store.getStreamSnapshot(NAME, 'generation-1'))?.editor).toEqual(actor) + store.detachRoom(NAME) + await store.invalidateDocument(NAME, 20) + const replacement = docWithText('replacement') + replacement.getMap('config').set('docId', 'generation-2') + replacement.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + try { + await store.seedIfEmpty(NAME, Y.encodeStateAsUpdate(replacement), 20) + expect((await store.getStreamSnapshot(NAME, 'generation-2'))?.editor).toBeNull() + } finally { + replacement.destroy() + } + } finally { + store.detachRoom(NAME) + source.destroy() + replica.destroy() + } + }) + beforeEach(() => { state.backing = { streams: new Map(), @@ -467,7 +524,13 @@ describe('FileDocStore', () => { 'replaced by a newer durable version' ) await expect( - store.publishClientUpdateAndWait(NAME, 'stale-update', updateFor('stale acknowledged write')) + store.publishClientUpdateAndWait( + NAME, + 'stale-update', + updateFor('stale acknowledged write'), + undefined, + { userId: 'editor', connectionId: 'socket-editor' } + ) ).rejects.toThrow('replaced by a newer durable version') const fresh = seedFor('fresh generation') @@ -494,15 +557,24 @@ describe('FileDocStore', () => { await store.seedIfEmpty(NAME, seedFor('base'), 10) const generation = await store.getDocumentGeneration(NAME) const delta = updateFor('edit') - await store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation) + await store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation, { + userId: 'editor', + connectionId: 'socket-editor', + }) state.backing!.streams.delete(`filedoc:stream:${NAME}`) await expect(store.publishAndWait(NAME, delta, generation)).rejects.toThrow('replaced') await expect( - store.publishClientUpdateAndWait(NAME, 'new-update', delta, generation) + store.publishClientUpdateAndWait(NAME, 'new-update', delta, generation, { + userId: 'editor', + connectionId: 'socket-editor', + }) ).rejects.toThrow('replaced') await expect( - store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation) + store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation, { + userId: 'editor', + connectionId: 'socket-editor', + }) ).rejects.toThrow('replaced') expect(state.backing!.streams.has(`filedoc:stream:${NAME}`)).toBe(false) }) @@ -759,8 +831,14 @@ describe('FileDocStore', () => { const store = await newStore() const update = updateFor('retry-safe') - await store.publishClientUpdateAndWait(NAME, 'update-1', update) - await store.publishClientUpdateAndWait(NAME, 'update-1', update) + await store.publishClientUpdateAndWait(NAME, 'update-1', update, undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) + await store.publishClientUpdateAndWait(NAME, 'update-1', update, undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(2) }) @@ -769,8 +847,14 @@ describe('FileDocStore', () => { seedLegacyStream() const store = await newStore() - await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('first')) - await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('second')) + await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('first'), undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) + await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('second'), undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(3) }) @@ -820,7 +904,10 @@ describe('FileDocStore', () => { state.backing!.onSnapshot = async () => { await replacer.invalidateDocument(NAME, 20) await replacer.seedIfEmpty(NAME, freshSeed, 20) - await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation') + await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation', { + userId: 'editor', + connectionId: 'socket-editor', + }) expect(state.backing!.streams.get(streamKey)?.map((entry) => entry.id)).toEqual([ '1000-0', '1000-1', @@ -833,7 +920,10 @@ describe('FileDocStore', () => { '1000-0', '1000-1', ]) - await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation') + await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation', { + userId: 'editor', + connectionId: 'socket-editor', + }) expect(state.backing!.streams.get(streamKey)).toHaveLength(2) const persisted = await replacer.getStreamState(NAME) expect(persisted).not.toBeNull() @@ -852,7 +942,10 @@ describe('FileDocStore', () => { await expect(store.attachRoom(NAME, doc)).rejects.toThrow('not initialized') await expect( - store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('x')) + store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('x'), undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) ).rejects.toThrow('not initialized') await expect(store.seedIfEmpty(NAME, seedFor('seed'))).rejects.toThrow('not initialized') await expect(store.getStreamState(NAME)).rejects.toThrow('not initialized') diff --git a/apps/realtime/src/handlers/file-doc-store.ts b/apps/realtime/src/handlers/file-doc-store.ts index 0bac89af46e..1694416b774 100644 --- a/apps/realtime/src/handlers/file-doc-store.ts +++ b/apps/realtime/src/handlers/file-doc-store.ts @@ -329,11 +329,9 @@ function advanceEditor( ? message.ei : id if (current && !isAfterStreamId(sourceId, current.id)) return current - const editor = - message.eu?.trim() && message.ec?.trim() - ? { userId: message.eu, connectionId: message.ec } - : null - return { id: sourceId, editor } + /** Durable external merges carry no editor; retain only a previously authenticated writer. */ + if (!message.eu?.trim() || !message.ec?.trim()) return current + return { id: sourceId, editor: { userId: message.eu, connectionId: message.ec } } } /** One locally-open room the store tracks: its doc and the last stream id applied to it. */ @@ -608,11 +606,8 @@ export class FileDocStore { expectedGeneration = this.rooms.get(name)?.generation ?? '', editor?: FileDocEditor ): Promise { - if ( - name.startsWith('project-file-doc:') && - (!editor?.userId.trim() || !editor.connectionId.trim()) - ) { - throw new Error('Project document updates require an authenticated editor') + if (!editor?.userId.trim() || !editor.connectionId.trim()) { + throw new Error('Document updates require an authenticated editor') } if (!this.enabled) return if (!this.write) throw new Error('FileDocStore is not initialized') diff --git a/apps/realtime/src/handlers/file-doc.multireplica.test.ts b/apps/realtime/src/handlers/file-doc.multireplica.test.ts index e8fb74d7dd7..d78cbb84c19 100644 --- a/apps/realtime/src/handlers/file-doc.multireplica.test.ts +++ b/apps/realtime/src/handlers/file-doc.multireplica.test.ts @@ -62,9 +62,11 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' it('drops a stale durable write against the SHARED synced version', async () => { // A durable write (e.g. a concurrent human save on another process) records the shared synced version. - expect(await applyMarkdownToLiveFileDoc('file-1', '# durable', { version: 100 })).toBe( - 'applied' - ) + expect( + await applyMarkdownToLiveFileDoc({ type: 'workspace-file-doc', id: 'file-1' }, '# durable', { + version: 100, + }) + ).toBe('applied') expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100, 'shared-generation') expect(fakeStore.getStreamState).toHaveBeenCalledWith(ROOM_NAME, 'shared-generation') expect(fakeStore.publishAndWait).toHaveBeenCalledWith( @@ -76,15 +78,23 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' // A durable write with an OLDER version than the SHARED synced version is stale — rejected under the // lock before any diff is built, so it can't regress the doc across replicas. - expect(await applyMarkdownToLiveFileDoc('file-1', '# older durable', { version: 50 })).toBe( - 'stale' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# older durable', + { version: 50 } + ) + ).toBe('stale') expect(mockFetchFileDocMerge).not.toHaveBeenCalled() // A newer durable write applies and advances the shared synced version. - expect(await applyMarkdownToLiveFileDoc('file-1', '# durable again', { version: 150 })).toBe( - 'applied' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# durable again', + { version: 150 } + ) + ).toBe('applied') expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 150, 'shared-generation') // setSyncedVersion fired only for the two applied durable writes, never for the stale one. expect(fakeStore.setSyncedVersion).toHaveBeenCalledTimes(2) @@ -98,7 +108,11 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' fakeStore.isAgentStreaming.mockResolvedValue(true) expect( - await applyMarkdownToLiveFileDoc('file-1', '# streamed by a client', { version: 100 }) + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# streamed by a client', + { version: 100 } + ) ).toBe('applied') expect(mockFetchFileDocMerge).not.toHaveBeenCalled() // content deferred to the client expect(fakeStore.publishAndWait).not.toHaveBeenCalled() @@ -106,9 +120,13 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' // Once streaming stops the flag clears and the (now near-noop) durable merge resumes normally. fakeStore.isAgentStreaming.mockResolvedValue(false) - expect(await applyMarkdownToLiveFileDoc('file-1', '# final durable', { version: 150 })).toBe( - 'applied' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# final durable', + { version: 150 } + ) + ).toBe('applied') expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index e20140897c0..5f30b7ed880 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -4,6 +4,7 @@ import { FILE_DOC_SCHEMA_VERSION, FILE_DOC_SEED, } from '@sim/realtime-protocol/file-doc' +import { fileDocAdmissionRoom } from '@sim/realtime-protocol/file-doc-target' import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { flushMicrotasks } from '@sim/testing/helpers' import { sleep } from '@sim/utils/helpers' @@ -36,7 +37,6 @@ vi.mock('@/handlers/file-doc-app', () => ({ import { applyMarkdownToLiveFileDoc, cleanupFileDocForSocket, - fileDocAdmissionRoom, flushAllFileDocRooms, invalidateLiveFileDocument, setupWorkspaceFileDocHandlers, @@ -391,7 +391,8 @@ describe('setupWorkspaceFileDocHandlers', () => { source.getText(FILE_DOC_FIELD).insert(0, 'Stale text') const acknowledge = vi.fn() try { - if (timing === 'before append') await invalidateLiveFileDocument('file-1', 2) + if (timing === 'before append') + await invalidateLiveFileDocument({ type: 'workspace-file-doc', id: 'file-1' }, 2) sent.length = 0 handlers[FILE_DOC_EVENTS.UPDATE]( { @@ -404,7 +405,7 @@ describe('setupWorkspaceFileDocHandlers', () => { ) if (timing !== 'before append') { expect(publish).toHaveBeenCalledTimes(1) - await invalidateLiveFileDocument('file-1', 2) + await invalidateLiveFileDocument({ type: 'workspace-file-doc', id: 'file-1' }, 2) if (timing === 'during append and reseed') { mockFetchFileDocSeed.mockResolvedValue({ ...seedResult('Replacement', 'doc-new'), @@ -527,7 +528,7 @@ describe('setupWorkspaceFileDocHandlers', () => { // A real user edit (socket-origin sync update) marks the doc dirty. const edit = new Y.Doc() edit.getText(FILE_DOC_FIELD).insert(0, 'user typed this') - handlers[FILE_DOC_EVENTS.MESSAGE]( + await handlers[FILE_DOC_EVENTS.MESSAGE]( frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) ) @@ -605,8 +606,8 @@ describe('setupWorkspaceFileDocHandlers', () => { const edit = new Y.Doc() edit.getText(FILE_DOC_FIELD).insert(0, 'edit after revocation') - const editFrame = () => - handlers[FILE_DOC_EVENTS.MESSAGE]( + const editFrame = async () => + await handlers[FILE_DOC_EVENTS.MESSAGE]( frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) ) @@ -651,7 +652,7 @@ describe('setupWorkspaceFileDocHandlers', () => { const before = sent.length const edit = new Y.Doc() edit.getText(FILE_DOC_FIELD).insert(0, 'agent streamed this') - handlers[FILE_DOC_EVENTS.MESSAGE]( + await handlers[FILE_DOC_EVENTS.MESSAGE]( frame(FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST, (e) => syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) ) @@ -688,7 +689,7 @@ describe('setupWorkspaceFileDocHandlers', () => { const edit = new Y.Doc() edit.getText(FILE_DOC_FIELD).insert(0, 'user typed this') - handlers[FILE_DOC_EVENTS.MESSAGE]( + await handlers[FILE_DOC_EVENTS.MESSAGE]( frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) ) @@ -750,8 +751,8 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(memberships.has(ROOM_NAME)).toBe(false) resolvePermission(permission) await joining - expect(memberships.has(ROOM_NAME)).toBe(permission === 'write') - if (permission === 'write') { + expect(memberships.has(ROOM_NAME)).toBe(permission !== null) + if (permission !== null) { expect(joinSuccessFileId(pending.socket)).toBe('file-1') expect(pending.socket.emit).toHaveBeenCalledWith( FILE_DOC_EVENTS.MESSAGE, @@ -801,7 +802,7 @@ describe('setupWorkspaceFileDocHandlers', () => { commitRoomPermission( 'user-1', { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-1' }, - 'read', + null, beginRoomPermissionRead() ) } else if (access === 'expired') { @@ -809,7 +810,7 @@ describe('setupWorkspaceFileDocHandlers', () => { } finishSubscription() await joining - expect(memberships.has(fileDocAdmissionRoom('file-1'))).toBe(false) + expect(memberships.has(fileDocAdmissionRoom({ fileId: 'file-1' }))).toBe(false) expect(memberships.has(ROOM_NAME)).toBe(access === 'unchanged') if (access === 'unchanged') { expect(joinSuccessFileId(pending.socket)).toBe('file-1') @@ -866,7 +867,7 @@ describe('setupWorkspaceFileDocHandlers', () => { // The joiner that never triggered the fetch is served the seeded document all the same. b.socket.emit.mockClear() - b.handlers[FILE_DOC_EVENTS.MESSAGE]( + await b.handlers[FILE_DOC_EVENTS.MESSAGE]( frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc())) ) const reply = b.socket.emit.mock.calls.find( @@ -930,7 +931,7 @@ describe('setupWorkspaceFileDocHandlers', () => { // A document frame sent before the join was answered reaches an unbound socket and is dropped. const early = new Y.Doc() early.getText(FILE_DOC_FIELD).insert(0, 'too early') - handlers[FILE_DOC_EVENTS.MESSAGE]( + await handlers[FILE_DOC_EVENTS.MESSAGE]( frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(early)) ) @@ -942,7 +943,7 @@ describe('setupWorkspaceFileDocHandlers', () => { // The first thing the client is served is the finished document — content and seed flag together. socket.emit.mockClear() - handlers[FILE_DOC_EVENTS.MESSAGE]( + await handlers[FILE_DOC_EVENTS.MESSAGE]( frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc())) ) const reply = socket.emit.mock.calls.find( @@ -964,19 +965,31 @@ describe('setupWorkspaceFileDocHandlers', () => { mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc())) // A newer durable version lands and is recorded as the synced version. - expect(await applyMarkdownToLiveFileDoc('file-1', '# newer', { version: 100 })).toBe('applied') + expect( + await applyMarkdownToLiveFileDoc({ type: 'workspace-file-doc', id: 'file-1' }, '# newer', { + version: 100, + }) + ).toBe('applied') mockFetchFileDocMerge.mockClear() // An older durable version arriving out of order (e.g. a concurrent write on another process) is // stale: skipped before any diff is computed, so the live doc never regresses to older content and // no diff is published that a later persist could write back. - expect(await applyMarkdownToLiveFileDoc('file-1', '# older, stale', { version: 50 })).toBe( - 'stale' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# older, stale', + { version: 50 } + ) + ).toBe('stale') // The same version is idempotent — also skipped. - expect(await applyMarkdownToLiveFileDoc('file-1', '# same version', { version: 100 })).toBe( - 'stale' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# same version', + { version: 100 } + ) + ).toBe('stale') expect(mockFetchFileDocMerge).not.toHaveBeenCalled() }) @@ -994,8 +1007,8 @@ describe('setupWorkspaceFileDocHandlers', () => { .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve))) .mockResolvedValueOnce(noOpUpdate) - const first = applyMarkdownToLiveFileDoc('file-1', '# One') - const second = applyMarkdownToLiveFileDoc('file-1', '# Two') + const first = applyMarkdownToLiveFileDoc({ type: 'workspace-file-doc', id: 'file-1' }, '# One') + const second = applyMarkdownToLiveFileDoc({ type: 'workspace-file-doc', id: 'file-1' }, '# Two') await flushMicrotasks(SEED_CHAIN_TICKS) expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(1) // second is queued behind the first @@ -1014,7 +1027,7 @@ describe('setupWorkspaceFileDocHandlers', () => { await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 100 }) sent.length = 0 - a.handlers[FILE_DOC_EVENTS.MESSAGE](spoof) + await a.handlers[FILE_DOC_EVENTS.MESSAGE](spoof) const relayed = sent.find( (m) => @@ -1105,7 +1118,7 @@ describe('setupWorkspaceFileDocHandlers', () => { const updateFrame = frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(doc)) ) - a.handlers[FILE_DOC_EVENTS.MESSAGE](updateFrame) + await a.handlers[FILE_DOC_EVENTS.MESSAGE](updateFrame) expect(sent.some((m) => m.event === FILE_DOC_EVENTS.MESSAGE)).toBe(false) }) diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts index a86c8387757..20797b6e6ad 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -44,14 +44,18 @@ import { toFileDocBytes, } from '@sim/realtime-protocol/file-doc' import { - projectFileDocRoom, - projectFileDocTarget, - ROOM_TYPES, - type RoomRef, - roomName, -} from '@sim/realtime-protocol/rooms' + type FileDocOwner, + type FileDocTarget, + fileDocAdmissionRoom, + fileDocOwnerWireFields, + fileDocRoom, + fileDocTargetFromRoom, + parseFileDocTarget, +} from '@sim/realtime-protocol/file-doc-target' +import { ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' +import { generateShortId } from '@sim/utils/id' import * as decoding from 'lib0/decoding' import * as encoding from 'lib0/encoding' import type { Server } from 'socket.io' @@ -59,13 +63,8 @@ import * as awarenessProtocol from 'y-protocols/awareness' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' import { resolveAvatarUrl } from '@/handlers/avatar' -import { - fetchFileDocMerge, - fetchFileDocPersist, - fetchFileDocSeed, - fetchProjectFileDocPersist, - fetchProjectFileDocSeed, -} from '@/handlers/file-doc-app' +import { fetchFileDocMerge } from '@/handlers/file-doc-app' +import { fileDocOwnerAdapter } from '@/handlers/file-doc-owner' import { type FileDocEditor, FileDocInvalidatedError, @@ -125,7 +124,7 @@ const MERGE_LOCK_RETRIES = Math.ceil( const AGENT_STREAM_FLAG_TTL_MS = 10_000 /** One presence ownership within a room: a (socket, clientID) pair. */ -interface FileDocOwner { +interface FileDocPresenceOwner { /** * An awareness clientID this socket declared at join. The socket may only publish/remove awareness * for a clientID it owns, so an authenticated peer cannot forge or clear another collaborator's @@ -144,13 +143,13 @@ interface FileDocOwner { interface FileDocRoom { /** The `workspace_files.id` this room edits. */ fileId: string - projectId?: string + owner: FileDocOwner lastEditorConnectionId: string | null doc: Y.Doc awareness: awarenessProtocol.Awareness /** socketId → (clientId → its presence ownership). A socket owns one entry per collaborative provider - * it mounted for this file (see {@link FileDocOwner}); an empty inner map is never kept. */ - owners: Map> + * it mounted for this file (see {@link FileDocPresenceOwner}); an empty inner map is never kept. */ + owners: Map> /** * The in-flight server seed for this room, or `null`. Concurrent joins await THIS promise rather * than each starting a fetch — and, unlike a "started" boolean, awaiting it is what lets a second @@ -158,8 +157,6 @@ interface FileDocRoom { * settles, so a failed seed is re-attempted by a later join (a genuinely empty file stays empty). */ seeding: Promise | null - /** The workspace this file belongs to, captured at join — needed to persist back to markdown. */ - workspaceId: string | null /** The last collaborator to edit here, for persist attribution (blob metadata) only. */ lastEditorUserId: string | null /** @@ -236,19 +233,6 @@ interface AwarenessChange { removed: number[] } -const fileDocRoom = (fileId: string, projectId?: string): RoomRef => - projectId - ? projectFileDocRoom(projectId, fileId) - : { - type: ROOM_TYPES.WORKSPACE_FILE_DOC, - id: fileId, - } - -/** Pending admissions receive invalidations here, never document or presence frames. */ -export function fileDocAdmissionRoom(fileId: string, projectId?: string): string { - return `file-doc-admission:${projectId ? `${projectId}/` : ''}${fileId}` -} - /** * A `y-protocols` transaction/awareness origin is the emitting socket id (a * string) when it came from a client, and something else (`null` / `'local'` / @@ -344,12 +328,7 @@ function broadcastLocal( * last-disconnect is separate ({@link flushPersist} with `final`). */ function schedulePersist(name: string, room: FileDocRoom): void { - if ( - room.projectId - ? !getFileDocStore().enabled && !room.lastEditorConnectionId - : !room.workspaceId || !room.lastEditorUserId - ) - return + if (!getFileDocStore().enabled && (!room.lastEditorUserId || !room.lastEditorConnectionId)) return const now = Date.now() if (room.persistDeadline === null) room.persistDeadline = now + PERSIST_MAX_WAIT_MS if (room.persistTimer) clearTimeout(room.persistTimer) @@ -387,20 +366,17 @@ async function persistRoom(name: string, room: FileDocRoom, final: boolean): Pro // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}). if ( !room.edited || - (room.projectId - ? !getFileDocStore().enabled && !room.lastEditorConnectionId - : !room.workspaceId || !room.lastEditorUserId) + (!getFileDocStore().enabled && (!room.lastEditorUserId || !room.lastEditorConnectionId)) ) return const store = getFileDocStore() const generation = docIdOf(room.doc) - const workspaceId = room.workspaceId const userId = room.lastEditorUserId const localEditor: FileDocEditor | null = userId && room.lastEditorConnectionId ? { userId, connectionId: room.lastEditorConnectionId } : null - let projectEditor = localEditor + let snapshotEditor = localEditor // Synchronous fallback capture — before any await, since the caller may destroy `room.doc` the moment // this yields. Only meaningful once seeded; used only when the authoritative stream state is absent. const localState = isDocSeeded(room.doc) ? Y.encodeStateAsUpdate(room.doc) : null @@ -415,31 +391,31 @@ async function persistRoom(name: string, room: FileDocRoom, final: boolean): Pro // would let the If-Match pass and clobber the reconciled edit). Fall back to that snapshot once the // room is torn down (last-leave), where the doc is gone and there is nothing left to reconcile. if (fileDocRooms.get(name) === room && isDocSeeded(room.doc)) { - if (room.projectId) { - projectEditor = - room.lastEditorUserId && room.lastEditorConnectionId - ? { userId: room.lastEditorUserId, connectionId: room.lastEditorConnectionId } - : null - } + snapshotEditor = + room.lastEditorUserId && room.lastEditorConnectionId + ? { userId: room.lastEditorUserId, connectionId: room.lastEditorConnectionId } + : null return Y.encodeStateAsUpdate(room.doc) } - if (room.projectId) projectEditor = localEditor + snapshotEditor = localEditor return localState } try { - if (room.projectId) { - const snapshot = await store.getStreamSnapshot(name, generation) - projectEditor = snapshot?.editor ?? null - return snapshot?.docState ?? null - } - return (await store.getStreamState(name)) ?? localState + const snapshot = await store.getStreamSnapshot(name, generation) + snapshotEditor = snapshot?.editor ?? null + return snapshot?.docState ?? null } catch (streamError) { - if (room.projectId || streamError instanceof FileDocInvalidatedError) throw streamError + if ( + !fileDocOwnerAdapter(room.owner).allowLocalSnapshotFallback || + streamError instanceof FileDocInvalidatedError + ) + throw streamError // A transient Redis read must NOT drop the write when we already hold a valid local snapshot — // else the last-disconnect flush loses the session's edits as the room is torn down. But once a // reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a // failed read then correctly THROWS and aborts rather than clobbering with the stale snapshot. - if (!localState) throw streamError + if (!localState || !localEditor) throw streamError + snapshotEditor = localEditor logger.warn(`Stream state unavailable for file ${room.fileId}; persisting local snapshot`, { error: getErrorMessage(streamError), }) @@ -485,18 +461,13 @@ async function persistRoom(name: string, room: FileDocRoom, final: boolean): Pro const docState = await captureState() if (!docState) return // nothing seeded/authoritative to persist yet if (!(await store.isDocumentGenerationCurrent(name, generation))) return - if (room.projectId && !projectEditor) return - const result = - room.projectId && projectEditor - ? await fetchProjectFileDocPersist( - { projectId: room.projectId, fileId: room.fileId, ...projectEditor }, - docState, - ifMatch - ) - : workspaceId && userId - ? await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch) - : null - if (!result) return + if (!snapshotEditor) return + const result = await fileDocOwnerAdapter(room.owner).persist( + room, + snapshotEditor, + docState, + ifMatch + ) if (result.status === 'missing') return // the file was deleted; nothing to write if (result.status === 'deferred') { // No version token available (momentarily — a Redis blip on a peer-seeded task). Leave the edits in @@ -718,16 +689,14 @@ export async function flushAllFileDocRooms(): Promise { async function ensureRoomReady( name: string, room: FileDocRoom, - workspaceId: string | null, actor?: FileDocEditor ): Promise { await room.hydrated // The room can be dropped and re-created while the catch-up is in flight (a fast open→close); the // join re-checks identity after this and abandons a stale room rather than serving from it. if (fileDocRooms.get(name) !== room) return - if (!room.projectId && !workspaceId) - throw new Error(`File document ${room.fileId} has no owner context`) - await ensureServerSeed(name, room, workspaceId, actor) + if (!room.owner) throw new Error(`File document ${room.fileId} has no owner context`) + await ensureServerSeed(name, room, actor) if (fileDocRooms.get(name) === room && !isDocSeeded(room.doc)) { throw new Error(`File document ${room.fileId} could not be seeded`) } @@ -738,14 +707,9 @@ async function ensureRoomReady( * Clear settled attempts to allow retry after transient failures. Existing empty files have a named * seed; a missing file must fail admission rather than create an editable blank room. */ -function ensureServerSeed( - name: string, - room: FileDocRoom, - workspaceId: string | null, - actor?: FileDocEditor -): Promise { +function ensureServerSeed(name: string, room: FileDocRoom, actor?: FileDocEditor): Promise { if (isDocSeeded(room.doc)) return Promise.resolve() - room.seeding ??= runServerSeed(name, room, workspaceId, actor).finally(() => { + room.seeding ??= runServerSeed(name, room, actor).finally(() => { room.seeding = null }) return room.seeding @@ -766,7 +730,6 @@ class FileDocNotFoundError extends Error {} async function runServerSeed( name: string, room: FileDocRoom, - workspaceId: string | null, actor?: FileDocEditor ): Promise { const store = getFileDocStore() @@ -776,7 +739,7 @@ async function runServerSeed( // fix for split-brain seeding). Returns a lock token here (single-pod: a sentinel token). const token = await store.shouldSeed(name) if (token) { - await seedUnderLock(name, room, workspaceId, token, actor) + await seedUnderLock(name, room, token, actor) return } // No token: a peer holds the lock with its fetch in flight, or the stream is already seeded (which @@ -792,32 +755,25 @@ async function runServerSeed( async function seedUnderLock( name: string, room: FileDocRoom, - workspaceId: string | null, token: string, actor?: FileDocEditor ): Promise { const store = getFileDocStore() // Release the lock on EVERY exit from here (one `finally`, impossible to leak). try { - const seed = - room.projectId && actor - ? await fetchProjectFileDocSeed({ - projectId: room.projectId, - fileId: room.fileId, - ...actor, - }) - : workspaceId - ? await fetchFileDocSeed(workspaceId, room.fileId) - : null + const seed = await fileDocOwnerAdapter(room.owner).seed(room, actor) if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return if (!seed) throw new FileDocNotFoundError('File not found') /** * Publish before local apply: the atomic empty-stream append, not the expiring lock, prevents * independent Yjs histories from entering the same room. */ - const didSeed = room.projectId - ? await store.seedIfEmpty(name, seed.update, seed.version, true) - : await store.seedIfEmpty(name, seed.update, seed.version) + const didSeed = await store.seedIfEmpty( + name, + seed.update, + seed.version, + fileDocOwnerAdapter(room.owner).tracksLifecycle + ) /** * Record only our winning seed's version before the readiness guard: the tailer may already have * applied it during the append, but persistence still needs the matching local version. @@ -837,7 +793,7 @@ async function seedUnderLock( } } catch (error) { if (error instanceof FileDocNotFoundError) throw error - logger.warn(`Server seed failed for file ${room.fileId} (workspace ${workspaceId})`, error) + logger.warn(`Server seed failed for file ${room.fileId}`, error) } finally { await store.releaseSeedLock(name, token) } @@ -878,12 +834,14 @@ interface MergeOrder { * reconcile treats it distinctly (retry later) rather than as "nothing to reconcile into". */ export function applyMarkdownToLiveFileDoc( - fileId: string, + ref: RoomRef, markdown: string, - order: MergeOrder = {}, - projectId?: string + order: MergeOrder = {} ): Promise<'applied' | 'no-live-room' | 'merge-unavailable' | 'stale'> { - const name = roomName(fileDocRoom(fileId, projectId)) + const address = fileDocTargetFromRoom(ref) + if (!address) throw new Error('Invalid file document room') + const { fileId } = address + const name = roomName(ref) return serializeFileDocMutation(name, () => mergeMarkdownIntoRoom(name, fileId, markdown, order)) } @@ -911,11 +869,11 @@ async function acquireFileDocMergeSlot(name: string): Promise { /** Serializes and version-orders an unsupported durable replacement with live Markdown merges. */ export function invalidateLiveFileDocument( - fileId: string, - version: number, - projectId?: string + ref: RoomRef, + version: number ): Promise<{ status: 'applied'; docId?: string } | { status: 'stale' }> { - const name = roomName(fileDocRoom(fileId, projectId)) + if (!fileDocTargetFromRoom(ref)) throw new Error('Invalid file document room') + const name = roomName(ref) return serializeFileDocMutation(name, async () => { const store = getFileDocStore() const token = await acquireFileDocMergeSlot(name) @@ -930,15 +888,17 @@ export function invalidateLiveFileDocument( } /** Lifecycle retirement compares identities, independently of the unchanged Markdown timestamp. */ -export function retireLiveProjectFileDocument( - target: { projectId: string; fileId: string; retiredDocId: string; replacementDocId: string }, +export function retireLiveFileDocument( + target: FileDocTarget & { retiredDocId: string; replacementDocId: string }, io: Server ): Promise<{ status: 'applied'; docId: string } | { status: 'stale' }> { - const name = roomName(projectFileDocRoom(target.projectId, target.fileId)) + if (!fileDocOwnerAdapter(target.owner).tracksLifecycle) + throw new Error('Document owner does not support lifecycle retirement') + const name = roomName(fileDocRoom(target)) return serializeFileDocMutation(name, async () => { const store = getFileDocStore() const token = await acquireFileDocMergeSlot(name) - if (!token) throw new Error('Project document retirement slot is temporarily unavailable') + if (!token) throw new Error('Document retirement slot is temporarily unavailable') try { const result = await store.retireDocumentGeneration( name, @@ -946,14 +906,11 @@ export function retireLiveProjectFileDocument( target.replacementDocId ) if (result.status === 'applied') { - io.to([name, fileDocAdmissionRoom(target.fileId, target.projectId)]).emit( - FILE_DOC_EVENTS.INVALIDATED, - { - fileId: target.fileId, - docId: target.retiredDocId, - message: 'This document was archived or restored. Reload to continue.', - } - ) + io.to([name, fileDocAdmissionRoom(target)]).emit(FILE_DOC_EVENTS.INVALIDATED, { + fileId: target.fileId, + docId: target.retiredDocId, + message: 'This document was archived or restored. Reload to continue.', + }) } const existing = fileDocRooms.get(name) if (existing && docIdOf(existing.doc) === target.retiredDocId) @@ -1068,8 +1025,8 @@ async function mergeMarkdownIntoRoom( * relay handlers exactly once: document updates and awareness changes are * broadcast to the room. */ -function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { - const name = roomName(ref) +function getOrCreateRoom(io: Server, target: FileDocTarget): FileDocRoom { + const name = roomName(fileDocRoom(target)) const existing = fileDocRooms.get(name) if (existing) return existing @@ -1081,14 +1038,12 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { // Started BEFORE the room is registered so no join can observe a room without its hydration handle. const hydrated = getFileDocStore().attachRoom(name, doc) const room: FileDocRoom = { - fileId: projectFileDocTarget(ref)?.fileId ?? ref.id, - projectId: projectFileDocTarget(ref)?.projectId, + ...target, lastEditorConnectionId: null, doc, awareness, owners: new Map(), seeding: null, - workspaceId: null, lastEditorUserId: null, edited: false, seededObserved: false, @@ -1194,71 +1149,31 @@ function emitJoinError( }) } -/** - * Per-frame authorization for a socket's inbound document/awareness frames. - * - * Room membership alone is NOT a standing right to write: a collaborator removed - * from the workspace — or downgraded to `read` — must stop landing durable edits on - * an already-open socket, without waiting for the next re-validation sweep. This is - * the synchronous half of that enforcement; the sweep is the asynchronous half. - * - * Reads the shared role cache without awaiting, because this sits on the Yjs relay - * hot path (one call per keystroke-sized frame). Three outcomes: - * - fresh cached permission that satisfies `write` → accept. - * - fresh cached permission that does NOT → drop the frame and evict immediately. - * - nothing fresh cached (TTL lapsed) → accept this frame and kick off a background - * refresh, so the very next frames are gated on an authoritative read. Accepting - * is correct rather than lax: the entry was authoritative when written and every - * join records one, so the exposure is bounded by the same TTL the workflow - * write-path has always had, and the sweep evicts independently. - */ -function isFileDocWriteAllowed( +/** Resolve read membership and publish the separate editing capability after authorization. */ +function resolveFileDocPermission( socket: AuthenticatedSocket, io: Server, name: string -): boolean | Promise { +): string | null | Promise { const userId = socket.userId - const fileId = fileDocRooms.get(name)?.fileId - if (!userId || !fileId) return false - - const projectId = fileDocRooms.get(name)?.projectId - const room = fileDocRoom(fileId, projectId) - if (projectId) - return (async () => { - const permission = await resolveCurrentRoomPermission(userId, room, 'read', socket.id) - if (socketToRoomName.get(socket.id) !== name) return false - const canWrite = permission === 'write' || permission === 'admin' - socket.emit(FILE_DOC_EVENTS.PERMISSION, { - fileId, - projectId, - canWrite, - } satisfies FileDocPermission) - if (permission === null) - evictSocketFromRoom(socket, room, 'Your access to this document has been revoked', io) - return canWrite - })() - const cached = peekRoomPermission(userId, room) - if (cached === undefined) { - // Single-flighted, so a burst of frames triggers at most one query. - void resolveCurrentRoomPermission( - userId, - room, - ROOM_MEMBERSHIP_ACTIONS[room.type], - socket.id - ).catch(() => {}) - return true + const document = fileDocRooms.get(name) + if (!userId || !document) return null + const room = fileDocRoom(document) + const accept = (permission: string | null) => { + if (socketToRoomName.get(socket.id) !== name || fileDocRooms.get(name) !== document) return null + socket.emit(FILE_DOC_EVENTS.PERMISSION, { + fileId: document.fileId, + ...fileDocOwnerWireFields(document.owner), + canWrite: permission === 'write' || permission === 'admin', + } satisfies FileDocPermission) + if (permission === null) + evictSocketFromRoom(socket, room, 'Your access to this document has been revoked', io) + return permission } - if (satisfiesRoomMembership(cached, ROOM_TYPES.WORKSPACE_FILE_DOC)) return true - - logger.warn( - `Dropping file-doc frame from user ${userId} whose access to file ${fileId} no longer permits writing` - ) - // Evicting (not just dropping the frame) is what makes this stick: the registered - // eviction handler clears `socketToRoomName`, and every inbound frame is gated on - // that binding — so the socket cannot apply another document update even if it - // keeps sending them. - evictSocketFromRoom(socket, room, 'Your access to this document has been revoked', io) - return false + const cached = peekRoomPermission(userId, room) + return cached === undefined + ? resolveCurrentRoomPermission(userId, room, 'read', socket.id).then(accept) + : accept(cached) } /** @@ -1278,7 +1193,6 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn if (!name) return const room = fileDocRooms.get(name) if (!room) return - if (!room.projectId && !isFileDocWriteAllowed(socket, io, name)) return const bytes = toFileDocBytes(data) if (!bytes) return @@ -1302,42 +1216,40 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn } const decoder = decoding.createDecoder(bytes) const messageType = decoding.readVarUint(decoder) - if (room.projectId) { - const userId = socket.userId - if (!userId) return - const permission = await resolveCurrentRoomPermission( - userId, - fileDocRoom(room.fileId, room.projectId), - 'read', - socket.id - ) - if (socketToRoomName.get(socket.id) !== name) return - if (permission === null) { - evictSocketFromRoom( + const permission = await resolveFileDocPermission(socket, io, name) + if (permission === null) return + const canWrite = permission === 'write' || permission === 'admin' + if (messageType === FILE_DOC_MESSAGE_TYPE.SYNC) { + const peek = decoding.createDecoder(bytes) + decoding.readVarUint(peek) + const subtype = decoding.readVarUint(peek) + if (subtype !== syncProtocol.messageYjsSyncStep1) { + if ( + !canWrite || + (subtype !== syncProtocol.messageYjsSyncStep2 && + subtype !== syncProtocol.messageYjsUpdate) + ) + return + await handleClientUpdate( socket, - fileDocRoom(room.fileId, room.projectId), - 'Your access to this document has been revoked', - io + io, + { + fileId: room.fileId, + ...fileDocOwnerWireFields(room.owner), + docId: docIdOf(room.doc), + updateId: generateShortId(), + update: decoding.readVarUint8Array(peek), + }, + () => {} ) return } - if (messageType === FILE_DOC_MESSAGE_TYPE.SYNC) { - const peek = decoding.createDecoder(bytes) - decoding.readVarUint(peek) - if (decoding.readVarUint(peek) !== syncProtocol.messageYjsSyncStep1) return - } else if ( - messageType !== FILE_DOC_MESSAGE_TYPE.AWARENESS && - !(await isFileDocWriteAllowed(socket, io, name)) - ) - return - } + } else if (messageType !== FILE_DOC_MESSAGE_TYPE.AWARENESS && !canWrite) return + if (!(await getFileDocStore().isDocumentGenerationCurrent(name, docIdOf(room.doc)))) return + if (socketToRoomName.get(socket.id) !== name || fileDocRooms.get(name) !== room) return switch (messageType) { case FILE_DOC_MESSAGE_TYPE.SYNC: { - // Attribute a server-side persist of the resulting edit to the actual editor (blob metadata). A - // socket's providers are all the same user, so any owner's userId identifies the editor. - const editor = room.owners.get(socket.id)?.values().next().value?.userId - if (editor && !room.projectId) room.lastEditorUserId = editor const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) // `socket.id` is the transaction origin, so the doc's `update` handler @@ -1352,7 +1264,7 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn } case FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST: { // An agent-streamed frame: apply + fan out to the room (so a collaborator sees the stream live) but - // do NOT treat it as a durable user edit. Unlike SYNC, we do NOT set `lastEditorUserId`, and the + // do NOT treat it as a durable user edit. We do NOT set `lastEditorUserId`, and the // apply uses {@link AGENT_SYNC_ORIGIN} (a non-string sentinel) so `originSocketId` is `null` in // `doc.on('update')` — skipping `edited`/`schedulePersist`, and broadcasting to the WHOLE room // (including the sender socket, so a same-socket sibling provider stays live). The copilot's final @@ -1410,15 +1322,10 @@ async function handleClientUpdate( } const candidate = data as Partial + const target = parseFileDocTarget(candidate) const update = toFileDocBytes(candidate.update) if ( - typeof candidate.fileId !== 'string' || - candidate.fileId.length === 0 || - (candidate.projectId !== undefined && - (typeof candidate.projectId !== 'string' || - !candidate.projectId || - /[/:]/.test(candidate.projectId) || - /[/:]/.test(candidate.fileId))) || + !target || typeof candidate.docId !== 'string' || candidate.docId.length === 0 || typeof candidate.updateId !== 'string' || @@ -1433,19 +1340,25 @@ async function handleClientUpdate( } const name = socketToRoomName.get(socket.id) - if (!name || name !== roomName(fileDocRoom(candidate.fileId, candidate.projectId))) { + if (!name || name !== roomName(fileDocRoom(target))) { reject('NOT_JOINED', true, candidate.updateId) return } const room = fileDocRooms.get(name) - if (!room) { + if ( + !room || + (target.owner && + (target.owner.entityType !== room.owner.entityType || + target.owner.entityId !== room.owner.entityId)) + ) { reject('NOT_JOINED', true, candidate.updateId) return } let allowed: boolean try { - const admission = isFileDocWriteAllowed(socket, io, name) - allowed = typeof admission === 'boolean' ? admission : await admission + const decision = resolveFileDocPermission(socket, io, name) + const permission = decision instanceof Promise ? await decision : decision + allowed = permission === 'write' || permission === 'admin' } catch (error) { logger.warn('Document write authorization unavailable', { error }) reject('TEMPORARY_FAILURE', true, candidate.updateId) @@ -1561,7 +1474,7 @@ export function cleanupFileDocForSocket(socketId: string, io: Server, endOfLife /** * Registers the collaborative file-document handlers on a socket. Room id is the - * file id; joining requires workspace `write` (editing a document). Mirrors the + * file id; joining requires `read`; content changes require `write`. Mirrors the * workspace-files join shape (auth → readiness → validate → authorize → join), * then runs the Yjs sync/awareness handshake. * @@ -1592,7 +1505,8 @@ export function setupWorkspaceFileDocHandlers( } socket.on(FILE_DOC_EVENTS.JOIN, async (payload: JoinFileDocPayload) => { - const { fileId, clientId, projectId } = payload + const { fileId, clientId } = payload + const target = parseFileDocTarget(payload) // Hoisted so the catch can tell whether this join was superseded (a switch to another file) // before surfacing a retryable error for the abandoned one. let generation: number | undefined @@ -1624,13 +1538,7 @@ export function setupWorkspaceFileDocHandlers( return } if ( - typeof fileId !== 'string' || - fileId.length === 0 || - (projectId !== undefined && - (typeof projectId !== 'string' || - !projectId || - /[/:]/.test(projectId) || - /[/:]/.test(fileId))) || + !target || // A Yjs clientID is a uint32; reject malformed values before they can become ownership keys. !isYjsClientId(clientId) ) { @@ -1649,7 +1557,7 @@ export function setupWorkspaceFileDocHandlers( return } - const room = fileDocRoom(fileId, projectId) + const room = fileDocRoom(target) const name = roomName(room) // Co-mounted providers share a generation only while their owner-qualified target matches. @@ -1661,7 +1569,7 @@ export function setupWorkspaceFileDocHandlers( generation = joinGeneration.get(socket.id) ?? 0 } - const admissionName = fileDocAdmissionRoom(fileId, projectId) + const admissionName = fileDocAdmissionRoom(target) const authorizeJoin = () => resolveRoomJoinAuth({ @@ -1681,19 +1589,33 @@ export function setupWorkspaceFileDocHandlers( }) const store = getFileDocStore() // A delayed authorization may retire only the generation observed before that lookup. - const observedGeneration = projectId ? await store.getDocumentGeneration(name) : '' + const observedGeneration = await store.getDocumentGeneration(name) const authorized = await authorizeJoin() if (!authorized) return + if ( + target.owner?.entityType === 'workspace' && + target.owner.entityId !== authorized.workspaceId + ) { + emitJoinError(socket, fileId, clientId, 'File not found', 'NOT_FOUND', false) + return + } + + const owner = + target.owner ?? + (authorized.workspaceId + ? { entityType: 'workspace' as const, entityId: authorized.workspaceId } + : null) + if (!owner) throw new Error('Document authorization did not resolve its owner') // Server-authenticated identity for the presence roster (never trusts the client-set // awareness). Resolved here so the generation guard below also covers this await. const avatarUrl = await resolveAvatarUrl(socket, userId) - if (projectId && authorized.docId) { + if (fileDocOwnerAdapter(owner).tracksLifecycle && authorized.docId) { if (observedGeneration && observedGeneration !== authorized.docId) { - await retireLiveProjectFileDocument( + await retireLiveFileDocument( { - projectId, + owner, fileId, retiredDocId: observedGeneration, replacementDocId: authorized.docId, @@ -1724,10 +1646,7 @@ export function setupWorkspaceFileDocHandlers( discardInvalidatedRoom(name, io) } - const entry = getOrCreateRoom(io, room) - // The workspace the server-side persist writes back to — and what the seed is built from, so it - // must be captured BEFORE the room is prepared below. - if (authorized.workspaceId) entry.workspaceId = authorized.workspaceId + const entry = getOrCreateRoom(io, { fileId, owner }) // Hold the room open across the awaits below: it has no owner until this join commits, so a // concurrent last-leave would otherwise tear down the very document being prepared. @@ -1758,7 +1677,7 @@ export function setupWorkspaceFileDocHandlers( // document's history, and it watches that replay on screen (reload right after moving a block // and the block moves again in front of you). Waiting here is what makes the handshake below // authoritative: the client's first sync IS the finished document, in one message. - await ensureRoomReady(name, entry, entry.workspaceId, { userId, connectionId: socket.id }) + await ensureRoomReady(name, entry, { userId, connectionId: socket.id }) // Re-check access immediately before registering, mirroring the workflow join: the // access re-validation sweep records a revocation BEFORE it evicts, so a join that @@ -1774,9 +1693,7 @@ export function setupWorkspaceFileDocHandlers( socket.id ) if (!satisfiesRoomMembership(currentPermission, room.type)) { - logger.warn( - `User ${userId} lost write access to file ${fileId} before the join completed` - ) + logger.warn(`User ${userId} lost read access to file ${fileId} before the join completed`) emitJoinError(socket, fileId, clientId, 'Access denied to file', 'ACCESS_DENIED', false) return } @@ -1794,7 +1711,7 @@ export function setupWorkspaceFileDocHandlers( const joinedVersion = Math.max(entry.syncedVersion ?? 0, (await store.getSyncedVersion(name)) ?? 0) || undefined /** Adapter membership can wait; resolve access again before checking the final generation. */ - let finalPermission = await resolveCurrentRoomPermission( + const finalPermission = await resolveCurrentRoomPermission( userId, room, ROOM_MEMBERSHIP_ACTIONS[room.type], @@ -1819,7 +1736,7 @@ export function setupWorkspaceFileDocHandlers( } if (!canRegisterJoin()) return await changeMembership(() => socket.join(name)) - if (projectId) { + if (fileDocOwnerAdapter(owner).tracksLifecycle) { const currentAccess = await authorizeJoin() if (!currentAccess) return if (!isCurrentJoin()) return @@ -1834,7 +1751,6 @@ export function setupWorkspaceFileDocHandlers( ) return } - finalPermission = currentAccess.workspacePermission } /** Adapter joins can wait; recheck access and liveness before ownership or synchronization. */ if (!canRegisterJoin()) return @@ -1897,7 +1813,7 @@ export function setupWorkspaceFileDocHandlers( // only after its LAST provider for the file tears down). let clientMap = entry.owners.get(socket.id) if (clientMap === undefined) { - clientMap = new Map() + clientMap = new Map() entry.owners.set(socket.id, clientMap) } clientMap.set(clientId, { clientId, userId }) @@ -1917,10 +1833,6 @@ export function setupWorkspaceFileDocHandlers( user: { socketId: socket.id, userId, userName, avatarUrl }, } - // Attribution for the server-side persist, refreshed to the actual editor on each edit in - // `handleMessage`. - if (!projectId) entry.lastEditorUserId = userId - // Name the document this room holds, so a client that still carries a DIFFERENT one (its room // outlived by a document rebuilt in its place) can refuse to merge instead of unioning two // documents into the file twice over. Read after readiness — before it, the room has no doc yet. @@ -1930,10 +1842,11 @@ export function setupWorkspaceFileDocHandlers( docId: docIdOf(entry.doc), version: joinedVersion, schemaVersion: FILE_DOC_SCHEMA_VERSION, - ...(store.enabled || projectId ? { acknowledgedUpdates: true as const } : {}), - ...(projectId - ? { projectId, canWrite: finalPermission === 'write' || finalPermission === 'admin' } - : {}), + ...fileDocOwnerWireFields(entry.owner), + acknowledgedUpdates: true as const, + canWrite: + peekRoomPermission(userId, room) === 'write' || + peekRoomPermission(userId, room) === 'admin', }) // Server-authenticated roster → everyone in the room, including this joiner. broadcastFileDocPresence(io, name, entry) @@ -1981,7 +1894,8 @@ export function setupWorkspaceFileDocHandlers( } catch (error) { logger.error('Error joining file-doc room:', error) try { - const name = roomName(fileDocRoom(fileId, projectId)) + const name = target ? roomName(fileDocRoom(target)) : null + if (!name) throw error /** * Roll back ownership only if this attempt committed it. A failed provisional admission must * preserve a previous file's binding and any co-mounted provider already in the target room. @@ -2028,9 +1942,9 @@ export function setupWorkspaceFileDocHandlers( socket.on(FILE_DOC_EVENTS.LEAVE, async (payload?: LeaveFileDocPayload) => { try { - const leavingRoom = payload?.fileId - ? roomName(fileDocRoom(payload.fileId, payload.projectId)) - : undefined + const target = payload?.fileId ? parseFileDocTarget(payload) : undefined + if (payload?.fileId && !target) return + const leavingRoom = target ? roomName(fileDocRoom(target)) : undefined if (!leavingRoom || leavingRoom === currentFileRoom) { joinGeneration.set(socket.id, (joinGeneration.get(socket.id) ?? 0) + 1) currentFileRoom = null diff --git a/apps/realtime/src/handlers/file-list-app.ts b/apps/realtime/src/handlers/file-list-app.ts new file mode 100644 index 00000000000..10866500c41 --- /dev/null +++ b/apps/realtime/src/handlers/file-list-app.ts @@ -0,0 +1,59 @@ +import type { RoomAuthorizationResult } from '@sim/platform-authz/rooms' +import { FILE_DOC_INTERNAL_HEADERS, FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { type ProjectRoomRef, projectFileDocTarget, ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { toRecord } from '@sim/utils/object' +import { env, getBaseUrl } from '@/env' +import { fetchProjectFileDocAccess } from '@/handlers/file-doc-app' + +/** Each Project room revalidates through its own bounded semantic app operation. */ +export async function fetchProjectRoomAccess( + room: ProjectRoomRef, + actor: { userId: string; connectionId: string } +): Promise { + switch (room.type) { + case ROOM_TYPES.PROJECT_FILE_DOC: { + const target = projectFileDocTarget(room) + if (!target) throw new Error('Invalid Project document target') + return fetchProjectFileDocAccess({ ...target, ...actor }) + } + case ROOM_TYPES.PROJECT_FILES: { + if (!room.id || room.id.length > 200 || /[/:\s]/.test(room.id)) { + throw new Error('Invalid Project collection target') + } + const response = await fetch( + `${getBaseUrl()}/api/internal/project-file-list/${encodeURIComponent(room.id)}/access`, + { + method: 'POST', + headers: { + 'x-api-key': env.INTERNAL_API_SECRET, + [FILE_DOC_INTERNAL_HEADERS.userId]: actor.userId, + [FILE_DOC_INTERNAL_HEADERS.connectionId]: actor.connectionId, + }, + signal: AbortSignal.timeout(FILE_DOC_TIMEOUTS.seedRequestMs), + } + ) + if ([403, 404, 409].includes(response.status)) { + await response.body?.cancel() + return { + allowed: false, + status: response.status === 409 ? 404 : response.status, + workspaceId: null, + workspacePermission: null, + } + } + if (!response.ok) { + await response.body?.cancel() + throw new Error(`Project collection authorization failed: ${response.status}`) + } + const result = toRecord(await response.json()) + if (result.projectId !== room.id || result.canRead !== true) { + throw new Error('Project collection authorization returned an invalid target') + } + return { allowed: true, status: 200, workspaceId: null, workspacePermission: 'read' } + } + default: { + const unknownType: never = room.type + throw new Error(`Unsupported Project room ${unknownType}`) + } + } +} diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts index cda7c032d62..f345553caed 100644 --- a/apps/realtime/src/handlers/index.ts +++ b/apps/realtime/src/handlers/index.ts @@ -1,4 +1,4 @@ -import { WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { INVALIDATION_ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { setupConnectionHandlers } from '@/handlers/connection' import { setupWorkspaceFileDocHandlers } from '@/handlers/file-doc' import { setupOperationsHandlers } from '@/handlers/operations' @@ -17,8 +17,7 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom setupSubblocksHandlers(socket, roomManager) setupVariablesHandlers(socket, roomManager) setupPresenceHandlers(socket, roomManager) - // Presence-free, workspace-scoped live-list rooms (share one implementation). - for (const roomType of WORKSPACE_LIST_ROOM_TYPES) { + for (const roomType of INVALIDATION_ROOM_TYPES) { setupWorkspaceInvalidationRoom(socket, roomManager, roomType) } setupWorkspaceFileDocHandlers(socket, roomManager) diff --git a/apps/realtime/src/handlers/room-join-auth.ts b/apps/realtime/src/handlers/room-join-auth.ts index 593c3b069b2..bc7b55748c7 100644 --- a/apps/realtime/src/handlers/room-join-auth.ts +++ b/apps/realtime/src/handlers/room-join-auth.ts @@ -1,7 +1,7 @@ import type { createLogger } from '@sim/logger' import { authorizeRoom } from '@sim/platform-authz/rooms' -import { projectFileDocTarget, ROOM_TYPES, type RoomRef } from '@sim/realtime-protocol/rooms' -import { fetchProjectFileDocAccess } from '@/handlers/file-doc-app' +import { isProjectRoom, type RoomRef } from '@sim/realtime-protocol/rooms' +import { fetchProjectRoomAccess } from '@/handlers/file-list-app' import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' type Authorized = Awaited> & { docId?: string | null } @@ -42,12 +42,9 @@ export async function resolveRoomJoinAuth( // is never overwritten by this older result — see {@link commitRoomPermission}. const readSeq = beginRoomPermissionRead() try { - if (room.type === ROOM_TYPES.PROJECT_FILE_DOC) { - const target = projectFileDocTarget(room) - if (!target || !params.connectionId) - throw new Error('Project document requires a socket identity') - authorized = await fetchProjectFileDocAccess({ - ...target, + if (isProjectRoom(room)) { + if (!params.connectionId) throw new Error('Project room requires a socket identity') + authorized = await fetchProjectRoomAccess(room, { userId, connectionId: params.connectionId, }) diff --git a/apps/realtime/src/handlers/workspace-invalidation-room.test.ts b/apps/realtime/src/handlers/workspace-invalidation-room.test.ts index 794970e62e4..7f8f6cd8fba 100644 --- a/apps/realtime/src/handlers/workspace-invalidation-room.test.ts +++ b/apps/realtime/src/handlers/workspace-invalidation-room.test.ts @@ -1,4 +1,5 @@ -import { WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { INVALIDATION_ROOM_TYPES, invalidationRoomIdKey } from '@sim/realtime-protocol/rooms' +import { createDeferred } from '@sim/testing' import { databaseMock } from '@sim/testing/mocks/database.mock' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -14,10 +15,14 @@ vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: mockAuthorizeRoom, })) +vi.mock('@/handlers/file-list-app', () => ({ + fetchProjectRoomAccess: mockAuthorizeRoom, +})) + import { setupWorkspaceInvalidationRoom } from '@/handlers/workspace-invalidation-room' import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' -type Payload = { workspaceId?: string } +type Payload = { workspaceId?: string; projectId?: string } function createSocket(overrides?: Record) { const handlers: Record Promise | void> = {} @@ -25,6 +30,7 @@ function createSocket(overrides?: Record) { const rooms = new Set() const socket = { id: 'socket-1', + disconnected: false, userId: 'user-1', userName: 'Test User', userImage: 'avatar.png', @@ -68,14 +74,13 @@ function createRoomManager(overrides?: Partial): IRoomManager { } as unknown as IRoomManager } -// The presence-free live-list rooms share one implementation; run the whole suite against each -// so they can never drift. Event names and room names derive from the room type. -describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', (roomType) => { +/** All invalidation room types share authorization and cancellation behavior. */ +describe.each(INVALIDATION_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', (roomType) => { const joinEvent = `join-${roomType}` const successEvent = `${joinEvent}-success` const errorEvent = `${joinEvent}-error` const leaveEvent = `leave-${roomType}` - const _roomOf = (workspaceId: string) => `${roomType}:${workspaceId}` + const idKey = invalidationRoomIdKey(roomType) const setup = (socket: ReturnType['socket'], roomManager: IRoomManager) => setupWorkspaceInvalidationRoom( @@ -103,7 +108,7 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( const { socket, handlers } = createSocket() setup(socket, createRoomManager()) - await handlers[joinEvent]({ workspaceId: 'ws-1' }) + await handlers[joinEvent]({ [idKey]: 'ws-1' }) expect(socket.emit).toHaveBeenCalledWith( errorEvent, @@ -112,11 +117,7 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( }) it('aborts a join superseded during the access re-check await', async () => { - // The access re-resolve is an await like any other: a leave landing during it must - // still cancel this join, or the stale join would leave the room the client - // switched to and commit the abandoned one. Forced down the re-resolve's DB path - // by expiring the cached decision mid-join, so the interleaving is deterministic - // rather than dependent on microtask ordering. + /** Expire the cache to exercise a leave while the permission re-check is pending. */ vi.useFakeTimers() try { const { handlers, socket } = createSocket({ id: 'socket-sup', userId: 'user-sup' }) @@ -141,12 +142,12 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( await sleep(31_000) } else { // Second call is the re-check's re-resolve: the client leaves during it. - handlers[leaveEvent]({ workspaceId: 'ws-sup' }) + handlers[leaveEvent]({ [idKey]: 'ws-sup' }) } return { allowed: true, status: 200, workspaceId: 'ws-sup', workspacePermission: 'admin' } }) - const joining = handlers[joinEvent]({ workspaceId: 'ws-sup' }) + const joining = handlers[joinEvent]({ [idKey]: 'ws-sup' }) await vi.advanceTimersByTimeAsync(31_000) await joining @@ -178,7 +179,7 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( return { allowed: true, status: 200, workspaceId: 'ws-race', workspacePermission: 'admin' } }) - await handlers[joinEvent]({ workspaceId: 'ws-race' }) + await handlers[joinEvent]({ [idKey]: 'ws-race' }) expect(socket.emit).toHaveBeenCalledWith( errorEvent, @@ -187,3 +188,180 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( expect(socket.join).not.toHaveBeenCalled() }) }) + +/** Exercise every owner address through the real authorization and membership handler. */ +describe.each(INVALIDATION_ROOM_TYPES)('concurrent owner subscriptions (%s)', (roomType) => { + const idKey = invalidationRoomIdKey(roomType) + const payload = (id: string): Payload => ({ [idKey]: id }) + const joinEvent = `join-${roomType}` + const leaveEvent = `leave-${roomType}` + const successEvent = `${joinEvent}-success` + const errorEvent = `${joinEvent}-error` + const allowed = { allowed: true, status: 200, workspacePermission: 'admin' } + + function setup() { + const state = createSocket({ disconnected: false }) + setupWorkspaceInvalidationRoom( + state.socket as unknown as Parameters[0], + createRoomManager(), + roomType + ) + return state + } + + function pendingAuthorization() { + const pending = createDeferred() + mockAuthorizeRoom.mockImplementationOnce(() => pending.promise) + return pending + } + + beforeEach(() => { + mockAuthorizeRoom.mockReset().mockResolvedValue(allowed) + }) + + it('keeps both owners subscribed after sequential joins', async () => { + const { handlers, rooms } = setup() + await handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-b')) + expect(rooms).toEqual(new Set([`${roomType}:owner-a`, `${roomType}:owner-b`])) + }) + + it('allows independent joins to finish in reverse order', async () => { + const { handlers, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-b')) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`, `${roomType}:owner-b`])) + }) + + it('scoped leave cancels only its pending owner and retains other membership', async () => { + const { handlers, rooms } = setup() + await handlers[joinEvent](payload('owner-c')) + const first = pendingAuthorization() + const joiningA = handlers[joinEvent](payload('owner-a')) + const second = pendingAuthorization() + const joiningB = handlers[joinEvent](payload('owner-b')) + handlers[leaveEvent](payload('owner-a')) + second.resolve(allowed) + first.resolve(allowed) + await Promise.all([joiningA, joiningB]) + expect(rooms).toEqual(new Set([`${roomType}:owner-b`, `${roomType}:owner-c`])) + }) + + it('scoped leave removes only the specified joined owner', async () => { + const { handlers, rooms } = setup() + await handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-b')) + handlers[leaveEvent](payload('owner-b')) + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + }) + + it('leave all cancels every pending join and removes only this room type', async () => { + const { handlers, rooms } = setup() + rooms.add('other:owner') + await handlers[joinEvent](payload('owner-c')) + const first = pendingAuthorization() + const joiningA = handlers[joinEvent](payload('owner-a')) + const second = pendingAuthorization() + const joiningB = handlers[joinEvent](payload('owner-b')) + handlers[leaveEvent]() + first.resolve(allowed) + second.resolve(allowed) + await Promise.all([joiningA, joiningB]) + expect(rooms).toEqual(new Set(['other:owner'])) + }) + + it('does not revive an old attempt after leave and rejoin of the same owner', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + handlers[leaveEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-a')) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + expect(socket.emit.mock.calls.filter(([event]) => event === successEvent)).toHaveLength(1) + }) + + it('supersedes a duplicate pending join for the same owner only', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-b')) + await handlers[joinEvent](payload('owner-a')) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`, `${roomType}:owner-b`])) + expect(socket.emit.mock.calls.filter(([event]) => event === successEvent)).toHaveLength(2) + }) + + it('suppresses stale authorization errors after the owner has rejoined', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + handlers[leaveEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-a')) + first.reject(new Error('Delayed authorization failure')) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + expect(socket.emit).not.toHaveBeenCalledWith(errorEvent, expect.anything()) + }) + + it('does not clear a newer pending attempt when the old attempt finishes', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joiningA = handlers[joinEvent](payload('owner-a')) + const second = pendingAuthorization() + const joiningAgain = handlers[joinEvent](payload('owner-a')) + first.resolve(allowed) + await joiningA + expect(rooms.size).toBe(0) + second.resolve(allowed) + await joiningAgain + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + expect(socket.emit.mock.calls.filter(([event]) => event === successEvent)).toHaveLength(1) + }) + + it('preserves another owner while rejecting a revoked pending join', async () => { + const { handlers, socket, rooms } = setup() + await handlers[joinEvent](payload('owner-b')) + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + commitRoomPermission( + socket.userId, + { type: roomType, id: 'owner-a' }, + null, + beginRoomPermissionRead() + ) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-b`])) + expect(socket.emit).toHaveBeenCalledWith( + errorEvent, + expect.objectContaining({ [idKey]: 'owner-a', code: 'ACCESS_DENIED' }) + ) + }) + + it('does not commit any pending joins after disconnect', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + socket.disconnected = true + first.resolve(allowed) + await joining + expect(rooms.size).toBe(0) + expect(socket.emit).not.toHaveBeenCalledWith(successEvent, expect.anything()) + }) + + it('rejects malformed joins without cancelling a valid pending owner', async () => { + const { handlers, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('')) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + }) +}) diff --git a/apps/realtime/src/handlers/workspace-invalidation-room.ts b/apps/realtime/src/handlers/workspace-invalidation-room.ts index 362e37875f6..5bc3bb89b68 100644 --- a/apps/realtime/src/handlers/workspace-invalidation-room.ts +++ b/apps/realtime/src/handlers/workspace-invalidation-room.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform-authz/room-policy' -import { type RoomRef, type RoomType, roomName } from '@sim/realtime-protocol/rooms' +import { + type InvalidationRoomType, + invalidationRoomIdKey, + type RoomRef, + roomName, +} from '@sim/realtime-protocol/rooms' +import { toRecord } from '@sim/utils/object' import { resolveRoomJoinAuth } from '@/handlers/room-join-auth' import type { AuthenticatedSocket } from '@/middleware/auth' import { resolveCurrentRoomPermission } from '@/middleware/permissions' @@ -8,52 +14,41 @@ import type { IRoomManager } from '@/rooms' const logger = createLogger('WorkspaceInvalidationRoom') -interface JoinPayload { - workspaceId: string -} - /** - * Wires a workspace-scoped, presence-free "invalidation room" onto a socket: the client joins a - * room named after its workspace, and a `${roomType}-changed` event — fanned out by the server-side + * Wires an owner-scoped, presence-free "invalidation room" onto a socket: the client joins a + * room named after its owner, and a `${roomType}-changed` event — fanned out by the server-side * mutation path over HTTP — reaches every viewer so they refetch. This is the shared core behind the - * workspace-files and workspace-tables browsers; they differ only in `roomType` (which also derives - * the event names, since each room type's wire token IS its event stem: `join-${roomType}`, - * `leave-${roomType}`, `join-${roomType}-success/-error`, and the `${roomType}-changed` broadcast). + * file and workspace resource browsers; their registered owner key preserves each wire payload. + * The `roomType` derives + * the event names: `join-${roomType}`, + * `leave-${roomType}`, `join-${roomType}-success/-error`, and `${roomType}-changed`. * * These rooms carry NO presence — "who's in a resource" comes from the per-resource room (file-doc / * table), and mutations go over HTTP. Membership is tracked natively by Socket.IO (`socket.rooms`), - * so a workspace switch just leaves the prior room — no room-manager presence bookkeeping to sync. + * so multiple owners can subscribe independently without room-manager presence bookkeeping. */ export function setupWorkspaceInvalidationRoom( socket: AuthenticatedSocket, roomManager: IRoomManager, - roomType: RoomType + roomType: InvalidationRoomType ) { + const idKey = invalidationRoomIdKey(roomType) const joinEvent = `join-${roomType}` const leaveEvent = `leave-${roomType}` const successEvent = `${joinEvent}-success` const errorEvent = `${joinEvent}-error` const roomPrefix = `${roomType}:` - const room = (workspaceId: string): RoomRef => ({ type: roomType, id: workspaceId }) + const room = (ownerId: string): RoomRef => ({ type: roomType, id: ownerId }) - // Monotonic per-socket join counter: each join captures its number and, after the async - // authorize, aborts if a newer intent has superseded it — a fast workspace switch A→B can - // otherwise let A's late completion leave B and strand the socket in A, missing B's - // `${roomType}-changed` invalidations. - let joinGeneration = 0 - // The workspace the socket currently intends to be in (set when a join starts). A leave that - // targets this workspace — or an unscoped "leave all" — advances joinGeneration so an in-flight - // join is cancelled instead of completing after the view has closed. A stale/deferred leave for - // a DIFFERENT workspace must NOT advance it, or it would abort the join the client has since - // switched to (the bug that bit the file-doc room in #5941). - let currentWorkspace: string | null = null + /** Unique tokens prevent an older attempt from surviving an owner leave/rejoin cycle. */ + const joinAttempts = new Map() - socket.on(joinEvent, async ({ workspaceId }: JoinPayload) => { - // Validate synchronously BEFORE claiming a generation, so a rejected/malformed join can't - // advance joinGeneration and cancel a legitimate in-flight join for another workspace. + socket.on(joinEvent, async (payload: unknown) => { + const ownerId = toRecord(payload)[idKey] + /** Reject invalid requests before superseding an existing attempt for this owner. */ if (!socket.userId || !socket.userName) { socket.emit(errorEvent, { - workspaceId, + [idKey]: ownerId, error: 'Authentication required', code: 'AUTHENTICATION_REQUIRED', retryable: false, @@ -63,7 +58,7 @@ export function setupWorkspaceInvalidationRoom( if (!roomManager.isReady()) { socket.emit(errorEvent, { - workspaceId, + [idKey]: ownerId, error: 'Realtime unavailable', code: 'ROOM_MANAGER_UNAVAILABLE', retryable: true, @@ -71,106 +66,97 @@ export function setupWorkspaceInvalidationRoom( return } - // Validate the client-supplied id before it reaches the DB query (join payloads are - // otherwise raw client input) and before advancing the generation. - if (typeof workspaceId !== 'string' || workspaceId.length === 0) { + /** Validate owner identifiers before authorization. */ + if ( + typeof ownerId !== 'string' || + ownerId.length === 0 || + (idKey === 'projectId' && + (ownerId.length > 200 || ownerId !== ownerId.trim() || /[/:\s]/.test(ownerId))) + ) { socket.emit(errorEvent, { - workspaceId: typeof workspaceId === 'string' ? workspaceId : '', - error: 'Invalid workspace id', + [idKey]: typeof ownerId === 'string' ? ownerId : '', + error: idKey === 'projectId' ? 'Invalid Project id' : 'Invalid workspace id', code: 'INVALID_PAYLOAD', retryable: false, }) return } - const joinAttempt = (joinGeneration += 1) - currentWorkspace = workspaceId + const joinAttempt = Symbol() + joinAttempts.set(ownerId, joinAttempt) + const isCurrentAttempt = () => joinAttempts.get(ownerId) === joinAttempt && !socket.disconnected try { - const ref = room(workspaceId) + const ref = room(ownerId) const authorized = await resolveRoomJoinAuth({ userId: socket.userId, + connectionId: socket.id, room: ref, action: ROOM_MEMBERSHIP_ACTIONS[roomType], logger, logLabel: `${roomType} room for ${socket.userId}`, messages: { - verifyFailed: 'Failed to verify workspace access', - notFound: 'Workspace not found', - accessDenied: 'Access denied to workspace', + verifyFailed: + idKey === 'projectId' + ? 'Failed to verify Project access' + : 'Failed to verify workspace access', + notFound: idKey === 'projectId' ? 'Project not found' : 'Workspace not found', + accessDenied: + idKey === 'projectId' ? 'Access denied to Project' : 'Access denied to workspace', + }, + emitError: ({ error, code, retryable }) => { + if (isCurrentAttempt()) { + socket.emit(errorEvent, { [idKey]: ownerId, error, code, retryable }) + } }, - emitError: ({ error, code, retryable }) => - socket.emit(errorEvent, { workspaceId, error, code, retryable }), }) - if (!authorized) return + if (!authorized || !isCurrentAttempt()) return - // Re-check access before committing: the access re-validation sweep records a - // revocation BEFORE it evicts, so a join that authorized just before the - // revocation must not complete afterwards and put the socket back in the room. - // RE-RESOLVES rather than peeking — a peek treats an expired entry as unknown and - // fails open, which a join stalled longer than the cache TTL would slip through. - // Normally a cache hit (this join's own authorize just warmed it). Mirrors the - // file-doc and table joins. + /** Re-resolve access so revocation or an expired permission cache cannot admit a stale join. */ const currentPermission = await resolveCurrentRoomPermission( socket.userId, ref, - ROOM_MEMBERSHIP_ACTIONS[roomType] + ROOM_MEMBERSHIP_ACTIONS[roomType], + socket.id ) + if (!isCurrentAttempt()) return if (!satisfiesRoomMembership(currentPermission, roomType)) { socket.emit(errorEvent, { - workspaceId, - error: 'Access denied to workspace', + [idKey]: ownerId, + error: idKey === 'projectId' ? 'Access denied to Project' : 'Access denied to workspace', code: 'ACCESS_DENIED', retryable: false, }) return } - // A newer join started on this socket during the awaits above — including the access - // re-resolve — or it dropped: abort so a stale join can't leave the room the client has - // since switched to. Last await before the commit, so nothing interleaves after it. - if (joinGeneration !== joinAttempt || socket.disconnected) return - - // Leave any previously-joined room of this type (workspace switch), read straight from the - // socket's native room membership so there's no presence store to keep in sync. - const target = roomName(ref) - for (const joined of socket.rooms) { - if (joined !== target && joined.startsWith(roomPrefix)) socket.leave(joined) - } - - socket.join(target) - socket.emit(successEvent, { workspaceId }) + socket.join(roomName(ref)) + socket.emit(successEvent, { [idKey]: ownerId }) } catch (error) { + if (!isCurrentAttempt()) return logger.error(`Error joining ${roomType} room:`, error) try { - socket.leave(roomName(room(workspaceId))) + socket.leave(roomName(room(ownerId))) } catch {} - // Suppress the client-facing error when this join was already superseded: the client has - // switched to a newer workspace, and a retryable error naming the abandoned one could make it - // re-join and cancel the newer join. The leave above still runs. - if (joinGeneration !== joinAttempt || socket.disconnected) return socket.emit(errorEvent, { - workspaceId, + [idKey]: ownerId, error: `Failed to join ${roomType}`, code: 'JOIN_FAILED', retryable: true, }) + } finally { + if (joinAttempts.get(ownerId) === joinAttempt) joinAttempts.delete(ownerId) } }) - socket.on(leaveEvent, (payload?: { workspaceId?: string }) => { - // Cancel an in-flight join whose target the client is now leaving: a join awaiting - // authorization when the view unmounts would otherwise complete afterwards and strand the - // socket in a room it has left. Only when the leave targets the current join intent (or is - // unscoped) — a deferred leave for a different workspace must not abort the join the client - // has since switched to. - if (!payload?.workspaceId || payload.workspaceId === currentWorkspace) { - joinGeneration += 1 - currentWorkspace = null - } - // Scope the leave to a specific workspace when the client provides one: a deferred leave - // from a prior page must not evict a room the socket has since switched into. - const target = payload?.workspaceId ? roomName(room(payload.workspaceId)) : null + socket.on('disconnect', () => joinAttempts.clear()) + + socket.on(leaveEvent, (payload?: unknown) => { + const ownerId = toRecord(payload)[idKey] + if (ownerId !== undefined && (typeof ownerId !== 'string' || !ownerId)) return + if (ownerId) joinAttempts.delete(ownerId) + else joinAttempts.clear() + const target = ownerId ? roomName(room(ownerId)) : null for (const joined of socket.rooms) { if (!joined.startsWith(roomPrefix)) continue if (target && joined !== target) continue diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index ab2c2cca569..de9773d11c0 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -13,14 +13,9 @@ import { VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, } from '@sim/realtime-protocol/constants' -import { - projectFileDocTarget, - ROOM_TYPES, - type RoomRef, - roomName, -} from '@sim/realtime-protocol/rooms' +import { isProjectRoom, ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' import { and, eq, isNull } from 'drizzle-orm' -import { fetchProjectFileDocAccess } from '@/handlers/file-doc-app' +import { fetchProjectRoomAccess } from '@/handlers/file-list-app' const logger = createLogger('SocketPermissions') @@ -228,10 +223,9 @@ async function readAuthoritativeRoomPermission( room: RoomRef, connectionId?: string ): Promise { - if (room.type === ROOM_TYPES.PROJECT_FILE_DOC) { - const target = projectFileDocTarget(room) - if (!target || !connectionId) throw new Error('Project document requires a socket identity') - const authorization = await fetchProjectFileDocAccess({ ...target, userId, connectionId }) + if (isProjectRoom(room)) { + if (!connectionId) throw new Error('Project room requires a socket identity') + const authorization = await fetchProjectRoomAccess(room, { userId, connectionId }) return authorization.allowed ? authorization.workspacePermission : null } if (room.type === ROOM_TYPES.WORKFLOW) { @@ -270,8 +264,11 @@ async function resolveRoleUncached( // already-revoked user — so a recorded revocation survives a transient DB failure // instead of reverting to the stale join-time role. Only trust `fallbackRole` when // nothing has been recorded for this (user, workflow) yet. - if (room.type === ROOM_TYPES.PROJECT_FILE_DOC) throw error const lastKnown = roleCache.get(key) + if (isProjectRoom(room) || room.type === ROOM_TYPES.WORKSPACE_FILE_DOC) { + if (lastKnown?.role === null) return null + throw error + } return lastKnown !== undefined ? lastKnown.role : fallbackRole } } @@ -283,7 +280,7 @@ async function resolveRoleUncached( * (single-flight), so out-of-order cache writes cannot resurrect revoked access. * * Returns `null` when the user genuinely has no access (removed/revoked, or the room's - * resource is gone). Project callback failures throw so callers can retry without recording a revocation. + * resource is gone). Document authorization failures throw so callers retry without granting writes or recording a revocation. * On a transient DB failure for other room types, it reuses the last recorded decision for * this (user, room) — including a previously recorded revocation (`null`) — and only * falls back to `fallbackRole` when no decision has been recorded yet, so a blip neither diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index c7ed19e10e8..cf0c9f74d12 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -1,19 +1,23 @@ import type { IncomingMessage, ServerResponse } from 'http' import { FILE_DOC_EVENTS, type FileDocInvalidated } from '@sim/realtime-protocol/file-doc' import { - projectFileDocRoom, - ROOM_TYPES, + fileDocAdmissionRoom, + fileDocRoom, + parseFileDocTarget, +} from '@sim/realtime-protocol/file-doc-target' +import { + INVALIDATION_ROOM_TYPES, + invalidationRoomIdKey, roomName, - WORKSPACE_LIST_ROOM_TYPES, } from '@sim/realtime-protocol/rooms' import { safeCompare } from '@sim/security/compare' import { env } from '@/env' import { applyMarkdownToLiveFileDoc, - fileDocAdmissionRoom, invalidateLiveFileDocument, - retireLiveProjectFileDocument, + retireLiveFileDocument, } from '@/handlers/file-doc' +import { fileDocOwnerAdapter } from '@/handlers/file-doc-owner' import { type IRoomManager, WorkflowRoomService } from '@/rooms' interface Logger { @@ -180,16 +184,19 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { // HTTP API (not the socket); this is the lossy liveness signal — a missed one only means // stale-until-refetch. Endpoint and event names derive from the room type, mirroring the socket // handler and the client hook. - const listRoomType = WORKSPACE_LIST_ROOM_TYPES.find( - (type) => req.url === `/api/${type}-changed` - ) + const listRoomType = INVALIDATION_ROOM_TYPES.find((type) => req.url === `/api/${type}-changed`) if (req.method === 'POST' && listRoomType) { try { const body = await readRequestBody(req) - const { workspaceId } = JSON.parse(body) - if (!isNonEmptyString(workspaceId)) return sendError(res, 'Invalid workspaceId', 400) - roomManager.emitToRoom({ type: listRoomType, id: workspaceId }, `${listRoomType}-changed`, { - workspaceId, + const idKey = invalidationRoomIdKey(listRoomType) + const ownerId = JSON.parse(body)?.[idKey] + if ( + !isNonEmptyString(ownerId) || + (idKey === 'projectId' && (ownerId.length > 200 || /[/:\s]/.test(ownerId))) + ) + return sendError(res, 'Invalid collection owner', 400) + roomManager.emitToRoom({ type: listRoomType, id: ownerId }, `${listRoomType}-changed`, { + [idKey]: ownerId, timestamp: Date.now(), }) sendSuccess(res) @@ -208,29 +215,17 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { if (req.method === 'POST' && req.url === '/api/file-doc/apply-edit') { try { const body = await readRequestBody(req) - const { fileId, markdown, version, owner } = JSON.parse(body) - if (!isNonEmptyString(fileId) || typeof markdown !== 'string') { + const input = JSON.parse(body) + const { markdown, version } = input + const target = parseFileDocTarget(input) + if (!target || typeof markdown !== 'string') { return sendError(res, 'Invalid fileId or markdown', 400) } - if ( - owner !== undefined && - (!owner || - owner.entityType !== 'project' || - !isNonEmptyString(owner.entityId) || - /[/:]/.test(owner.entityId) || - /[/:]/.test(fileId)) - ) - return sendError(res, 'Invalid file owner', 400) // `version` (the durable updatedAt this markdown was written with) records that the live doc now // incorporates that durable version, so the persist If-Match guard won't flag it as a conflict. - const result = await applyMarkdownToLiveFileDoc( - fileId, - markdown, - { - version: typeof version === 'number' ? version : undefined, - }, - owner?.entityId - ) + const result = await applyMarkdownToLiveFileDoc(fileDocRoom(target), markdown, { + version: typeof version === 'number' ? version : undefined, + }) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ applied: result === 'applied', status: result })) } catch (error) { @@ -242,30 +237,28 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { if (req.method === 'POST' && req.url === '/api/file-doc/retire') { try { - const { projectId, fileId, retiredDocId, replacementDocId } = JSON.parse( - await readRequestBody(req) - ) + const input = JSON.parse(await readRequestBody(req)) + const { retiredDocId, replacementDocId } = input + const target = parseFileDocTarget(input) if ( - !isNonEmptyString(projectId) || - /[/:]/.test(projectId) || - !isNonEmptyString(fileId) || - /[/:]/.test(fileId) || + !target?.owner || + !fileDocOwnerAdapter(target.owner).tracksLifecycle || !isNonEmptyString(retiredDocId) || retiredDocId.length > 128 || !isNonEmptyString(replacementDocId) || replacementDocId.length > 128 || retiredDocId === replacementDocId ) - return sendError(res, 'Invalid Project document retirement', 400) - const result = await retireLiveProjectFileDocument( - { projectId, fileId, retiredDocId, replacementDocId }, + return sendError(res, 'Invalid file document retirement', 400) + const result = await retireLiveFileDocument( + { fileId: target.fileId, owner: target.owner, retiredDocId, replacementDocId }, roomManager.io ) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ status: result.status })) } catch (error) { - logger.error('Error retiring Project document:', error) - sendError(res, 'Failed to retire Project document') + logger.error('Error retiring file document:', error) + sendError(res, 'Failed to retire file document') } return } @@ -273,24 +266,16 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { if (req.method === 'POST' && req.url === '/api/file-doc/invalidate') { try { const body = await readRequestBody(req) - const { fileId, version, owner } = JSON.parse(body) - if (!isNonEmptyString(fileId)) return sendError(res, 'Invalid fileId', 400) - if ( - owner !== undefined && - (!owner || - owner.entityType !== 'project' || - !isNonEmptyString(owner.entityId) || - /[/:]/.test(owner.entityId) || - /[/:]/.test(fileId)) - ) - return sendError(res, 'Invalid file owner', 400) + const input = JSON.parse(body) + const { version } = input + const target = parseFileDocTarget(input) + if (!target) return sendError(res, 'Invalid file target', 400) + const { fileId } = target if (!Number.isSafeInteger(version) || version <= 0) { return sendError(res, 'Invalid version', 400) } - const room = owner - ? projectFileDocRoom(owner.entityId, fileId) - : ({ type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: fileId } as const) - const result = await invalidateLiveFileDocument(fileId, version, owner?.entityId) + const room = fileDocRoom(target) + const result = await invalidateLiveFileDocument(room, version) const payload: FileDocInvalidated = { fileId, version, @@ -299,7 +284,7 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { } if (result.status === 'applied') roomManager.io - .to([roomName(room), fileDocAdmissionRoom(fileId, owner?.entityId)]) + .to([roomName(room), fileDocAdmissionRoom(target)]) .emit(FILE_DOC_EVENTS.INVALIDATED, payload) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ status: result.status })) diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index 9273ad4fc59..831ad619e32 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -250,6 +250,11 @@ function requireSessionScope(value: string | null, label = 'scope'): string { async function principalUserId(principal: Principal, workspaceId?: string): Promise { switch (principal.kind) { + case 'resource_delegated': + throw new UploadSessionError( + 'forbidden', + 'Resource delegation cannot create workspace uploads' + ) case 'slack_app': case 'slack_installation': throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads') diff --git a/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/access/route.ts b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/access/route.ts new file mode 100644 index 00000000000..11e44aefe8b --- /dev/null +++ b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/access/route.ts @@ -0,0 +1,20 @@ +import { projectFileDocAccessContract } from '@/lib/api/contracts/project-file-doc' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { realtimeProjectFileAuth } from '@/lib/auth/realtime-file-delegation' +import { getProjectFileDocAccess } from '@/lib/projects/files/application/documents' + +export const POST = defineInternalJsonRoute({ + contract: projectFileDocAccessContract, + operation: getProjectFileDocAccess.operation, + auth: realtimeProjectFileAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated realtime relay; socket frame admission bounds collaborative traffic', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getProjectFileDocAccess, +}) diff --git a/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts new file mode 100644 index 00000000000..05223b4958f --- /dev/null +++ b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts @@ -0,0 +1,25 @@ +import { projectFileDocPersistContract } from '@/lib/api/contracts/project-file-doc' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { realtimeProjectFileAuth } from '@/lib/auth/realtime-file-delegation' +import { persistProjectFileDoc } from '@/lib/projects/files/application/documents' + +export const POST = defineInternalJsonRoute({ + contract: projectFileDocPersistContract, + operation: persistProjectFileDoc.operation, + auth: realtimeProjectFileAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated realtime relay; socket frame admission bounds collaborative traffic', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + ...params, + docState: new Uint8Array(Buffer.from(body.docState, 'base64')), + expectedVersion: body.expectedVersion, + }), + useCase: persistProjectFileDoc, + parseOptions: { maxBodyBytes: 17 * 1024 * 1024 }, +}) diff --git a/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/seed/route.ts b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/seed/route.ts new file mode 100644 index 00000000000..5c0ee2895e2 --- /dev/null +++ b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/seed/route.ts @@ -0,0 +1,24 @@ +import { projectFileDocSeedContract } from '@/lib/api/contracts/project-file-doc' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { realtimeProjectFileAuth } from '@/lib/auth/realtime-file-delegation' +import { buildProjectFileDocSeed } from '@/lib/projects/files/application/documents' + +export const POST = defineInternalJsonRoute({ + contract: projectFileDocSeedContract, + operation: buildProjectFileDocSeed.operation, + auth: realtimeProjectFileAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated realtime relay; socket frame admission bounds collaborative traffic', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }, { request }) => ({ ...params, signal: request.signal }), + useCase: buildProjectFileDocSeed, + present: (result) => ({ + update: Buffer.from(result.update).toString('base64'), + version: result.version, + }), +}) diff --git a/apps/sim/app/api/internal/project-file-list/[projectId]/access/route.ts b/apps/sim/app/api/internal/project-file-list/[projectId]/access/route.ts new file mode 100644 index 00000000000..0e735c4afe2 --- /dev/null +++ b/apps/sim/app/api/internal/project-file-list/[projectId]/access/route.ts @@ -0,0 +1,21 @@ +import { projectFileListAccessContract } from '@/lib/api/contracts/realtime-file-lists' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { realtimeProjectFileListAuth } from '@/lib/auth/realtime-file-list-delegation' +import { getProjectFileListAccess } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: projectFileListAccessContract, + operation: getProjectFileListAccess.operation, + auth: realtimeProjectFileListAuth, + rateLimit: internalRateLimits.none({ + reason: + 'Service-authenticated realtime collection admission and bounded membership revalidation', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getProjectFileListAccess, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts new file mode 100644 index 00000000000..524ad9765ac --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts @@ -0,0 +1,58 @@ +import { + readProjectFileContentContract, + updateProjectFileContentContract, +} from '@/lib/api/contracts/project-files' +import { + defineInternalBinaryRoute, + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileContent } from '@/lib/projects/files/api' +import { + downloadProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' + +export const GET = defineInternalBinaryRoute({ + contract: readProjectFileContentContract, + auth: internalSessionAuth, + headSafe: false, + operation: downloadProjectFileContent.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated internal file byte delivery follows the workspace download policy', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: downloadProjectFileContent, + present: presentProjectFileContent, +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateProjectFileContentContract, + auth: internalSessionAuth, + operation: updateProjectFileContent.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.id === 'string' && typeof params.fileId === 'string') + await updateProjectFileContent.authorize({ + principal, + input: { projectId: params.id, fileId: params.fileId, content: '', encoding: 'utf-8' }, + }) + }, + mapInput: ({ params, body }) => ({ + projectId: params.id, + fileId: params.fileId, + content: body.content, + encoding: body.encoding ?? 'utf-8', + contentType: body.contentType, + expectedRevision: body.expectedRevision, + ...(body.expectedUpdatedAt ? { expectedUpdatedAt: new Date(body.expectedUpdatedAt) } : {}), + }), + useCase: updateProjectFileContent, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/restore/route.ts new file mode 100644 index 00000000000..9f6c0f30773 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/restore/route.ts @@ -0,0 +1,18 @@ +import { restoreProjectFileContract } from '@/lib/api/contracts/project-file-lifecycle' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { projectFileOperations, restoreProjectFile } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: restoreProjectFileContract, + auth: internalSessionAuth, + operation: projectFileOperations.restore, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: restoreProjectFile, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/route.ts new file mode 100644 index 00000000000..d41fbbefccd --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/route.ts @@ -0,0 +1,33 @@ +import { renameProjectFileContract } from '@/lib/api/contracts/project-file-lifecycle' +import { getProjectFileContract } from '@/lib/api/contracts/project-files' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + getProjectFileMetadata, + projectFileOperations, + renameProjectFile, +} from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectFileContract, + auth: internalSessionAuth, + operation: projectFileOperations.readMetadata, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: getProjectFileMetadata, +}) + +export const PATCH = defineInternalJsonRoute({ + contract: renameProjectFileContract, + auth: internalSessionAuth, + operation: projectFileOperations.rename, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, fileId: params.fileId, ...body }), + useCase: renameProjectFile, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/content/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/content/route.ts new file mode 100644 index 00000000000..587644c7b52 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/content/route.ts @@ -0,0 +1,26 @@ +import { readProjectFileVersionContentContract } from '@/lib/api/contracts/project-file-versions' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileVersionContent } from '@/lib/projects/files/api' +import { downloadProjectFileVersionContent } from '@/lib/projects/files/application' +export const GET = defineInternalBinaryRoute({ + contract: readProjectFileVersionContentContract, + auth: internalSessionAuth, + headSafe: false, + operation: downloadProjectFileVersionContent.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated historical byte delivery follows the workspace download policy', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.id, + fileId: params.fileId, + version: params.version, + }), + useCase: downloadProjectFileVersionContent, + present: presentProjectFileVersionContent, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/revert/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/revert/route.ts new file mode 100644 index 00000000000..311d998f099 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/revert/route.ts @@ -0,0 +1,31 @@ +import { revertProjectFileVersionContract } from '@/lib/api/contracts/project-file-versions' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { revertProjectFileVersion } from '@/lib/projects/files/application' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +export const POST = defineInternalJsonRoute({ + contract: revertProjectFileVersionContract, + auth: internalSessionAuth, + operation: revertProjectFileVersion.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.history' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + projectId: params.id, + fileId: params.fileId, + version: params.version, + expectedCurrentVersion: body.expectedCurrentVersion, + expectedRevision: body.expectedRevision, + }), + useCase: revertProjectFileVersion, + present: ({ file, version, reverted }) => ({ + file: file, + version: toProjectFileVersion(version), + reverted, + ...workspaceFileRevisionField(file), + }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/route.ts new file mode 100644 index 00000000000..38b20a207cd --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/route.ts @@ -0,0 +1,40 @@ +import { + deleteProjectFileVersionContract, + getProjectFileVersionContract, +} from '@/lib/api/contracts/project-file-versions' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { deleteProjectFileVersion, readProjectFileVersion } from '@/lib/projects/files/application' +export const GET = defineInternalJsonRoute({ + contract: getProjectFileVersionContract, + auth: internalSessionAuth, + operation: readProjectFileVersion.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.history' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.id, + fileId: params.fileId, + version: params.version, + }), + useCase: readProjectFileVersion, + present: ({ version }) => ({ version: toProjectFileVersion(version) }), +}) +export const DELETE = defineInternalJsonRoute({ + contract: deleteProjectFileVersionContract, + auth: internalSessionAuth, + operation: deleteProjectFileVersion.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.history' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.id, + fileId: params.fileId, + version: params.version, + }), + useCase: deleteProjectFileVersion, + present: ({ file, version }) => ({ fileId: file.id, version, deleted: true as const }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/versions/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/route.ts new file mode 100644 index 00000000000..297ee8dcef2 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/route.ts @@ -0,0 +1,48 @@ +import { listProjectFileVersionsContract } from '@/lib/api/contracts/project-file-versions' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { listProjectFileVersions } from '@/lib/projects/files/application' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +export const GET = defineInternalJsonRoute({ + contract: listProjectFileVersionsContract, + auth: internalSessionAuth, + operation: listProjectFileVersions.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.history' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + projectId: params.id, + fileId: params.fileId, + sortOrder: query.sortOrder, + limit: query.limit, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey( + cursorRoute(listProjectFileVersionsContract, { id: params.id, fileId: params.fileId }) + ) + ), + }), + useCase: listProjectFileVersions, + present: ({ versions, nextKeys, revision }, { input }) => ({ + revision, + versions: versions.map(toProjectFileVersion), + nextCursor: writeSortedCursor( + nextKeys, + 'version', + input.sortOrder, + cursorScopeKey( + cursorRoute(listProjectFileVersionsContract, { + id: input.projectId, + fileId: input.fileId, + }) + ) + ), + }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/archive/route.ts b/apps/sim/app/api/projects/[id]/files/archive/route.ts new file mode 100644 index 00000000000..5dda324ffaa --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/archive/route.ts @@ -0,0 +1,18 @@ +import { archiveProjectFileItemsContract } from '@/lib/api/contracts/project-file-lifecycle' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { archiveProjectFileItems, projectFileOperations } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: archiveProjectFileItemsContract, + auth: internalSessionAuth, + operation: projectFileOperations.archiveItems, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, ...body }), + useCase: archiveProjectFileItems, +}) diff --git a/apps/sim/app/api/projects/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/projects/[id]/files/folders/[folderId]/restore/route.ts new file mode 100644 index 00000000000..4afc4583018 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/folders/[folderId]/restore/route.ts @@ -0,0 +1,18 @@ +import { restoreProjectFileFolderContract } from '@/lib/api/contracts/project-file-lifecycle' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { projectFileOperations, restoreProjectFileFolder } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: restoreProjectFileFolderContract, + auth: internalSessionAuth, + operation: projectFileOperations.restoreFolder, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, folderId: params.folderId }), + useCase: restoreProjectFileFolder, +}) diff --git a/apps/sim/app/api/projects/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/projects/[id]/files/folders/[folderId]/route.ts new file mode 100644 index 00000000000..66ae653f777 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/folders/[folderId]/route.ts @@ -0,0 +1,19 @@ +import { updateProjectFileFolderContract } from '@/lib/api/contracts/project-file-folders' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { projectFileOperations, updateProjectFileFolder } from '@/lib/projects/files/application' + +export const PATCH = defineInternalJsonRoute({ + contract: updateProjectFileFolderContract, + auth: internalSessionAuth, + operation: projectFileOperations.updateFolder, + rateLimit: internalRateLimits.user({ bucketName: 'project-file-folders.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, folderId: params.folderId, ...body }), + useCase: updateProjectFileFolder, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/folders/route.ts b/apps/sim/app/api/projects/[id]/files/folders/route.ts new file mode 100644 index 00000000000..47cb2c6e6d4 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/folders/route.ts @@ -0,0 +1,37 @@ +import { + createProjectFileFolderContract, + listProjectFileFoldersContract, +} from '@/lib/api/contracts/project-file-folders' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + createProjectFileFolder, + listProjectFileFolders, + projectFileOperations, +} from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: listProjectFileFoldersContract, + auth: internalSessionAuth, + operation: projectFileOperations.listFolders, + rateLimit: internalRateLimits.user({ bucketName: 'project-file-folders.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ projectId: params.id, scope: query.scope }), + useCase: listProjectFileFolders, + present: (result) => result, +}) + +export const POST = defineInternalJsonRoute({ + contract: createProjectFileFolderContract, + auth: internalSessionAuth, + operation: projectFileOperations.createFolder, + rateLimit: internalRateLimits.user({ bucketName: 'project-file-folders.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, ...body }), + useCase: createProjectFileFolder, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/move/route.ts b/apps/sim/app/api/projects/[id]/files/move/route.ts new file mode 100644 index 00000000000..1c9daa7ba64 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/move/route.ts @@ -0,0 +1,18 @@ +import { moveProjectFileItemsContract } from '@/lib/api/contracts/project-file-lifecycle' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { moveProjectFileItems, projectFileOperations } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: moveProjectFileItemsContract, + auth: internalSessionAuth, + operation: projectFileOperations.moveItems, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, ...body }), + useCase: moveProjectFileItems, +}) diff --git a/apps/sim/app/api/projects/[id]/files/route.ts b/apps/sim/app/api/projects/[id]/files/route.ts new file mode 100644 index 00000000000..267b8187139 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/route.ts @@ -0,0 +1,119 @@ +import { + createProjectFileContract, + listProjectFilesContract, +} from '@/lib/api/contracts/project-files' +import { canonicalUnorderedArray, cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + createProjectFile, + listProjectFileItems, + projectFileOperations, +} from '@/lib/projects/files/application' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +function cursorFilters( + id: string, + query: { + scope?: 'active' | 'archived' + folderId?: string | null + types?: readonly string[] + sizes?: readonly string[] + creatorIds?: readonly string[] + search?: string + } +) { + return cursorScopeKey(cursorRoute(listProjectFilesContract, { id }), { + scope: query.scope, + folderId: query.folderId, + types: canonicalUnorderedArray(query.types ?? []), + sizes: canonicalUnorderedArray(query.sizes ?? []), + creatorIds: canonicalUnorderedArray(query.creatorIds ?? []), + search: query.search, + }) +} + +export const GET = defineInternalJsonRoute({ + contract: listProjectFilesContract, + auth: internalSessionAuth, + operation: projectFileOperations.list, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => { + const folderId = + query.recursive || query.search || query.scope === 'archived' + ? undefined + : (query.folderId ?? null) + return { + projectId: params.id, + scope: query.scope, + folderId, + types: query.types, + sizes: query.sizes, + creatorIds: query.creatorIds, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params.id, { ...query, folderId }) + ), + } + }, + useCase: listProjectFileItems, + present: ({ files, items, creators, nextKeys, capabilities }, { input }) => ({ + files, + items, + creators, + capabilities, + nextCursor: writeSortedCursor( + nextKeys, + input.sortBy, + input.sortOrder, + cursorFilters(input.projectId, input) + ), + }), +}) + +export const POST = defineInternalJsonRoute({ + contract: createProjectFileContract, + auth: internalSessionAuth, + operation: projectFileOperations.create, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.id === 'string') + await createProjectFile.authorize({ + principal, + input: { + projectId: params.id, + name: 'admission', + contentType: 'application/octet-stream', + content: '', + encoding: 'utf-8', + }, + }) + }, + mapInput: ({ params, body }) => ({ + projectId: params.id, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + exactName: body.exactName, + folderId: body.folderId, + folderPath: body.folderPath, + }), + useCase: createProjectFile, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..0cd1e6a422d --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,25 @@ +import { completeProjectFileUploadContract } from '@/lib/api/contracts/project-file-uploads' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileUpload } from '@/lib/projects/files/api' +import { completeProjectFileUploadSession } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: completeProjectFileUploadContract, + parseOptions: { optionalJsonBody: true }, + auth: internalSessionAuth, + operation: completeProjectFileUploadSession.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.id, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: completeProjectFileUploadSession, + present: ({ session, value }) => presentProjectFileUpload(session, value.file), +}) diff --git a/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..79e26b2983a --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,26 @@ +import { getProjectFileUploadPartUrlsContract } from '@/lib/api/contracts/project-file-uploads' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getProjectFileUploadPartUrls } from '@/lib/projects/files/application' +import { requestOrigin } from '@/lib/uploads/upload-session/application' + +export const POST = defineInternalJsonRoute({ + contract: getProjectFileUploadPartUrlsContract, + auth: internalSessionAuth, + operation: getProjectFileUploadPartUrls.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, headers, body }, { request }) => ({ + projectId: params.id, + localOrigin: requestOrigin(request), + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: getProjectFileUploadPartUrls, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..d4d751cdad1 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/route.ts @@ -0,0 +1,45 @@ +import { + abortProjectFileUploadContract, + getProjectFileUploadContract, +} from '@/lib/api/contracts/project-file-uploads' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileUpload } from '@/lib/projects/files/api' +import { + abortProjectFileUploadSession, + getProjectFileUploadSession, +} from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectFileUploadContract, + auth: internalSessionAuth, + operation: getProjectFileUploadSession.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.id, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: getProjectFileUploadSession, + present: ({ session, file }) => presentProjectFileUpload(session, file), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: abortProjectFileUploadContract, + auth: internalSessionAuth, + operation: abortProjectFileUploadSession.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.id, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: abortProjectFileUploadSession, + present: (session) => presentProjectFileUpload(session, null), +}) diff --git a/apps/sim/app/api/projects/[id]/files/uploads/route.ts b/apps/sim/app/api/projects/[id]/files/uploads/route.ts new file mode 100644 index 00000000000..4f64cc3d0a0 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/uploads/route.ts @@ -0,0 +1,33 @@ +import { createProjectFileUploadContract } from '@/lib/api/contracts/project-file-uploads' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileUpload } from '@/lib/projects/files/api' +import { createProjectFileUploadSession } from '@/lib/projects/files/application' +import { requestOrigin } from '@/lib/uploads/upload-session/application' + +export const POST = defineInternalJsonRoute({ + contract: createProjectFileUploadContract, + auth: internalSessionAuth, + operation: createProjectFileUploadSession.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }, { request }) => ({ + projectId: params.id, + localOrigin: requestOrigin(request), + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + folderId: body.folderId, + folderPath: body.folderPath, + }), + useCase: createProjectFileUploadSession, + present: (session) => ({ + session: presentProjectFileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }), +}) diff --git a/apps/sim/app/api/users/me/settings/route.test.ts b/apps/sim/app/api/users/me/settings/route.test.ts index 050225f6090..0485a5b31cc 100644 --- a/apps/sim/app/api/users/me/settings/route.test.ts +++ b/apps/sim/app/api/users/me/settings/route.test.ts @@ -44,7 +44,10 @@ describe('GET /api/users/me/settings', () => { }) it('preserves anonymous defaults without entering the protected current-user read', async () => { - const response = await GET() + const response = await GET( + createMockRequest({ method: 'GET', url: 'http://localhost:3000/api/users/me/settings' }), + undefined + ) expect(response.status).toBe(200) await expect(response.json()).resolves.toMatchObject({ @@ -59,7 +62,10 @@ describe('GET /api/users/me/settings', () => { throw new Error('Database unavailable') }) - const response = await GET() + const response = await GET( + createMockRequest({ method: 'GET', url: 'http://localhost:3000/api/users/me/settings' }), + undefined + ) expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'Failed to load settings' }) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index eb0df4f5f66..a81e5b9ea15 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -9,12 +9,12 @@ import { v2ApiKeyAuth, v2RateLimits, } from '@/lib/api/server/routes' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { encodeFilenameForHeader } from '@/app/api/files/utils' import { toV2File } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' @@ -44,12 +44,15 @@ export const GET = defineV2BinaryRoute({ assertedWorkspaceId: query.workspaceId, }), useCase: downloadWorkspaceFileStream, - present: ({ file, stream, contentType, contentLength }) => ({ - body: stream, - contentType, - contentDisposition: `attachment; ${encodeFilenameForHeader(file.name)}`, - contentLength, - }), + present: ({ file, stream, contentType, contentLength }) => + presentFileDelivery({ + body: stream, + filename: file.name, + contentType, + contentLength, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }), }) /** diff --git a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts index d2f978f1572..5c5c16fe85f 100644 --- a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts @@ -1,9 +1,9 @@ import { v2DownloadFileVersionContract } from '@/lib/api/contracts/v2/file-versions' import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { downloadWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { encodeFilenameForHeader } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -29,10 +29,13 @@ export const GET = defineV2BinaryRoute({ version: params.version, }), useCase: downloadWorkspaceFileVersion, - present: ({ file, stream, contentType, contentLength }) => ({ - body: stream, - contentType, - contentDisposition: `attachment; ${encodeFilenameForHeader(file.name)}`, - contentLength, - }), + present: ({ file, stream, contentType, contentLength }) => + presentFileDelivery({ + body: stream, + filename: file.name, + contentType, + contentLength, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }), }) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/content/route.ts new file mode 100644 index 00000000000..8153a30ee77 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/content/route.ts @@ -0,0 +1,61 @@ +import { + v2ReadProjectFileContentContract, + v2UpdateProjectFileContentContract, +} from '@/lib/api/contracts/v2/project-files' +import { + defineV2BinaryRoute, + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentProjectFileContent } from '@/lib/projects/files/api' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { + downloadProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' + +export const GET = defineV2BinaryRoute({ + contract: v2ReadProjectFileContentContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }), + useCase: downloadProjectFileContent, + present: presentProjectFileContent, + headSafe: false, +}) + +export const PUT = defineV2JsonRoute({ + contract: v2UpdateProjectFileContentContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.updateContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.projectId === 'string' && typeof params.fileId === 'string') + await updateProjectFileContent.authorize({ + principal, + input: { + projectId: params.projectId, + fileId: params.fileId, + content: '', + encoding: 'utf-8', + }, + }) + }, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + fileId: params.fileId, + content: body.content, + encoding: body.encoding, + expectedRevision: body.expectedRevision, + }), + useCase: updateProjectFileContent, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route.ts new file mode 100644 index 00000000000..bd9bf654b3f --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route.ts @@ -0,0 +1,21 @@ +import { v2GetProjectFileMetadataContract } from '@/lib/api/contracts/v2/project-files' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { getProjectFileMetadata } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetProjectFileMetadataContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readMetadata, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getProjectFileMetadata, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/restore/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/restore/route.ts new file mode 100644 index 00000000000..cf0b5a51d42 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/restore/route.ts @@ -0,0 +1,21 @@ +import { v2RestoreProjectFileContract } from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { restoreProjectFile } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2RestoreProjectFileContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.restore, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ ...params }), + useCase: restoreProjectFile, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/route.ts new file mode 100644 index 00000000000..f60e2064cf9 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/route.ts @@ -0,0 +1,21 @@ +import { v2RenameProjectFileContract } from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { renameProjectFile } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const PATCH = defineV2JsonRoute({ + contract: v2RenameProjectFileContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.rename, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: renameProjectFile, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route.ts new file mode 100644 index 00000000000..6f13ce8af36 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route.ts @@ -0,0 +1,25 @@ +import { v2ReadProjectFileVersionContentContract } from '@/lib/api/contracts/v2/project-file-versions' +import { + defineV2BinaryRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentProjectFileVersionContent } from '@/lib/projects/files/api' +import { downloadProjectFileVersionContent } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +export const GET = defineV2BinaryRoute({ + contract: v2ReadProjectFileVersionContentContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readVersionContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + headSafe: false, + mapInput: ({ params }) => ({ + projectId: params.projectId, + fileId: params.fileId, + version: params.version, + }), + useCase: downloadProjectFileVersionContent, + present: presentProjectFileVersionContent, +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route.ts new file mode 100644 index 00000000000..88e6b4343b6 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route.ts @@ -0,0 +1,34 @@ +import { v2RevertProjectFileVersionContract } from '@/lib/api/contracts/v2/project-file-versions' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toProjectFileVersion, toV2ProjectFile } from '@/lib/projects/files/api' +import { revertProjectFileVersion } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +export const POST = defineV2JsonRoute({ + contract: v2RevertProjectFileVersionContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.revertVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + fileId: params.fileId, + version: params.version, + expectedCurrentVersion: body.expectedCurrentVersion, + expectedRevision: body.expectedRevision, + }), + useCase: revertProjectFileVersion, + present: ({ file, version, reverted }) => ({ + data: { + file: toV2ProjectFile(file), + version: toProjectFileVersion(version), + reverted, + ...workspaceFileRevisionField(file), + }, + }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route.ts new file mode 100644 index 00000000000..4f10d24cc3c --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route.ts @@ -0,0 +1,41 @@ +import { + v2DeleteProjectFileVersionContract, + v2GetProjectFileVersionContract, +} from '@/lib/api/contracts/v2/project-file-versions' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { deleteProjectFileVersion, readProjectFileVersion } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +export const GET = defineV2JsonRoute({ + contract: v2GetProjectFileVersionContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.projectId, + fileId: params.fileId, + version: params.version, + }), + useCase: readProjectFileVersion, + present: ({ version }) => ({ data: toProjectFileVersion(version) }), +}) +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteProjectFileVersionContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.deleteVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.projectId, + fileId: params.fileId, + version: params.version, + }), + useCase: deleteProjectFileVersion, + present: ({ file, version }) => ({ data: { fileId: file.id, version, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/route.ts new file mode 100644 index 00000000000..648b77ab7d1 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/route.ts @@ -0,0 +1,51 @@ +import { v2ListProjectFileVersionsContract } from '@/lib/api/contracts/v2/project-file-versions' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { listProjectFileVersions } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +export const GET = defineV2JsonRoute({ + contract: v2ListProjectFileVersionsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.listVersions, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + projectId: params.projectId, + fileId: params.fileId, + sortOrder: query.sortOrder, + limit: query.limit, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey( + cursorRoute(v2ListProjectFileVersionsContract, { + projectId: params.projectId, + fileId: params.fileId, + }) + ) + ), + }), + useCase: listProjectFileVersions, + present: ({ versions, nextKeys }, { params, query }) => ({ + data: versions.map(toProjectFileVersion), + nextCursor: writeSortedCursor( + nextKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey( + cursorRoute(v2ListProjectFileVersionsContract, { + projectId: params.projectId, + fileId: params.fileId, + }) + ) + ), + }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/archive/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/archive/route.ts new file mode 100644 index 00000000000..3595cddde3b --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/archive/route.ts @@ -0,0 +1,20 @@ +import { v2ArchiveProjectFileItemsContract } from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { archiveProjectFileItems } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2ArchiveProjectFileItemsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.archiveItems, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: archiveProjectFileItems, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route.ts new file mode 100644 index 00000000000..2acf28c40e9 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route.ts @@ -0,0 +1,23 @@ +import { v2RestoreProjectFileFolderContract } from '@/lib/api/contracts/v2/project-file-folders' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileFolder } from '@/lib/projects/files/api/presenters' +import { restoreProjectFileFolder } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2RestoreProjectFileFolderContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.restoreFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ ...params }), + useCase: restoreProjectFileFolder, + present: ({ folder, restoredItems }) => ({ + data: { folder: toV2ProjectFileFolder(folder), restoredItems }, + }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/route.ts new file mode 100644 index 00000000000..5a8f80ac5ed --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/route.ts @@ -0,0 +1,21 @@ +import { v2UpdateProjectFileFolderContract } from '@/lib/api/contracts/v2/project-file-folders' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileFolder } from '@/lib/projects/files/api/presenters' +import { updateProjectFileFolder } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateProjectFileFolderContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.updateFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateProjectFileFolder, + present: ({ folder }) => ({ data: toV2ProjectFileFolder(folder) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/folders/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/folders/route.ts new file mode 100644 index 00000000000..e56888d13ce --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/folders/route.ts @@ -0,0 +1,35 @@ +import { + v2CreateProjectFileFolderContract, + v2ListProjectFileFoldersContract, +} from '@/lib/api/contracts/v2/project-file-folders' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileFolder } from '@/lib/projects/files/api/presenters' +import { createProjectFileFolder, listProjectFileFolders } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2ListProjectFileFoldersContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.listFolders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: listProjectFileFolders, + present: ({ folders }) => ({ data: folders.map(toV2ProjectFileFolder), nextCursor: null }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateProjectFileFolderContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.createFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: createProjectFileFolder, + present: ({ folder }) => ({ data: toV2ProjectFileFolder(folder) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/move/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/move/route.ts new file mode 100644 index 00000000000..2da7873dd48 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/move/route.ts @@ -0,0 +1,24 @@ +import { v2MoveProjectFileItemsContract } from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { moveProjectFileItems } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2MoveProjectFileItemsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.moveItems, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + ...params, + ...body, + targetFolderPath: body.targetFolderPath ?? '/', + }), + useCase: moveProjectFileItems, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/route.ts new file mode 100644 index 00000000000..b4a81170233 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/route.ts @@ -0,0 +1,82 @@ +import { listsSubfolders } from '@/lib/api/contracts/v2/files' +import { + type V2ListProjectFilesQuery, + v2CreateProjectFileContract, + v2ListProjectFilesContract, +} from '@/lib/api/contracts/v2/project-files' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { createProjectFile, listProjectFiles } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +function cursorFilters(projectId: string, query: V2ListProjectFilesQuery) { + return cursorScopeKey(cursorRoute(v2ListProjectFilesContract, { projectId }), { + scope: query.scope, + folderPath: query.folderPath, + recursive: String(listsSubfolders(query)), + search: query.search, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListProjectFilesContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + projectId: params.projectId, + scope: query.scope, + folderPath: query.folderPath, + recursive: listsSubfolders(query), + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params.projectId, query) + ), + }), + useCase: listProjectFiles, + present: ({ files, nextKeys }, { params, query }) => ({ + data: files.map(toV2ProjectFile), + nextCursor: writeSortedCursor( + nextKeys, + query.sortBy, + query.sortOrder, + cursorFilters(params.projectId, query) + ), + }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateProjectFileContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + folderPath: body.folderPath ?? '/', + exactName: true, + }), + useCase: createProjectFile, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..1c5f1c1a75a --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,25 @@ +import { v2CompleteProjectFileUploadContract } from '@/lib/api/contracts/v2/project-file-uploads' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileUpload } from '@/lib/projects/files/api/upload-presenter' +import { completeProjectFileUploadSession } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2CompleteProjectFileUploadContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadComplete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.projectId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: completeProjectFileUploadSession, + present: ({ session, value }) => ({ data: toV2ProjectFileUpload(session, value.file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..f86960c363e --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,25 @@ +import { v2GetProjectFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/project-file-uploads' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { getProjectFileUploadPartUrls } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2GetProjectFileUploadPartUrlsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadParts, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, headers, body }) => ({ + projectId: params.projectId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: getProjectFileUploadPartUrls, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..342b400ec6f --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route.ts @@ -0,0 +1,46 @@ +import { + v2AbortProjectFileUploadContract, + v2GetProjectFileUploadContract, +} from '@/lib/api/contracts/v2/project-file-uploads' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileUpload } from '@/lib/projects/files/api/upload-presenter' +import { + abortProjectFileUploadSession, + getProjectFileUploadSession, +} from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetProjectFileUploadContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadRead, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.projectId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: getProjectFileUploadSession, + present: ({ session, file }) => ({ data: toV2ProjectFileUpload(session, file) }), +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2AbortProjectFileUploadContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadCancel, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.projectId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: abortProjectFileUploadSession, + present: (session) => ({ data: toV2ProjectFileUpload(session, null) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/uploads/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/route.ts new file mode 100644 index 00000000000..39fff7c9ea9 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/route.ts @@ -0,0 +1,34 @@ +import { v2CreateProjectFileUploadContract } from '@/lib/api/contracts/v2/project-file-uploads' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileUpload } from '@/lib/projects/files/api/upload-presenter' +import { createProjectFileUploadSession } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2CreateProjectFileUploadContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadCreate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + folderId: body.folderId, + folderPath: body.folderPath, + }), + useCase: createProjectFileUploadSession, + present: (session) => ({ + data: { + session: toV2ProjectFileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts index 5679f08562e..779c4070d69 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts @@ -1,9 +1,9 @@ import { v2DownloadRunFileContract } from '@/lib/api/contracts/v2/workflows' import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { downloadWorkflowRunFileStream } from '@/lib/workflows/application/download-workflow-run-file' import { workflowOperations } from '@/lib/workflows/application/operations' -import { encodeFilenameForHeader } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts index 7cbe7d5d6da..a2c3f14599f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -103,13 +103,8 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { expect(mocks.updateContent).not.toHaveBeenCalled() }) - /** - * The route declares a 70 MB inline cap, but Next's proxy truncates a client - * body past 10 MiB, so the parser clamps to that ceiling and answers 413 - * rather than letting a truncated prefix surface as malformed JSON. - */ it('rejects a JSON body above the proxy ceiling after admission', async () => { - const response = await PUT(createRequest({ content: '' }, 10 * 1024 * 1024 + 1), routeContext) + const response = await PUT(createRequest({ content: '' }, 17 * 1024 * 1024 + 1), routeContext) expect(response.status).toBe(413) expect(mocks.admit).toHaveBeenCalled() diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/content/route.ts new file mode 100644 index 00000000000..51830d2cce9 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/content/route.ts @@ -0,0 +1,36 @@ +import { downloadWorkspaceFileVersionContract } from '@/lib/api/contracts/workspace-file-versions' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { downloadWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const GET = defineInternalBinaryRoute({ + contract: downloadWorkspaceFileVersionContract, + auth: internalSessionAuth, + headSafe: false, + operation: fileOperations.downloadVersion, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated historical downloads follow the workspace binary delivery policy', + }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params }) => ({ + assertedWorkspaceId: params.id, + fileId: params.fileId, + version: params.version, + }), + useCase: downloadWorkspaceFileVersion, + present: ({ file, stream, contentType, contentLength }) => + presentFileDelivery({ + body: stream, + filename: file.name, + contentType, + contentLength, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/revert/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/revert/route.ts new file mode 100644 index 00000000000..5cf11381e7f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/revert/route.ts @@ -0,0 +1,26 @@ +import { revertWorkspaceFileVersionContract } from '@/lib/api/contracts/workspace-file-versions' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +import { revertWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: revertWorkspaceFileVersionContract, + auth: internalSessionAuth, + operation: fileOperations.revertVersion, + rateLimit: internalRateLimits.user({ bucketName: 'workspace-files.history' }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + assertedWorkspaceId: params.id, + fileId: params.fileId, + version: params.version, + ...body, + }), + useCase: revertWorkspaceFileVersion, + present: ({ file, reverted }) => ({ reverted, ...workspaceFileRevisionField(file) }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/route.ts new file mode 100644 index 00000000000..0930003b7c2 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/route.ts @@ -0,0 +1,47 @@ +import { listWorkspaceFileVersionsContract } from '@/lib/api/contracts/workspace-file-versions' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies, toFileVersion } from '@/lib/workspace-files/api' +import { listWorkspaceFileVersions } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceFileVersionsContract, + auth: internalSessionAuth, + operation: fileOperations.listVersions, + rateLimit: internalRateLimits.user({ bucketName: 'workspace-files.history' }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + assertedWorkspaceId: params.id, + fileId: params.fileId, + limit: query.limit, + sortOrder: query.sortOrder, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(listWorkspaceFileVersionsContract, params)) + ), + }), + useCase: listWorkspaceFileVersions, + present: ({ versions, nextKeys, revision }, { input }) => ({ + versions: versions.map(toFileVersion), + revision, + nextCursor: writeSortedCursor( + nextKeys, + 'version', + input.sortOrder, + cursorScopeKey( + cursorRoute(listWorkspaceFileVersionsContract, { + id: input.assertedWorkspaceId, + fileId: input.fileId, + }) + ) + ), + }), +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 91e45a93748..a34fd55f286 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -7,11 +7,12 @@ import { FILE_DOC_TIMEOUTS, type FileDocUpdateAck, } from '@sim/realtime-protocol/file-doc' +import { flushMicrotasks } from '@sim/testing/helpers/async' import { update as updateJournalStorage } from 'idb-keyval' import * as decoding from 'lib0/decoding' import * as encoding from 'lib0/encoding' import type { Socket } from 'socket.io-client' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import * as awarenessProtocol from 'y-protocols/awareness' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' @@ -30,6 +31,13 @@ vi.mock('idb-keyval', () => ({ }), })) +const workspaceScope = { + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + userId: 'provider-user', +} as const + +beforeEach(() => journalStorage.clear()) + const UPDATE_BATCH_TEST_WINDOW_MS = 100 /** A minimal fake Socket.IO client whose server→client events can be fired in tests. */ @@ -77,7 +85,7 @@ function createProvider(connected = true) { const { socket, emit, fire, timeout } = createSocket(connected) const doc = new Y.Doc() const awareness = new awarenessProtocol.Awareness(doc) - const provider = new FileDocProvider(socket, 'file-1', doc, awareness) + const provider = new FileDocProvider(socket, 'file-1', doc, awareness, workspaceScope) return { provider, doc, awareness, emit, fire, timeout } } @@ -93,6 +101,7 @@ function acceptJoin( clientId, docId, acknowledgedUpdates: acknowledgedUpdates ? true : undefined, + canWrite: true, }) } @@ -113,6 +122,144 @@ function syncStep1Frame(doc: Y.Doc): Uint8Array { } describe('FileDocProvider', () => { + it('never replays rejected document state after write regrant', async () => { + vi.useFakeTimers() + const old = createProvider() + const server = new Y.Doc() + server.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') + server.getText('body').insert(0, 'accepted') + Y.applyUpdate(old.doc, Y.encodeStateAsUpdate(server), old.provider) + const reset = vi.fn() + old.provider.on('write-access-lost', reset) + try { + acceptJoin(old.fire, old.doc.clientID, 'doc-1') + await vi.advanceTimersByTimeAsync(0) + old.doc.getText('body').insert(8, '-rejected') + await vi.advanceTimersByTimeAsync(UPDATE_BATCH_TEST_WINDOW_MS) + old.fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', canWrite: false }) + old.fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', canWrite: true }) + expect(old.provider.canWrite).toBe(false) + old.emit.mockClear() + old.fire(FILE_DOC_EVENTS.MESSAGE, syncStep1Frame(server)) + old.doc.getText('body').insert(0, 'also-rejected-') + await vi.advanceTimersByTimeAsync(UPDATE_BATCH_TEST_WINDOW_MS) + expect(reset).toHaveBeenCalledTimes(1) + expect(emittedMessages(old.emit)).toHaveLength(0) + expect( + old.emit.mock.calls.filter(([event]) => event === FILE_DOC_EVENTS.UPDATE) + ).toHaveLength(0) + old.provider.destroy() + const journal = new PendingFileDocUpdateJournal({ ...workspaceScope, fileId: 'file-1' }) + expect(await journal.load('doc-1')).toBeNull() + expect(server.getText('body').toString()).toBe('accepted') + const fresh = createProvider() + try { + fresh.fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + schemaVersion: FILE_DOC_SCHEMA_VERSION, + fileId: 'file-1', + clientId: fresh.doc.clientID, + docId: 'doc-1', + acknowledgedUpdates: true, + canWrite: false, + }) + await vi.advanceTimersByTimeAsync(0) + const frame = encoding.createEncoder() + encoding.writeVarUint(frame, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(frame, server) + fresh.fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(frame)) + expect(fresh.doc.getText('body').toString()).toBe('accepted') + expect(fresh.provider.canWrite).toBe(false) + server.getText('body').insert(8, '-peer') + const update = encoding.createEncoder() + encoding.writeVarUint(update, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeUpdate(update, Y.encodeStateAsUpdate(server)) + fresh.fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(update)) + expect(fresh.doc.getText('body').toString()).toBe('accepted-peer') + fresh.fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', canWrite: true }) + expect(fresh.provider.canWrite).toBe(true) + expect(fresh.doc.getText('body').toString()).not.toContain('rejected') + } finally { + fresh.provider.destroy() + fresh.awareness.destroy() + fresh.doc.destroy() + } + } finally { + old.provider.destroy() + old.awareness.destroy() + old.doc.destroy() + server.destroy() + vi.useRealTimers() + } + }) + + it('sends the workspace owner while accepting deployed ownerless join replies', async () => { + vi.useFakeTimers() + const { socket, emit, fire } = createSocket(true) + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + const owner = { entityType: 'workspace', entityId: 'workspace-1' } as const + const provider = new FileDocProvider(socket, 'file-1', doc, awareness, { + owner, + userId: 'user-1', + }) + try { + const join = emit.mock.calls.find(([event]) => event === FILE_DOC_EVENTS.JOIN)?.[1] + expect(join).toMatchObject({ fileId: 'file-1', owner }) + acceptJoin(fire, doc.clientID, 'doc-1') + await vi.advanceTimersByTimeAsync(0) + expect(provider.joinError).toBeNull() + expect(emittedMessages(emit).length).toBeGreaterThan(0) + } finally { + provider.destroy() + awareness.destroy() + doc.destroy() + vi.useRealTimers() + } + }) + + it.each(['legacy', 'owner'] as const)( + 'accepts %s Project responses only for its owner', + async (wire) => { + vi.useFakeTimers() + const { socket, fire } = createSocket(true) + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + const owner = { entityType: 'project', entityId: 'project-1' } as const + const provider = new FileDocProvider(socket, 'file-1', doc, awareness, { + owner, + userId: 'user-1', + }) + try { + expect(provider.canWrite).toBe(false) + const scope = wire === 'legacy' ? { projectId: 'project-1' } : { owner } + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + fileId: 'file-1', + ...scope, + clientId: doc.clientID, + docId: 'doc-1', + canWrite: false, + acknowledgedUpdates: true, + }) + await vi.advanceTimersByTimeAsync(0) + fire(FILE_DOC_EVENTS.PERMISSION, { + fileId: 'file-1', + owner: { ...owner, entityId: 'other' }, + canWrite: true, + }) + expect(provider.canWrite).toBe(false) + fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', ...scope, canWrite: true }) + expect(provider.canWrite).toBe(true) + fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', ...scope, canWrite: false }) + expect(provider.canWrite).toBe(false) + } finally { + provider.destroy() + awareness.destroy() + doc.destroy() + vi.useRealTimers() + } + } + ) + it.each([undefined, 1, FILE_DOC_SCHEMA_VERSION + 1])( 'rejects incompatible server schema %s before exchanging document state', (schemaVersion) => { @@ -168,13 +315,15 @@ describe('FileDocProvider', () => { socket, 'file-1', firstDoc, - new awarenessProtocol.Awareness(firstDoc) + new awarenessProtocol.Awareness(firstDoc), + workspaceScope ) const second = new FileDocProvider( socket, 'file-1', secondDoc, - new awarenessProtocol.Awareness(secondDoc) + new awarenessProtocol.Awareness(secondDoc), + workspaceScope ) const serverDoc = new Y.Doc() const encoder = encoding.createEncoder() @@ -202,7 +351,7 @@ describe('FileDocProvider', () => { * the sync must not happen at all; the fatal path leaves the editor read-only on what it already * shows, and a reload binds a fresh document. */ - it('refuses to sync into a document it does not recognize', () => { + it('refuses to sync into a document it does not recognize', async () => { const { provider, doc, emit, fire } = createProvider(true) doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-original') const joinError = vi.fn() @@ -210,6 +359,7 @@ describe('FileDocProvider', () => { emit.mockClear() acceptJoin(fire, doc.clientID, 'doc-rebuilt') + await flushMicrotasks(20) expect(emittedMessages(emit)).toHaveLength(0) expect(provider.synced).toBe(false) @@ -217,12 +367,13 @@ describe('FileDocProvider', () => { expect(joinError).toHaveBeenCalledTimes(1) }) - it('fails closed when a seeded legacy tab has no identity but the server does', () => { + it('fails closed when a seeded legacy tab has no identity but the server does', async () => { const { provider, doc, emit, fire } = createProvider(true) doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) emit.mockClear() acceptJoin(fire, doc.clientID, 'doc-current') + await flushMicrotasks(20) expect(emittedMessages(emit)).toHaveLength(0) expect(provider.joinError).toMatchObject({ code: 'DOCUMENT_REPLACED', retryable: false }) @@ -237,7 +388,8 @@ describe('FileDocProvider', () => { socket, 'file-1', doc, - new awarenessProtocol.Awareness(doc) + new awarenessProtocol.Awareness(doc), + workspaceScope ) acceptJoin(fire, doc.clientID, 'doc-1') emit.mockClear() @@ -313,7 +465,10 @@ describe('FileDocProvider', () => { 'recovers an unacknowledged %s edit after restart and clears it only after acceptance', async (mode) => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const serverDoc = new Y.Doc() const serverConfig = serverDoc.getMap(FILE_DOC_SEED.configMap) serverConfig.set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -427,7 +582,7 @@ describe('FileDocProvider', () => { 'file-1', doc, new awarenessProtocol.Awareness(doc), - { workspaceId: 'workspace-1', userId: 'user-1' } + { owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, userId: 'user-1' } ) acceptJoin(fire, doc.clientID, 'doc-1') await vi.advanceTimersByTimeAsync(0) @@ -518,6 +673,7 @@ describe('FileDocProvider', () => { emit.mockClear() acceptJoin(fire, doc.clientID, 'doc-1') + await flushMicrotasks(20) expect(emit.mock.calls.some(([event]) => event === FILE_DOC_EVENTS.UPDATE)).toBe(true) provider.destroy() } finally { @@ -552,7 +708,10 @@ describe('FileDocProvider', () => { journalStorage.clear() const browserWindow = new EventTarget() vi.stubGlobal('window', browserWindow) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const clear = vi.spyOn(PendingFileDocUpdateJournal.prototype, 'clear') const { socket, emit, fire } = createSocket(true) @@ -628,7 +787,10 @@ describe('FileDocProvider', () => { journalStorage.clear() const browserWindow = new EventTarget() vi.stubGlobal('window', browserWindow) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const recoveredDoc = new Y.Doc() recoveredDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -659,7 +821,10 @@ describe('FileDocProvider', () => { it('journals an edit made while disconnected before page teardown', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, emit, fire } = createSocket(true) const doc = new Y.Doc() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -686,7 +851,10 @@ describe('FileDocProvider', () => { it('preserves pending recovery through page teardown and destroy', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, fire } = createSocket(true) const doc = new Y.Doc() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -723,7 +891,10 @@ describe('FileDocProvider', () => { journalStorage.clear() const browserWindow = new EventTarget() vi.stubGlobal('window', browserWindow) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const { socket, emit } = createSocket(false) const doc = new Y.Doc() @@ -772,7 +943,10 @@ describe('FileDocProvider', () => { it('reopens the current generation without installing an incompatible recovery draft', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const oldDoc = new Y.Doc() oldDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'old-doc') oldDoc.getText('default').insert(0, 'complete draft') @@ -812,7 +986,10 @@ describe('FileDocProvider', () => { 'does not install disk recovery without a negotiated identity (local identity: %s)', async (hasLocalIdentity) => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const draft = new Y.Doc() draft.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'old-doc') draft.getText('default').insert(0, 'retained draft') @@ -837,15 +1014,16 @@ describe('FileDocProvider', () => { } ) - it('admits the final online batch before leaving while its relay publication is still pending', () => { + it('admits the final online batch before leaving while its relay publication is still pending', async () => { const { socket, emit, fire } = createSocket(true) const serverDoc = new Y.Doc() serverDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') const doc = new Y.Doc() Y.applyUpdate(doc, Y.encodeStateAsUpdate(serverDoc)) const awareness = new awarenessProtocol.Awareness(doc) - const provider = new FileDocProvider(socket, 'file-1', doc, awareness) + const provider = new FileDocProvider(socket, 'file-1', doc, awareness, workspaceScope) acceptJoin(fire, doc.clientID, 'doc-1') + await flushMicrotasks(20) let joined = true const publications: Array<() => void> = [] emit.mockImplementation((event, payload, acknowledge) => { @@ -882,7 +1060,10 @@ describe('FileDocProvider', () => { journalStorage.set(String(key), updater(journalStorage.get(String(key)))) }) const clear = vi.spyOn(PendingFileDocUpdateJournal.prototype, 'clear') - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, emit, fire } = createSocket(true) const serverDoc = new Y.Doc() serverDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -947,7 +1128,10 @@ describe('FileDocProvider', () => { async (outcome) => { vi.useFakeTimers() journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const { socket, emit, fire } = createSocket(true) const serverDoc = new Y.Doc() @@ -1006,7 +1190,10 @@ describe('FileDocProvider', () => { async (state) => { vi.useFakeTimers() journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, emit, fire } = createSocket(true) const doc = new Y.Doc() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -1042,7 +1229,10 @@ describe('FileDocProvider', () => { it('delivers a rejoined legacy batch before leaving without treating it as acknowledged', async () => { vi.useFakeTimers() journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, emit, fire } = createSocket(true) const serverDoc = new Y.Doc() serverDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -1084,7 +1274,10 @@ describe('FileDocProvider', () => { it('never falls back to a different document identity when loading local recovery', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const oldDoc = new Y.Doc() oldDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-old') oldDoc.getText('default').insert(0, 'old draft') @@ -1121,7 +1314,10 @@ describe('FileDocProvider', () => { it('recovers the negotiated generation even when an incompatible draft is newer', async () => { vi.useFakeTimers() journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const currentDraft = new Y.Doc() currentDraft.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'current-doc') @@ -1161,7 +1357,10 @@ describe('FileDocProvider', () => { it('rejects an in-memory generation mismatch before applying a matching server draft', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const serverDraft = new Y.Doc() serverDraft.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'server-doc') @@ -1186,7 +1385,10 @@ describe('FileDocProvider', () => { it('syncs after malformed recovery without replaying it on subsequent mounts', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const oldDoc = new Y.Doc() oldDoc.getText('default').insert(0, 'quarantined snapshot') await new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }).save( @@ -1236,7 +1438,7 @@ describe('FileDocProvider', () => { const doc = new Y.Doc() const awareness = new awarenessProtocol.Awareness(doc) const provider = new FileDocProvider(socket, 'file-1', doc, awareness, { - workspaceId: 'workspace-1', + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, userId: 'user-1', }) try { @@ -1266,7 +1468,10 @@ describe('FileDocProvider', () => { const load = vi .spyOn(PendingFileDocUpdateJournal.prototype, 'load') .mockReturnValue(new Promise(() => {})) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, fire } = createSocket(true) const doc = new Y.Doc() const provider = new FileDocProvider( @@ -1292,7 +1497,10 @@ describe('FileDocProvider', () => { const load = vi .spyOn(PendingFileDocUpdateJournal.prototype, 'load') .mockReturnValue(new Promise(() => {})) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, fire } = createSocket(true) const doc = new Y.Doc() const provider = new FileDocProvider( @@ -1319,7 +1527,8 @@ describe('FileDocProvider', () => { socket, 'file-1', firstDoc, - new awarenessProtocol.Awareness(firstDoc) + new awarenessProtocol.Awareness(firstDoc), + workspaceScope ) acceptJoin(fire, firstDoc.clientID) @@ -1328,7 +1537,8 @@ describe('FileDocProvider', () => { socket, 'file-2', secondDoc, - new awarenessProtocol.Awareness(secondDoc) + new awarenessProtocol.Awareness(secondDoc), + workspaceScope ) expect(firstProvider.joinError).toMatchObject({ code: 'DOCUMENT_REPLACED' }) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { @@ -1374,7 +1584,7 @@ describe('FileDocProvider', () => { 'file-1', doc, new awarenessProtocol.Awareness(doc), - { workspaceId: 'workspace-1', userId: 'user-1' } + { owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, userId: 'user-1' } ) acceptJoin(fire, doc.clientID, 'doc-1', mode === 'acknowledged') await vi.advanceTimersByTimeAsync(0) @@ -1417,7 +1627,7 @@ describe('FileDocProvider', () => { doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') const awareness = new awarenessProtocol.Awareness(doc) const provider = new FileDocProvider(socket, 'file-1', doc, awareness, { - workspaceId: 'workspace-1', + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, userId: 'user-1', }) acceptJoin(fire, doc.clientID, 'doc-1') @@ -1455,6 +1665,7 @@ describe('FileDocProvider', () => { try { const { provider, doc, emit, fire } = createProvider(true) acceptJoin(fire, doc.clientID, 'doc-1') + await flushMicrotasks(20) const serverDoc = new Y.Doc() const config = serverDoc.getMap(FILE_DOC_SEED.configMap) config.set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -1636,13 +1847,15 @@ describe('FileDocProvider', () => { socket, 'shared-file', docA, - new awarenessProtocol.Awareness(docA) + new awarenessProtocol.Awareness(docA), + workspaceScope ) const second = new FileDocProvider( socket, 'shared-file', docB, - new awarenessProtocol.Awareness(docB) + new awarenessProtocol.Awareness(docB), + workspaceScope ) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { schemaVersion: FILE_DOC_SCHEMA_VERSION, @@ -1663,7 +1876,10 @@ describe('FileDocProvider', () => { expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, expect.anything()) second.destroy() - expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'shared-file' }) + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { + fileId: 'shared-file', + owner: workspaceScope.owner, + }) }) it('keeps an unseeded document retryable after the readiness deadline and accepts late server content', () => { @@ -1800,3 +2016,42 @@ describe('FileDocProvider', () => { provider.destroy() }) }) + +describe('document permission protocol overlap', () => { + it.each([ + { owner: 'workspace', schemaVersion: 2, canWrite: undefined, expected: true }, + { owner: 'workspace', schemaVersion: 2, canWrite: false, expected: false }, + { owner: 'workspace', schemaVersion: undefined, canWrite: undefined, expected: false }, + { owner: 'project', schemaVersion: 2, canWrite: undefined, expected: false }, + { owner: 'project', schemaVersion: 2, canWrite: true, expected: true }, + ] as const)( + 'honors $owner schema=$schemaVersion capability=$canWrite', + ({ owner, schemaVersion, canWrite, expected }) => { + const { socket, fire } = createSocket() + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + const provider = new FileDocProvider(socket, 'file-1', doc, awareness, { + owner: { entityType: owner, entityId: 'scope-1' }, + userId: 'user-1', + }) + expect(provider.canWrite).toBe(false) + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + fileId: 'file-1', + owner: { entityType: owner, entityId: 'scope-1' }, + clientId: doc.clientID, + schemaVersion, + canWrite, + }) + expect(provider.canWrite).toBe(expected) + fire(FILE_DOC_EVENTS.PERMISSION, { + fileId: 'file-1', + owner: { entityType: owner, entityId: 'scope-1' }, + canWrite: false, + }) + expect(provider.canWrite).toBe(false) + provider.destroy() + awareness.destroy() + doc.destroy() + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index 17c3e07ad13..5d80c1812c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -10,13 +10,20 @@ import { FILE_DOC_SEED, FILE_DOC_TIMEOUTS, type FileDocInvalidated, + type FileDocPermission, type FileDocUpdateAck, type FileDocUpdatePayload, type JoinFileDocError, type JoinFileDocSuccess, toFileDocBytes, } from '@sim/realtime-protocol/file-doc' -import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { + type FileDocOwner, + fileDocOwnerWireFields, + fileDocRoom, + parseFileDocTarget, +} from '@sim/realtime-protocol/file-doc-target' +import { roomName } from '@sim/realtime-protocol/rooms' import { generateShortId } from '@sim/utils/id' import { backoffWithJitter } from '@sim/utils/retry' import * as decoding from 'lib0/decoding' @@ -36,6 +43,8 @@ import { PendingFileDocUpdateJournal } from '@/app/workspace/[workspaceId]/files */ interface FileDocProviderEvents { synced: (synced: boolean) => void + permission: (canWrite: boolean) => void + 'write-access-lost': () => void 'join-error': (error: JoinFileDocError) => void } @@ -60,8 +69,8 @@ function hasYjsUpdateContent(update: Uint8Array): boolean { } interface FileDocProviderScope { - workspaceId: string userId: string + owner: FileDocOwner } interface PendingClientUpdate { @@ -129,6 +138,11 @@ export class FileDocProvider extends ObservableV2 { >() synced = false + canWrite = false + private permissionKnown = false + private requiresReinitialization = false + private readonly owner: FileDocOwner + private readonly membershipKey: string /** * The current readiness failure, or `null`. Retryable timeouts clear once authoritative sync * completes; terminal rejections remain latched. Consumers read it when subscribing so an earlier @@ -177,11 +191,13 @@ export class FileDocProvider extends ObservableV2 { private readonly fileId: string, readonly doc: Y.Doc, readonly awareness: awarenessProtocol.Awareness, - scope?: FileDocProviderScope + scope: FileDocProviderScope ) { super() - this.journal = scope ? new PendingFileDocUpdateJournal({ ...scope, fileId: this.fileId }) : null + this.owner = scope.owner + this.membershipKey = roomName(fileDocRoom({ fileId, owner: this.owner })) + this.journal = new PendingFileDocUpdateJournal({ ...scope, fileId: this.fileId }) this.registerActiveProvider() // Restore an empty local awareness state if it has been cleared. A fresh @@ -198,6 +214,7 @@ export class FileDocProvider extends ObservableV2 { socket.on(FILE_DOC_EVENTS.MESSAGE, this.handleMessage) socket.on(FILE_DOC_EVENTS.JOIN_SUCCESS, this.handleJoinSuccess) + socket.on(FILE_DOC_EVENTS.PERMISSION, this.handlePermission) socket.on(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) socket.on(FILE_DOC_EVENTS.INVALIDATED, this.handleInvalidated) socket.on(ROOM_ACCESS_REVOKED_EVENT, this.handleAccessRevoked) @@ -211,7 +228,7 @@ export class FileDocProvider extends ObservableV2 { // Count this provider against the shared socket's membership of the file's room, so the room is // left only when the last provider for this file tears down (see {@link releaseRoomMembership}). - retainRoomMembership(socket, fileId) + retainRoomMembership(socket, this.membershipKey) if (socket.connected) this.join() @@ -244,6 +261,7 @@ export class FileDocProvider extends ObservableV2 { if (this.disposed || this.fatal || (this.synced && this.isSeeded())) return this.joinError = { fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), error: 'Realtime document was not ready in time', code: 'READINESS_TIMEOUT', retryable: true, @@ -309,6 +327,7 @@ export class FileDocProvider extends ObservableV2 { }, FILE_DOC_TIMEOUTS.joinAckMs) this.socket.emit(FILE_DOC_EVENTS.JOIN, { fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), clientId: this.doc.clientID, schemaVersion: FILE_DOC_SCHEMA_VERSION, }) @@ -386,10 +405,17 @@ export class FileDocProvider extends ObservableV2 { private handleJoinSuccess = (data: JoinFileDocSuccess) => { if ( data.fileId !== this.fileId || + !this.matchesTarget(data) || !this.joinPending || (data.clientId !== undefined && data.clientId !== this.doc.clientID) ) return + /** Schema-v2 Workspace relays without capabilities only acknowledge write-authorized joins. */ + const legacyWriterAdmission = + this.owner.entityType === 'workspace' && + data.canWrite === undefined && + data.schemaVersion === 2 + this.setCanWrite(data.canWrite === true || legacyWriterAdmission) this.clearJoinAckTimer() this.joinPending = false this.joinRetryAttempt = 0 @@ -405,6 +431,12 @@ export class FileDocProvider extends ObservableV2 { return } const recoveryDocId = data.docId + if (!this.canWrite) { + void this.journal.discard(recoveryDocId).then(() => { + this.finishAcceptJoin(data, generation, null) + }) + return + } if (!this.recoveryLoad || this.recoveryLoad.docId !== recoveryDocId) { this.recoveryLoad = { docId: recoveryDocId, @@ -412,7 +444,7 @@ export class FileDocProvider extends ObservableV2 { } } void this.recoveryLoad.promise.then((recovered) => { - this.finishAcceptJoin(data, generation, recovered) + this.finishAcceptJoin(data, generation, this.canWrite ? recovered : null) }) } @@ -424,6 +456,7 @@ export class FileDocProvider extends ObservableV2 { if ( this.disposed || this.fatal || + this.requiresReinitialization || !this.socket.connected || generation !== this.connectionGeneration || !this.joinHydrating @@ -516,6 +549,7 @@ export class FileDocProvider extends ObservableV2 { if (this.fatal || this.disposed) return const error: JoinFileDocError = { fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), error: message, code, retryable: false, @@ -538,7 +572,7 @@ export class FileDocProvider extends ObservableV2 { private registerActiveProvider(): void { const active = FileDocProvider.activeProviders.get(this.socket) - if (active?.fileId === this.fileId) { + if (active?.fileId === this.membershipKey) { active.providers.add(this) return } @@ -552,14 +586,14 @@ export class FileDocProvider extends ObservableV2 { } } FileDocProvider.activeProviders.set(this.socket, { - fileId: this.fileId, + fileId: this.membershipKey, providers: new Set([this]), }) } private unregisterActiveProvider(): void { const active = FileDocProvider.activeProviders.get(this.socket) - if (active?.fileId !== this.fileId) return + if (active?.fileId !== this.membershipKey) return active.providers.delete(this) if (active.providers.size === 0) FileDocProvider.activeProviders.delete(this.socket) } @@ -605,7 +639,8 @@ export class FileDocProvider extends ObservableV2 { * instead of silently accepting keystrokes that go nowhere. */ private handleAccessRevoked = (data: RoomAccessRevokedBroadcast) => { - if (data.room?.type !== ROOM_TYPES.WORKSPACE_FILE_DOC || data.room.id !== this.fileId) return + const room = fileDocRoom({ fileId: this.fileId, owner: this.owner }) + if (data.room?.type !== room.type || data.room.id !== room.id) return this.failFatally(data.message, 'ACCESS_REVOKED') } @@ -662,7 +697,7 @@ export class FileDocProvider extends ObservableV2 { // `this` is the transaction origin, so our own `doc.on('update')` skips // re-sending updates we just applied from the server. const syncType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this) - if (encoding.length(encoder) > 1) { + if (encoding.length(encoder) > 1 && !this.requiresReinitialization && this.canWrite) { const response = encoding.toUint8Array(encoder) if (this.updateMode === 'acknowledged' && syncType === syncProtocol.messageYjsSyncStep1) { const responseDecoder = decoding.createDecoder(response) @@ -693,7 +728,14 @@ export class FileDocProvider extends ObservableV2 { private handleDocUpdate = (update: Uint8Array, origin: unknown) => { /** A terminal document cannot publish; inbound and recovery updates must not echo. */ - if (this.fatal || origin === this || origin === RECOVERY_ORIGIN) return + if ( + this.fatal || + this.requiresReinitialization || + (this.permissionKnown && !this.canWrite) || + origin === this || + origin === RECOVERY_ORIGIN + ) + return // Agent-streamed frames must reach peers (so a collaborator sees the stream live) but must NOT be // treated by the server as a durable user edit — the copilot's final `edit_content` write is the // authoritative persist. Tag them so the relay applies + fans out but skips persist bookkeeping. @@ -725,6 +767,7 @@ export class FileDocProvider extends ObservableV2 { } private queuePendingUpdate(update: Uint8Array): void { + if (this.requiresReinitialization) return this.pendingUpdateBatch.push(update) this.updateBeforeUnloadProtection() } @@ -740,6 +783,7 @@ export class FileDocProvider extends ObservableV2 { private async flushPendingUpdates(): Promise { if ( + this.requiresReinitialization || this.updateMode === 'negotiating' || this.pendingUpdateBatch.length === 0 || this.disposed || @@ -793,19 +837,22 @@ export class FileDocProvider extends ObservableV2 { const pending = this.inFlightUpdate const docId = this.docId() if ( + this.requiresReinitialization || !pending || !docId || this.updateMode !== 'acknowledged' || this.disposed || this.fatal || !this.socket.connected || - !this.joinAccepted + !this.joinAccepted || + !this.canWrite ) return const generation = this.connectionGeneration const payload: FileDocUpdatePayload = { fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), docId, updateId: pending.updateId, update: pending.update, @@ -836,11 +883,13 @@ export class FileDocProvider extends ObservableV2 { return } + if (ack.code === 'ACCESS_REVOKED') { + this.setCanWrite(false) + return + } if (!ack.retryable) { const message = - ack.code === 'ACCESS_REVOKED' - ? 'Your access to this document has been revoked' - : 'This document changed while this tab was disconnected; refresh to continue editing' + 'This document changed while this tab was disconnected; refresh to continue editing' this.failFatally(message, ack.code) return } @@ -881,6 +930,7 @@ export class FileDocProvider extends ObservableV2 { } private persistPendingSnapshot(): Promise | undefined { + if (this.requiresReinitialization) return const update = this.pendingJournalUpdate() const docId = this.docId() if (!update || !docId || !this.journal) return @@ -919,7 +969,14 @@ export class FileDocProvider extends ObservableV2 { this.clearUpdateTimers() if (this.updateMode === 'acknowledged' && docId) { - const payload: FileDocUpdatePayload = { fileId: this.fileId, docId, updateId, update } + if (!this.canWrite) return + const payload: FileDocUpdatePayload = { + fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), + docId, + updateId, + update, + } this.socket .timeout(FILE_DOC_TIMEOUTS.updateAckMs) .emit(FILE_DOC_EVENTS.UPDATE, payload, (error: Error | null, ack?: FileDocUpdateAck) => { @@ -1025,6 +1082,48 @@ export class FileDocProvider extends ObservableV2 { this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) } + private matchesTarget(data: { + fileId: string + owner?: FileDocOwner + projectId?: string + }): boolean { + const target = parseFileDocTarget(data) + if (!target || target.fileId !== this.fileId) return false + if (!target.owner) return this.owner.entityType === 'workspace' + return ( + target.owner.entityType === this.owner.entityType && + target.owner.entityId === this.owner.entityId + ) + } + + private handlePermission = (data: FileDocPermission) => { + if (!this.matchesTarget(data) || data.fileId !== this.fileId) return + this.setCanWrite(data.canWrite) + if (data.canWrite) this.sendInFlightUpdate() + } + + private setCanWrite(canWrite: boolean) { + if (this.requiresReinitialization) return + this.permissionKnown = true + if (this.canWrite === canWrite) return + this.canWrite = canWrite + if (!canWrite) { + this.requiresReinitialization = true + this.clearUpdateTimers() + this.pendingUpdatesDrained = true + this.pendingUpdateBatch = [] + this.inFlightUpdate = null + this.flushingUpdate = null + this.updateBeforeUnloadProtection() + const docId = this.docId() + const discarded = docId ? this.journal?.discard(docId) : undefined + void Promise.resolve(discarded).then(() => { + if (!this.disposed) this.emit('write-access-lost', []) + }) + } + this.emit('permission', [canWrite]) + } + private setSynced(synced: boolean) { if (this.synced === synced) return this.synced = synced @@ -1071,11 +1170,15 @@ export class FileDocProvider extends ObservableV2 { // Only actually leave the room when this was the last provider for the file on the shared socket — // otherwise a sibling surface (e.g. the Files editor vs. the embedded chat panel) would be stranded. - if (releaseRoomMembership(this.socket, this.fileId)) { - this.socket.emit(FILE_DOC_EVENTS.LEAVE, { fileId: this.fileId }) + if (releaseRoomMembership(this.socket, this.membershipKey)) { + this.socket.emit(FILE_DOC_EVENTS.LEAVE, { + fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), + }) } this.socket.off(FILE_DOC_EVENTS.MESSAGE, this.handleMessage) this.socket.off(FILE_DOC_EVENTS.JOIN_SUCCESS, this.handleJoinSuccess) + this.socket.off(FILE_DOC_EVENTS.PERMISSION, this.handlePermission) this.socket.off(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) this.socket.off(FILE_DOC_EVENTS.INVALIDATED, this.handleInvalidated) this.socket.off(ROOM_ACCESS_REVOKED_EVENT, this.handleAccessRevoked) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.test.ts index ae430d15d8b..a62d140ad10 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.test.ts @@ -19,7 +19,7 @@ import { function journal(): PendingFileDocUpdateJournal { return new PendingFileDocUpdateJournal({ - workspaceId: 'workspace-1', + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, fileId: 'file-1', userId: 'user-1', }) @@ -325,7 +325,7 @@ describe('PendingFileDocUpdateJournal', () => { it('isolates records by user, workspace, and file', async () => { const first = journal() const otherUser = new PendingFileDocUpdateJournal({ - workspaceId: 'workspace-1', + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, fileId: 'file-1', userId: 'user-2', }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.ts index e4e91a2dd45..7954617effe 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { FILE_DOC_LIMITS } from '@sim/realtime-protocol/file-doc' +import type { FileDocOwner } from '@sim/realtime-protocol/file-doc-target' import { get, update as updateValue } from 'idb-keyval' import * as Y from 'yjs' @@ -28,9 +29,9 @@ interface JournalDocument extends PendingDocumentRecovery { } interface PendingUpdateJournalScope { - workspaceId: string fileId: string userId: string + owner: FileDocOwner } interface JournalSaveResult { @@ -105,7 +106,7 @@ export class PendingFileDocUpdateJournal { private readonly key: string private mutationQueue = Promise.resolve() - constructor({ workspaceId, fileId, userId }: PendingUpdateJournalScope) { + constructor({ owner, fileId, userId }: PendingUpdateJournalScope) { const origin = typeof location === 'undefined' ? 'server' : location.origin this.key = [ 'sim', @@ -113,7 +114,7 @@ export class PendingFileDocUpdateJournal { JOURNAL_VERSION, origin, userId, - workspaceId, + ...(owner.entityType === 'workspace' ? [owner.entityId] : [owner.entityType, owner.entityId]), fileId, ].join(':') } @@ -215,6 +216,17 @@ export class PendingFileDocUpdateJournal { ) } + /** Drop only this document's unaccepted recovery after confirmed loss of write access. */ + discard(docId: string): Promise { + return this.enqueue( + () => + updateValue(this.key, (value) => + record(liveDocuments(value, Date.now()).filter((document) => document.docId !== docId)) + ), + undefined + ) + } + clear(docId: string, acknowledgedUpdate: Uint8Array): Promise { return this.enqueue( () => diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts index 2bad83b13da..954e9efc68a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts @@ -2,12 +2,14 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { FILE_DOC_EVENTS, type FileDocPresence } from '@sim/realtime-protocol/file-doc' +import { type QueryFilters, useQueryClient } from '@tanstack/react-query' import { Awareness } from 'y-protocols/awareness' import * as Y from 'yjs' import { getUserColor } from '@/lib/workspaces/colors' import { FileDocProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider' import { useReportFileDocOthers } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' import { useSocket } from '@/app/workspace/providers/socket-provider' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' /** The live collaboration binding the editor wires into TipTap's Collaboration * (the {@link Y.Doc}) and CollaborationCaret (the awareness). */ @@ -22,6 +24,7 @@ export interface FileDocCollaboration { * provider is consumed for its readiness signal (`synced`) and fatal `join-error`. */ provider: FileDocProvider | null + canWrite: boolean /** * The local caret identity published to awareness: `name`/`color` for CollaborationCaret, * and `clientId` so the caret activity extension can tag each caret node (see @@ -38,10 +41,11 @@ interface UseFileDocCollaborationParams { userName: string /** * Whether to establish collaboration. Decided once at editor mount — only for a - * live, editable, non-streaming workspace document. When `false` the hook + * live, readable, non-streaming workspace document. When `false` the hook * returns `null` and the editor stays fully local. */ enabled: boolean + onWriteAccessLost?: () => void } /** @@ -57,13 +61,15 @@ export function useFileDocCollaboration({ userId, userName, enabled, + onWriteAccessLost, }: UseFileDocCollaborationParams): FileDocCollaboration | null { const { socket } = useSocket() + const queryClient = useQueryClient() + const onWriteAccessLostRef = useRef(onWriteAccessLost) + onWriteAccessLostRef.current = onWriteAccessLost // The Y.Doc + Awareness are the editor's authoritative binding — created once // and stable for the hook's life (see sim-react-performance: lazy-init ref). - // Only allocated when collaboration is enabled, so read-only / streaming / - // round-trip-unsafe views never build a Yjs document they won't use. const docRef = useRef(null) const awarenessRef = useRef(null) // Created ONCE and kept stable for the whole editor lifetime. The editor freezes these into @@ -97,6 +103,7 @@ export function useFileDocCollaboration({ }, []) const [provider, setProvider] = useState(null) + const [canWrite, setCanWrite] = useState(false) useEffect(() => { if (!enabled || !socket) return @@ -105,15 +112,30 @@ export function useFileDocCollaboration({ const doc = docRef.current as Y.Doc const awareness = awarenessRef.current as Awareness const fileProvider = new FileDocProvider(socket, fileId, doc, awareness, { - workspaceId, + owner: { entityType: 'workspace', entityId: workspaceId }, userId, }) + setCanWrite(fileProvider.canWrite) + const reloadAfterWriteLoss = async () => { + onWriteAccessLostRef.current?.() + const filters: QueryFilters[] = [ + { queryKey: workspaceFilesKeys.contentFile(workspaceId, fileId) }, + { queryKey: workspaceFilesKeys.record(workspaceId, fileId) }, + { queryKey: workspaceFilesKeys.workspaceLists(workspaceId) }, + ] + await Promise.all(filters.map((filter) => queryClient.cancelQueries(filter))) + await Promise.all(filters.map((filter) => queryClient.resetQueries(filter))) + } + fileProvider.on('permission', setCanWrite) + fileProvider.on('write-access-lost', reloadAfterWriteLoss) setProvider(fileProvider) return () => { + fileProvider.off('permission', setCanWrite) + fileProvider.off('write-access-lost', reloadAfterWriteLoss) fileProvider.destroy() setProvider(null) } - }, [enabled, socket, fileId, workspaceId, userId]) + }, [enabled, socket, fileId, workspaceId, userId, queryClient]) const reportOthers = useReportFileDocOthers() const reportOthersRef = useRef(reportOthers) @@ -172,9 +194,10 @@ export function useFileDocCollaboration({ doc: docRef.current as Y.Doc, awareness: awarenessRef.current as Awareness, provider, + canWrite, user, } : null, - [enabled, provider, user] + [enabled, provider, canWrite, user] ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx index 91f7f9abc4e..1a12ef886dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx @@ -3,10 +3,11 @@ import { act, Suspense, startTransition } from 'react' import { toast } from '@sim/emcn' import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' -import { authClientMock } from '@sim/testing/mocks/auth-client.mock' +import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock' import { nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock' import { PASTE_LIMITS } from '@sim/utils/paste' import { type Editor, Extension } from '@tiptap/core' +import Collaboration from '@tiptap/extension-collaboration' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Awareness } from 'y-protocols/awareness' @@ -14,19 +15,25 @@ import * as Y from 'yjs' import { exportWorkspaceFileSnapshotBodySchema } from '@/lib/api/contracts/workspace-files' import type { FileDownloadSource } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { useFileDocCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration' import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' import { ImageUploadPlaceholders } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload' import { createRichMarkdownPasteAdmission, type RichMarkdownPasteAdmissionOptions, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission' -import { LoadedRichMarkdownEditor } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor' +import { + LoadedRichMarkdownEditor, + RichMarkdownEditor, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor' nextNavigationMockFns.mockUsePathname.mockReturnValue('/workspace/workspace-1/files') -const { collaborationRef, uploadFile } = vi.hoisted(() => ({ +const { collaborationRef, realCollaboration, uploadFile, editableFileContent } = vi.hoisted(() => ({ collaborationRef: { current: null as unknown }, + realCollaboration: { enabled: false }, uploadFile: vi.fn(), + editableFileContent: vi.fn(), })) vi.mock( @@ -44,7 +51,7 @@ vi.mock('@/hooks/use-file-content-source', () => ({ vi.mock('@/app/workspace/[workspaceId]/components', () => ({ FindBar: () => null })) vi.mock( '@/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content', - () => ({ useEditableFileContent: vi.fn() }) + () => ({ useEditableFileContent: editableFileContent }) ) vi.mock('@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor', () => ({ TextEditor: () => null, @@ -54,8 +61,15 @@ vi.mock( () => ({ createMarkdownEditorExtensions: (options: { pasteAdmission?: RichMarkdownPasteAdmissionOptions + collaboration?: { doc: Y.Doc } }) => [ - ...createMarkdownContentExtensions(), + ...createMarkdownContentExtensions( + {}, + { disableHistory: realCollaboration.enabled && Boolean(options.collaboration) } + ), + ...(realCollaboration.enabled && options.collaboration + ? [Collaboration.configure({ document: options.collaboration.doc })] + : []), ImageUploadPlaceholders, ...(options.pasteAdmission ? [createRichMarkdownPasteAdmission(options.pasteAdmission)] : []), Extension.create({ name: 'slashCommand', addStorage: () => ({ insertImage: null }) }), @@ -97,6 +111,7 @@ const FILE: WorkspaceFileRecord = { size: 30, uploadedBy: 'user-1', uploadedAt: new Date('2026-09-03T20:00:00Z'), + updatedAt: new Date('2026-09-03T20:00:00Z'), } let root: Root let container: HTMLDivElement @@ -153,6 +168,7 @@ interface RenderOptions { file?: WorkspaceFileRecord onDeriveTitleFromHeading?: (fileId: string, headingText: string) => void collaborative?: boolean + recoveringCollaboration?: boolean downloadSourceRef?: { current: FileDownloadSource | null } isStreaming?: boolean streamIsIncremental?: boolean @@ -182,6 +198,7 @@ async function render( userId='user-1' userName='User' collaborative={options.collaborative} + recoveringCollaboration={options.recoveringCollaboration} enableFind={false} onChange={options.onChange ?? onChange} onEditSource={onEditSource} @@ -207,6 +224,7 @@ function getEditor() { beforeEach(() => { uploadFile.mockReset() collaborationRef.current = null + realCollaboration.enabled = false vi.spyOn(toast, 'warning').mockReturnValue('test-toast') vi.spyOn(toast, 'info').mockReturnValue('uploading-toast') vi.spyOn(toast, 'dismiss').mockImplementation(() => {}) @@ -229,6 +247,7 @@ describe('loaded rich editor lifecycle', () => { const frontmatter = '---\r\n# Metadata\r\ntitle: Updated\r\n---\r\n\r\n' config.set(FILE_DOC_SEED.frontmatterKey, frontmatter) collaborationRef.current = { + canWrite: true, doc, awareness: new Awareness(doc), provider, @@ -330,11 +349,224 @@ describe('loaded rich editor lifecycle', () => { } ) + it.each(['file', 'preview context'] as const)( + 'does not recover another %s after a write-loss remount', + async (changedScope) => { + authClientMockFns.mockUseSession.mockReturnValue({ + data: { user: { id: 'user-1', name: 'User' } }, + isPending: false, + }) + editableFileContent.mockReturnValue({ + content: 'Original', + acceptedBaselineContent: 'Original', + isStreamInteractionLocked: false, + isContentLoading: false, + hasContentError: false, + setDraftContent: onChange, + saveImmediately: onSaveShortcut, + }) + const draw = async (file = FILE, previewContextKey = 'first') => { + await act(async () => + root.render( + + ) + ) + } + await draw() + const call = vi.mocked(useFileDocCollaboration).mock.calls.at(-1) + if (!call) throw new Error('Expected collaboration binding') + const firstBinding = call[0] + expect(firstBinding.enabled).toBe(true) + await act(async () => firstBinding.onWriteAccessLost?.()) + editableFileContent.mockReturnValue({ + content: 'Streaming', + acceptedBaselineContent: 'Original', + isStreamInteractionLocked: true, + isContentLoading: false, + hasContentError: false, + setDraftContent: onChange, + saveImmediately: onSaveShortcut, + }) + await draw( + changedScope === 'file' ? { ...FILE, id: 'file-2' } : FILE, + changedScope === 'preview context' ? 'second' : 'first' + ) + expect(useFileDocCollaboration).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false }) + ) + } + ) + + it('retains collaboration while recovering during an active stream', async () => { + const provider = new FakeFileDocProvider() + const doc = new Y.Doc() + const awareness = new Awareness(doc) + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + collaborationRef.current = { + canWrite: false, + doc, + awareness, + provider, + user: { name: 'User', color: '#000000', clientId: doc.clientID }, + } + try { + await render('Uncommitted stream', 'Original', true, { + collaborative: true, + recoveringCollaboration: true, + isStreaming: true, + }) + expect(useFileDocCollaboration).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: true }) + ) + await act(async () => provider.setSynced(true)) + expect(getEditor().isEditable).toBe(false) + expect(onClientAutosaveChange).toHaveBeenCalledWith(false) + expect(onClientAutosaveChange).not.toHaveBeenCalledWith(true) + } finally { + await act(async () => root.render(null)) + awareness.destroy() + doc.destroy() + } + }) + + it('cancels image uploads and picker anchors on write loss without resuming after regrant', async () => { + const provider = new FakeFileDocProvider() + const doc = new Y.Doc() + const awareness = new Awareness(doc) + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + const collaboration = { + canWrite: true, + doc, + awareness, + provider, + user: { name: 'User', color: '#000000', clientId: doc.clientID }, + } + collaborationRef.current = collaboration + let completeUpload!: (value: { file: { url: string } }) => void + uploadFile.mockImplementation( + () => + new Promise((resolve) => { + completeUpload = resolve + }) + ) + try { + await render('Original', 'Original', true, { collaborative: true }) + await act(async () => provider.setSynced(true)) + const editor = getEditor() + await act(async () => editor.commands.setContent('

Original

')) + const input = container.querySelector('input[type="file"]') + if (!input) throw new Error('Image picker is missing') + await act(async () => editor.storage.slashCommand.insertImage?.(1)) + Object.defineProperty(input, 'files', { + configurable: true, + value: [ + new File(['a'], 'first.png', { type: 'image/png' }), + new File(['b'], 'second.png', { type: 'image/png' }), + ], + }) + await act(async () => input.dispatchEvent(new Event('change', { bubbles: true }))) + expect(uploadFile).toHaveBeenCalledTimes(1) + const signal = uploadFile.mock.calls[0][0].signal as AbortSignal + expect(signal.aborted).toBe(false) + expect(container.querySelectorAll('[role="status"]')).toHaveLength(2) + await act(async () => editor.storage.slashCommand.insertImage?.(1)) + collaborationRef.current = { ...collaboration, canWrite: false } + await render('Original', 'Original', true, { collaborative: true }) + expect(signal.aborted).toBe(true) + expect(container.querySelectorAll('[role="status"]')).toHaveLength(0) + collaborationRef.current = collaboration + await render('Original', 'Original', true, { collaborative: true }) + await act(async () => { + completeUpload({ file: { url: '/late.png' } }) + }) + await act(async () => input.dispatchEvent(new Event('change', { bubbles: true }))) + expect(uploadFile).toHaveBeenCalledTimes(1) + expect(editor.view.dom.querySelector('img')).toBeNull() + expect(editor.getText().trim()).toBe('Original') + } finally { + await act(async () => root.render(null)) + awareness.destroy() + doc.destroy() + } + }) + + it('stops queued and subsequent agent stream writes after losing write permission', async () => { + realCollaboration.enabled = true + const frames = new Map() + let nextFrameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + const id = ++nextFrameId + frames.set(id, callback) + return id + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => frames.delete(id)) + const tick = async () => { + const pending = [...frames.values()] + frames.clear() + await act(async () => { + for (const callback of pending) callback(0) + }) + } + const provider = new FakeFileDocProvider() + const doc = new Y.Doc() + const awareness = new Awareness(doc) + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + const collaboration = { + canWrite: true, + doc, + awareness, + provider, + user: { name: 'User', color: '#000000', clientId: doc.clientID }, + } + collaborationRef.current = collaboration + const streaming = { collaborative: true, isStreaming: true, streamIsIncremental: true } + try { + await render('Original', 'Original', true, { collaborative: true }) + await act(async () => provider.setSynced(true)) + const editor = getEditor() + await act(async () => editor.commands.setContent('

Original

')) + expect(editor.isEditable).toBe(true) + await render('Original accepted', 'Original', true, streaming) + await tick() + expect(editor.getText().trim()).toBe('Original accepted') + const acceptedDocument = doc.getXmlFragment('default').toString() + await render('Original accepted queued', 'Original', true, streaming) + collaborationRef.current = { ...collaboration, canWrite: false } + await render('Original accepted queued', 'Original', true, streaming) + await tick() + expect(doc.getXmlFragment('default').toString()).toBe(acceptedDocument) + await render('Original accepted queued later', 'Original', true, streaming) + await tick() + await render('Original accepted queued later', 'Original accepted queued later', true, { + collaborative: true, + }) + await tick() + expect(doc.getXmlFragment('default').toString()).toBe(acceptedDocument) + expect(editor.getText().trim()).toBe('Original accepted') + expect(editor.isEditable).toBe(false) + expect(provider.synced).toBe(true) + expect(provider.joinError).toBeNull() + expect(onChange).not.toHaveBeenCalled() + expect(onSaveShortcut).not.toHaveBeenCalled() + } finally { + await act(async () => root.render(null)) + awareness.destroy() + doc.destroy() + } + }) + it('keeps revoked pending edits visible and read-only without draft-management prompts', async () => { const provider = new FakeFileDocProvider() const doc = new Y.Doc() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) collaborationRef.current = { + canWrite: true, doc, awareness: new Awareness(doc), provider, @@ -374,6 +606,7 @@ describe('loaded rich editor lifecycle', () => { const provider = new FakeFileDocProvider() const doc = new Y.Doc() collaborationRef.current = { + canWrite: true, doc, awareness: new Awareness(doc), provider, @@ -414,6 +647,7 @@ describe('loaded rich editor lifecycle', () => { const doc = new Y.Doc() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) collaborationRef.current = { + canWrite: true, doc, awareness: new Awareness(doc), provider, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index a45cfa6288b..8d73bc078cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -316,6 +316,26 @@ function RichMarkdownSurface({ * re-clobbering exactly what this closes. The child turns it on for the non-collaborative fallback. */ const [canClientAutosave, setCanClientAutosave] = useState(!collaborative) + const [collaborationReset, setCollaborationReset] = useState({ + fileId: file.id, + previewContextKey, + generation: 0, + }) + const collaborationGeneration = + collaborationReset.fileId === file.id && + collaborationReset.previewContextKey === previewContextKey + ? collaborationReset.generation + : 0 + const resetCollaboration = useCallback(() => { + setCollaborationReset((previous) => ({ + fileId: file.id, + previewContextKey, + generation: + previous.fileId === file.id && previous.previewContextKey === previewContextKey + ? previous.generation + 1 + : 1, + })) + }, [file.id, previewContextKey]) const { content, @@ -370,7 +390,9 @@ function RichMarkdownSurface({ /> )} 0} + onWriteAccessLost={resetCollaboration} file={file} workspaceId={workspaceId} content={content} @@ -398,6 +420,8 @@ function RichMarkdownSurface({ } interface LoadedRichMarkdownEditorProps { + recoveringCollaboration?: boolean + onWriteAccessLost?: () => void file: WorkspaceFileRecord workspaceId: string /** The live content from the engine — grows as the agent streams, then settles to the saved doc. */ @@ -453,6 +477,8 @@ function lockSettled(content: string): SettledContent { /** The single TipTap editor: read-only while streaming, editable on settle; frontmatter is held aside and re-applied. */ export function LoadedRichMarkdownEditor({ + recoveringCollaboration = false, + onWriteAccessLost, file, workspaceId, content, @@ -480,14 +506,16 @@ export function LoadedRichMarkdownEditor({ /** Only accepted source snapshots and stream settlement change editing eligibility. */ const [settled, setSettled] = useState(() => - streamingAtMount ? null : lockSettled(content) + streamingAtMount && !recoveringCollaboration + ? null + : lockSettled(recoveringCollaboration ? (acceptedBaselineContent ?? '') : content) ) const [acceptedBaseline, setAcceptedBaseline] = useState(acceptedBaselineContent) /** * Collaboration is decided once at mount from synchronously-available inputs * via `useState`-init, and never changes — TipTap * fixes the extension set at editor creation, so it cannot turn on later. Enabled on a - * `collaborative` surface (the Files page or the embedded chat file preview) for an editable, + * `collaborative` surface (the Files page or the embedded chat file preview) for a readable, * round-trip-safe workspace document with a known user, as long as it is not ALREADY streaming at * mount. An agent stream that begins AFTER mount is applied as CRDT * diffs into the live doc, so collaboration and streaming coexist (see the streaming effect below). @@ -495,8 +523,7 @@ export function LoadedRichMarkdownEditor({ const [collaborationEnabled] = useState( () => collaborative && - canEdit && - !streamingAtMount && + (!streamingAtMount || recoveringCollaboration) && (settled?.verdict ?? false) && Boolean(userId) && (file.storageContext ?? 'workspace') === 'workspace' @@ -520,7 +547,6 @@ export function LoadedRichMarkdownEditor({ collaborationEnabled ? 'connecting' : 'ready' ) const collabReady = collabStatus === 'ready' - const isEditable = canEdit && !isStreaming && (settled?.verdict ?? false) && collabReady const collaboration = useFileDocCollaboration({ workspaceId, @@ -528,7 +554,10 @@ export function LoadedRichMarkdownEditor({ userId, userName, enabled: collaborationEnabled, + onWriteAccessLost, }) + const effectiveCanEdit = canEdit && (!collaborationEnabled || collaboration?.canWrite === true) + const isEditable = effectiveCanEdit && !isStreaming && (settled?.verdict ?? false) && collabReady /** * Initial editor content. When collaborating, the Y.Doc is the source of truth — @@ -547,7 +576,13 @@ export function LoadedRichMarkdownEditor({ */ const [placeholder] = useState(() => collaborationEnabled - ? { content: parseMarkdownToDoc(splitFrontmatter(content).body), markdown: content } + ? { + content: parseMarkdownToDoc( + splitFrontmatter(recoveringCollaboration ? (acceptedBaselineContent ?? '') : content) + .body + ), + markdown: recoveringCollaboration ? (acceptedBaselineContent ?? '') : content, + } : null ) /** @@ -628,6 +663,13 @@ export function LoadedRichMarkdownEditor({ /** The picker anchor maps through edits while the operating-system file dialog is open. */ const imageInputRef = useRef(null) const pendingImageAnchorRef = useRef(null) + const imageUploadBatchesRef = useRef + }> | null>(null) + imageUploadBatchesRef.current ??= new Set() /** * Uploads are sequential; every position is anchored before awaiting so queued images also follow @@ -642,39 +684,55 @@ export function LoadedRichMarkdownEditor({ fallback?: ImageFileFallback | null ) => { const editor = editorInstanceRef.current - if (!editor) return + if (!editor || !effectiveCanEdit) return const anchors = beginImageUploads( editor, range, images.map((image) => image.name) ) - for (const [index, image] of images.entries()) { - if (editor.isDestroyed) break - if (!editor.isEditable) { - for (const pending of anchors) removeImageUpload(editor, pending) - break - } - const anchor = anchors[index] - if (!anchor || findImageUpload(editor, anchor) === null) continue - const uploadingToastId = toast.info(`Uploading "${image.name}"…`, { duration: 0 }) - const result = await uploadFile - .mutateAsync({ workspaceId, file: image, folderId: file.folderId ?? null }) - .catch(() => null) - toast.dismiss(uploadingToastId) - if (result) { - const inserted = finishImageUpload( - editor, - anchor, - result.file.url, - image.name, - fallback ? resolveImageFileFallback(fallback, result.file.url) : undefined - ) - if (!inserted && !editor.isDestroyed) { - toast.info('The image was uploaded to the workspace but was not inserted.') + const batch = { + editor, + anchors, + controller: new AbortController(), + toastId: undefined as ReturnType | undefined, + } + imageUploadBatchesRef.current?.add(batch) + try { + for (const [index, image] of images.entries()) { + if (editor.isDestroyed || !editor.isEditable || batch.controller.signal.aborted) break + const anchor = anchors[index] + if (!anchor || findImageUpload(editor, anchor) === null) continue + batch.toastId = toast.info(`Uploading "${image.name}"…`, { duration: 0 }) + const result = await uploadFile + .mutateAsync({ + workspaceId, + file: image, + folderId: file.folderId ?? null, + signal: batch.controller.signal, + }) + .catch(() => null) + toast.dismiss(batch.toastId) + batch.toastId = undefined + if (batch.controller.signal.aborted) break + if (result) { + const inserted = finishImageUpload( + editor, + anchor, + result.file.url, + image.name, + fallback ? resolveImageFileFallback(fallback, result.file.url) : undefined + ) + if (!inserted && !editor.isDestroyed) { + toast.info('The image was uploaded to the workspace but was not inserted.') + } + } else { + removeImageUpload(editor, anchor) } - } else { - removeImageUpload(editor, anchor) } + } finally { + if (batch.toastId !== undefined) toast.dismiss(batch.toastId) + for (const anchor of anchors) removeImageUpload(editor, anchor) + imageUploadBatchesRef.current?.delete(batch) } } @@ -975,6 +1033,22 @@ export function LoadedRichMarkdownEditor({ } }, [editor, collaborationEnabled, isEditable]) + useEffect(() => { + if (!editor || !effectiveCanEdit) return + return () => { + for (const batch of imageUploadBatchesRef.current ?? []) { + batch.controller.abort() + for (const anchor of batch.anchors) removeImageUpload(batch.editor, anchor) + if (batch.toastId !== undefined) toast.dismiss(batch.toastId) + } + imageUploadBatchesRef.current?.clear() + const anchor = pendingImageAnchorRef.current + if (anchor) removeImageUpload(editor, anchor) + pendingImageAnchorRef.current = null + if (imageInputRef.current) imageInputRef.current.value = '' + } + }, [editor, effectiveCanEdit]) + /** * Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is * shared across instances). Reads only refs, so the handler stays stable across the editor's life. @@ -989,6 +1063,7 @@ export function LoadedRichMarkdownEditor({ } input?.addEventListener('cancel', cancelImagePicker) editor.storage.slashCommand.insertImage = (at: number) => { + if (!editor.isEditable) return const previous = pendingImageAnchorRef.current if (previous) removeImageUpload(editor, previous) pendingImageAnchorRef.current = @@ -1044,6 +1119,16 @@ export function LoadedRichMarkdownEditor({ // the standalone Files page) alike. This branch moves content only — editability for the collab // lifecycle is owned by the reactive effect above. if (collaborationEnabled) { + if (!effectiveCanEdit) { + if (streamRafRef.current !== null) cancelAnimationFrame(streamRafRef.current) + streamRafRef.current = null + pendingStreamSourceRef.current = null + if (collaboration && agentAnnouncedRef.current) clearAgentApplying(collaboration.awareness) + agentAnnouncedRef.current = false + if (agentStreamSessionRef.current) endAgentStream(agentStreamSessionRef.current) + agentStreamSessionRef.current = null + return + } if (isStreaming) { wasStreamingRef.current = true // Apply streamed diffs only after the shared doc has SEEDED (synced + seed flag, i.e. @@ -1251,7 +1336,7 @@ export function LoadedRichMarkdownEditor({ syncEditorContent(content) pendingCollapseRef.current = false editor.commands.setTextSelection(editor.state.doc.content.size) - editor.setEditable(canEdit && settledVerdict && collabReady) + editor.setEditable(effectiveCanEdit && settledVerdict && collabReady) if (shouldFocus) editor.commands.focus('end') }) return @@ -1264,7 +1349,7 @@ export function LoadedRichMarkdownEditor({ pendingCollapseRef.current = false editor.commands.setTextSelection(editor.state.doc.content.size) } - if (settled) editor.setEditable(canEdit && settled.verdict && collabReady) + if (settled) editor.setEditable(effectiveCanEdit && settled.verdict && collabReady) }) }, [ editor, @@ -1273,7 +1358,7 @@ export function LoadedRichMarkdownEditor({ settled, collaboration, isStreaming, - canEdit, + effectiveCanEdit, autoFocus, disableStreamingAutoScroll, collaborationEnabled, diff --git a/apps/sim/hooks/queries/workspace-file-table.ts b/apps/sim/hooks/queries/workspace-file-table.ts index c9f6573b8a9..98eeef30078 100644 --- a/apps/sim/hooks/queries/workspace-file-table.ts +++ b/apps/sim/hooks/queries/workspace-file-table.ts @@ -11,7 +11,7 @@ import { */ export const WORKSPACE_CSV_PREVIEW_STALE_TIME = 30 * 1000 -export const workspaceFileTableKeys = { +const workspaceFileTableKeys = { all: ['workspaceFileTable'] as const, previews: () => [...workspaceFileTableKeys.all, 'preview'] as const, preview: (workspaceId: string, fileId: string, key: string, version?: number) => diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 86f1ea5c367..2a8b2bff54c 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -67,7 +67,7 @@ export const workspaceFilesKeys = { cloudConfigured: () => [...workspaceFilesKeys.all, 'cloudConfigured'] as const, } -export const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000 +const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_CONTENT_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_BINARY_STALE_TIME = 30 * 1000 /** Cloud storage (S3/Blob) is env-driven and does not change at runtime. */ @@ -575,6 +575,7 @@ export function useUploadWorkspaceFile() { } }, onError: (error, variables) => { + if (variables.signal?.aborted) return logger.error('Failed to upload file:', error) if (!variables.skipToast) { toast.error(`Failed to upload "${variables.file.name}": ${error.message}`, { diff --git a/apps/sim/lib/api/contracts/file-browser.ts b/apps/sim/lib/api/contracts/file-browser.ts new file mode 100644 index 00000000000..4a037fe5a73 --- /dev/null +++ b/apps/sim/lib/api/contracts/file-browser.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { FILE_BROWSER_SIZES, FILE_BROWSER_TYPES } from '@/lib/workspace-files/browser' + +export const fileBrowserCreatorSchema = z.object({ + id: nonEmptyIdSchema, + name: z.string(), + image: z.string().nullable(), + deleted: z.boolean(), +}) +export const fileBrowserItemSchema = z.object({ + id: nonEmptyIdSchema, + kind: z.enum(['file', 'folder']), + name: z.string(), + parentId: nonEmptyIdSchema.nullable(), + size: z.number().nonnegative(), + type: z.string(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + creator: fileBrowserCreatorSchema.nullable(), +}) +export const fileBrowserTypesQuerySchema = z + .union([z.enum(FILE_BROWSER_TYPES), z.array(z.enum(FILE_BROWSER_TYPES)).max(4)]) + .transform((value) => (typeof value === 'string' ? [value] : value)) + .default([]) +export const fileBrowserSizesQuerySchema = z + .union([z.enum(FILE_BROWSER_SIZES), z.array(z.enum(FILE_BROWSER_SIZES)).max(3)]) + .transform((value) => (typeof value === 'string' ? [value] : value)) + .default([]) +export const fileBrowserCreatorsQuerySchema = z + .union([nonEmptyIdSchema, z.array(nonEmptyIdSchema).max(100)]) + .transform((value) => (typeof value === 'string' ? [value] : value)) + .default([]) diff --git a/apps/sim/lib/api/contracts/project-file-doc.ts b/apps/sim/lib/api/contracts/project-file-doc.ts new file mode 100644 index 00000000000..d1589f6e66e --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-doc.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import { persistFileDocResponseSchema } from '@/lib/api/contracts/file-doc' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const projectFileDocParamsSchema = z.object({ + projectId: z.string().min(1), + fileId: z.string().min(1), +}) +export type ProjectFileDocParams = z.output + +export const projectFileDocAccessResponseSchema = z.object({ + projectId: z.string(), + fileId: z.string(), + canRead: z.literal(true), + canWrite: z.boolean(), + docId: z.string().min(1).max(128).nullable(), +}) +export type ProjectFileDocAccessResponse = z.output + +export const projectFileDocAccessContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/project-file-doc/[projectId]/[fileId]/access', + params: projectFileDocParamsSchema, + response: { mode: 'json', schema: projectFileDocAccessResponseSchema }, +}) + +export const projectFileDocSeedResponseSchema = z.object({ + update: z.string(), + version: z.number().int(), +}) +export type ProjectFileDocSeedResponse = z.output + +export const projectFileDocSeedContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/project-file-doc/[projectId]/[fileId]/seed', + params: projectFileDocParamsSchema, + response: { mode: 'json', schema: projectFileDocSeedResponseSchema }, +}) + +export const projectFileDocPersistBodySchema = z + .object({ + docState: z + .string() + .min(1) + .max(16 * 1024 * 1024), + expectedVersion: z.number().int().optional(), + }) + .strict() +export type ProjectFileDocPersistBody = z.input + +export const projectFileDocPersistContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/project-file-doc/[projectId]/[fileId]/persist', + params: projectFileDocParamsSchema, + body: projectFileDocPersistBodySchema, + response: { mode: 'json', schema: persistFileDocResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-folders.ts b/apps/sim/lib/api/contracts/project-file-folders.ts new file mode 100644 index 00000000000..730ac106604 --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-folders.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { + projectFileCapabilitiesSchema, + projectFilesParamsSchema, +} from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + createWorkspaceFileFolderBodySchema, + listWorkspaceFileFoldersQuerySchema, + updateWorkspaceFileFolderBodySchema, + workspaceFileFolderSchema, +} from '@/lib/api/contracts/workspace-file-folders' + +export const projectFileFolderParamsSchema = projectFilesParamsSchema.extend({ + folderId: nonEmptyIdSchema, +}) +export type ProjectFileFolderParams = z.input + +export const projectFileFolderRecordSchema = workspaceFileFolderSchema + .omit({ workspaceId: true }) + .extend({ + owner: z.object({ entityType: z.literal('project'), entityId: nonEmptyIdSchema }), + userId: nonEmptyIdSchema, + }) +export type ProjectFileFolderRecord = z.output + +export const listProjectFileFoldersQuerySchema = listWorkspaceFileFoldersQuerySchema +export type ListProjectFileFoldersQuery = z.output + +export const listProjectFileFoldersResponseSchema = z.object({ + folders: z.array(projectFileFolderRecordSchema), + capabilities: projectFileCapabilitiesSchema, +}) +export type ListProjectFileFoldersResponse = z.output + +export const listProjectFileFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/folders', + params: projectFilesParamsSchema, + query: listProjectFileFoldersQuerySchema, + response: { mode: 'json', schema: listProjectFileFoldersResponseSchema }, +}) + +export const createProjectFileFolderBodySchema = createWorkspaceFileFolderBodySchema +export type CreateProjectFileFolderBody = z.input + +export const updateProjectFileFolderBodySchema = updateWorkspaceFileFolderBodySchema +export type UpdateProjectFileFolderBody = z.input + +export const projectFileFolderResponseSchema = z.object({ folder: projectFileFolderRecordSchema }) +export type ProjectFileFolderResponse = z.output + +export const createProjectFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/folders', + params: projectFilesParamsSchema, + body: createProjectFileFolderBodySchema, + response: { mode: 'json', schema: projectFileFolderResponseSchema }, +}) + +export const updateProjectFileFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/projects/[id]/files/folders/[folderId]', + params: projectFileFolderParamsSchema, + body: updateProjectFileFolderBodySchema, + response: { mode: 'json', schema: projectFileFolderResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-lifecycle.ts b/apps/sim/lib/api/contracts/project-file-lifecycle.ts new file mode 100644 index 00000000000..cb300dfbc07 --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-lifecycle.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { + projectFileFolderParamsSchema, + projectFileFolderRecordSchema, +} from '@/lib/api/contracts/project-file-folders' +import { + projectFileParamsSchema, + projectFileRecordSchema, + projectFilesParamsSchema, +} from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + bulkArchiveWorkspaceFileItemsBodySchema, + moveWorkspaceFileItemsBodySchema, +} from '@/lib/api/contracts/workspace-file-folders' +import { renameWorkspaceFileBodySchema } from '@/lib/api/contracts/workspace-files' + +export const renameProjectFileBodySchema = renameWorkspaceFileBodySchema.strict() +export type RenameProjectFileBody = z.input + +export const renameProjectFileResponseSchema = z.object({ file: projectFileRecordSchema }) +export type RenameProjectFileResponse = z.output + +export const renameProjectFileContract = defineRouteContract({ + method: 'PATCH', + path: '/api/projects/[id]/files/[fileId]', + params: projectFileParamsSchema, + body: renameProjectFileBodySchema, + response: { mode: 'json', schema: renameProjectFileResponseSchema }, +}) + +export const moveProjectFileItemsBodySchema = moveWorkspaceFileItemsBodySchema.strict() +export type MoveProjectFileItemsBody = z.input + +export const moveProjectFileItemsResponseSchema = z.object({ + movedFiles: z.number().int().nonnegative().describe('Number of files moved.'), + movedFolders: z.number().int().nonnegative().describe('Number of folders moved.'), + movedFileIds: z.array(nonEmptyIdSchema).describe('Identifiers of moved files.'), + movedFolderIds: z.array(nonEmptyIdSchema).describe('Identifiers of moved folders.'), +}) +export type MoveProjectFileItemsResponse = z.output + +export const moveProjectFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/move', + params: projectFilesParamsSchema, + body: moveProjectFileItemsBodySchema, + response: { mode: 'json', schema: moveProjectFileItemsResponseSchema }, +}) + +export const archiveProjectFileItemsBodySchema = bulkArchiveWorkspaceFileItemsBodySchema.strict() +export type ArchiveProjectFileItemsBody = z.input + +export const archiveProjectFileItemsResponseSchema = z.object({ + deletedItems: z + .object({ + files: z.number().int().nonnegative().describe('Number of affected files.'), + folders: z.number().int().nonnegative().describe('Number of affected folders.'), + }) + .describe('Counts of affected file items.'), + affectedIds: z + .object({ + fileIds: z.array(nonEmptyIdSchema).describe('Identifiers of affected files.'), + folderIds: z.array(nonEmptyIdSchema).describe('Identifiers of affected folders.'), + }) + .describe('Identifiers of affected file items.'), +}) +export type ArchiveProjectFileItemsResponse = z.output + +export const archiveProjectFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/archive', + params: projectFilesParamsSchema, + body: archiveProjectFileItemsBodySchema, + response: { mode: 'json', schema: archiveProjectFileItemsResponseSchema }, +}) + +export const restoreProjectFileResponseSchema = z.object({ + restored: z.literal(true), + file: projectFileRecordSchema, +}) +export type RestoreProjectFileResponse = z.output + +export const restoreProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/[fileId]/restore', + params: projectFileParamsSchema, + body: noInputSchema, + response: { mode: 'json', schema: restoreProjectFileResponseSchema }, +}) + +export const restoreProjectFileFolderResponseSchema = z.object({ + folder: projectFileFolderRecordSchema, + restoredItems: z + .object({ + files: z.number().int().nonnegative().describe('Number of affected files.'), + folders: z.number().int().nonnegative().describe('Number of affected folders.'), + }) + .describe('Counts of affected file items.'), +}) +export type RestoreProjectFileFolderResponse = z.output< + typeof restoreProjectFileFolderResponseSchema +> + +export const restoreProjectFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/folders/[folderId]/restore', + params: projectFileFolderParamsSchema, + body: noInputSchema, + response: { mode: 'json', schema: restoreProjectFileFolderResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-uploads.ts b/apps/sim/lib/api/contracts/project-file-uploads.ts new file mode 100644 index 00000000000..1d71eb7a92d --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-uploads.ts @@ -0,0 +1,92 @@ +import { z } from 'zod' +import { folderIdSchema, noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { + projectFileRecordSchema, + projectFilesParamsSchema, +} from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CreateFileUploadBodySchema } from '@/lib/api/contracts/v2/files' +import { + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadStatusSchema, + v2UploadTokenHeadersSchema, + v2UploadTransferSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const projectFileUploadParamsSchema = projectFilesParamsSchema.extend({ + uploadId: nonEmptyIdSchema, +}) +export type ProjectFileUploadParams = z.input + +export const createProjectFileUploadBodySchema = v2CreateFileUploadBodySchema + .omit({ workspaceId: true }) + .extend({ folderId: folderIdSchema.nullish() }) + .strict() +export type CreateProjectFileUploadBody = z.input + +export const projectFileUploadSessionSchema = z + .object({ + id: nonEmptyIdSchema, + purpose: z.literal('project_file'), + status: v2UploadStatusSchema, + name: z.string(), + contentType: z.string(), + size: z.number().int().nonnegative(), + expiresAt: z.string().datetime(), + error: z.string().nullable(), + result: projectFileRecordSchema.nullable(), + }) + .strict() +export type ProjectFileUploadSession = z.output + +export const createProjectFileUploadResponseSchema = z + .object({ + session: projectFileUploadSessionSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict() +export type CreateProjectFileUploadResponse = z.output + +export const createProjectFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/uploads', + params: projectFilesParamsSchema, + body: createProjectFileUploadBodySchema, + response: { mode: 'json', schema: createProjectFileUploadResponseSchema, status: 201 }, +}) + +export const getProjectFileUploadContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/uploads/[uploadId]', + params: projectFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: projectFileUploadSessionSchema }, +}) + +export const abortProjectFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/projects/[id]/files/uploads/[uploadId]', + params: projectFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: projectFileUploadSessionSchema }, +}) + +export const completeProjectFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/uploads/[uploadId]/complete', + params: projectFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + body: noInputSchema.default({}), + response: { mode: 'json', schema: projectFileUploadSessionSchema }, +}) + +export const getProjectFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/uploads/[uploadId]/parts', + params: projectFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2PartUrlsDataSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-versions.ts b/apps/sim/lib/api/contracts/project-file-versions.ts new file mode 100644 index 00000000000..e754ff9fc8b --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-versions.ts @@ -0,0 +1,94 @@ +import { z } from 'zod' +import { noInputSchema, versionNumberPathSchema } from '@/lib/api/contracts/primitives' +import { projectFileParamsSchema, projectFileRecordSchema } from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2DeleteFileVersionResultSchema, + v2FileVersionSchema, + v2ListFileVersionsQuerySchema, + v2RevertFileVersionBodySchema, +} from '@/lib/api/contracts/v2/file-versions' +import { writtenFileRevisionSchema } from '@/lib/api/contracts/v2/files' + +export const projectFileVersionSchema = v2FileVersionSchema.meta({ + id: 'ProjectFileVersion', + title: 'Project file version', + description: 'One recorded version of a shared Project file.', +}) +export type ProjectFileVersion = z.output +export const projectFileVersionParamsSchema = projectFileParamsSchema.extend({ + version: versionNumberPathSchema.describe('Version number.'), +}) +export type ProjectFileVersionParams = z.input +export const listProjectFileVersionsQuerySchema = v2ListFileVersionsQuerySchema.omit({ + workspaceId: true, +}) +export type ListProjectFileVersionsQuery = z.output +export const listProjectFileVersionsResponseSchema = z.object({ + revision: writtenFileRevisionSchema, + versions: z.array(projectFileVersionSchema), + nextCursor: z.string().nullable(), +}) +export type ListProjectFileVersionsResponse = z.output +export const getProjectFileVersionResponseSchema = z.object({ version: projectFileVersionSchema }) +export type GetProjectFileVersionResponse = z.output +export const revertProjectFileVersionBodySchema = v2RevertFileVersionBodySchema.omit({ + workspaceId: true, +}) +export type RevertProjectFileVersionBody = z.input +export const revertProjectFileVersionResponseSchema = z.object({ + reverted: z.boolean(), + file: projectFileRecordSchema, + version: projectFileVersionSchema, + revision: writtenFileRevisionSchema, +}) +export type RevertProjectFileVersionResponse = z.output< + typeof revertProjectFileVersionResponseSchema +> +export const deleteProjectFileVersionResponseSchema = v2DeleteFileVersionResultSchema.extend({ + deleted: z + .literal(true) + .describe( + 'The version is no longer available; stored-object cleanup is retried asynchronously when needed.' + ), +}) +export type DeleteProjectFileVersionResponse = z.output< + typeof deleteProjectFileVersionResponseSchema +> + +export const listProjectFileVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/versions', + params: projectFileParamsSchema, + query: listProjectFileVersionsQuerySchema, + response: { mode: 'json', schema: listProjectFileVersionsResponseSchema }, +}) +export const getProjectFileVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/versions/[version]', + params: projectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: getProjectFileVersionResponseSchema }, +}) +export const readProjectFileVersionContentContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/versions/[version]/content', + params: projectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'binary' }, +}) +export const revertProjectFileVersionContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/[fileId]/versions/[version]/revert', + params: projectFileVersionParamsSchema, + query: noInputSchema, + body: revertProjectFileVersionBodySchema, + response: { mode: 'json', schema: revertProjectFileVersionResponseSchema }, +}) +export const deleteProjectFileVersionContract = defineRouteContract({ + method: 'DELETE', + path: '/api/projects/[id]/files/[fileId]/versions/[version]', + params: projectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: deleteProjectFileVersionResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-files.ts b/apps/sim/lib/api/contracts/project-files.ts new file mode 100644 index 00000000000..9f7926edaeb --- /dev/null +++ b/apps/sim/lib/api/contracts/project-files.ts @@ -0,0 +1,170 @@ +import { z } from 'zod' +import { + fileBrowserCreatorSchema, + fileBrowserCreatorsQuerySchema, + fileBrowserItemSchema, + fileBrowserSizesQuerySchema, + fileBrowserTypesQuerySchema, +} from '@/lib/api/contracts/file-browser' +import { + booleanQueryFlagSchema, + folderIdSchema, + inlineFileRefQuerySchema, + nonEmptyIdSchema, +} from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2PaginationFields, v2SearchSchema, v2SortFields } from '@/lib/api/contracts/v2/shared' +import { + workspaceCsvPreviewQuerySchema, + workspaceCsvPreviewResponseSchema, +} from '@/lib/api/contracts/workspace-file-table' +import { + createWorkspaceFileBodySchema, + updateWorkspaceFileContentBodySchema, + workspaceFileRecordSchema, + workspaceFileScopeSchema, +} from '@/lib/api/contracts/workspace-files' +import { FILE_BROWSER_SORTS } from '@/lib/workspace-files/browser' + +export const projectFilesParamsSchema = z.object({ id: nonEmptyIdSchema }) +export type ProjectFilesParams = z.input + +export const projectFileParamsSchema = projectFilesParamsSchema.extend({ fileId: nonEmptyIdSchema }) +export type ProjectFileParams = z.input + +export const projectFileRecordSchema = workspaceFileRecordSchema + .omit({ workspaceId: true, storageContext: true, vfsNamespace: true }) + .extend({ + owner: z.object({ entityType: z.literal('project'), entityId: nonEmptyIdSchema }), + uploadedBy: nonEmptyIdSchema, + }) +export type ProjectFileRecord = z.output + +export const projectFileCapabilitiesSchema = z.object({ + canRead: z.boolean(), + canWrite: z.boolean(), +}) +export type ProjectFileCapabilities = z.output + +export const listProjectFilesQuerySchema = z + .object({ + scope: workspaceFileScopeSchema.default('active'), + folderId: folderIdSchema.optional(), + recursive: booleanQueryFlagSchema.optional(), + search: v2SearchSchema.optional(), + types: fileBrowserTypesQuerySchema, + sizes: fileBrowserSizesQuerySchema, + creatorIds: fileBrowserCreatorsQuerySchema, + ...v2SortFields(FILE_BROWSER_SORTS, { sortBy: 'updated', sortOrder: 'desc' }), + ...v2PaginationFields(), + }) + .refine((query) => !query.recursive || query.folderId == null, { + message: 'Recursive listing starts at the Project root; omit folderId', + path: ['folderId'], + }) +export type ListProjectFilesQuery = z.output + +export const listProjectFilesResponseSchema = z.object({ + files: z.array(projectFileRecordSchema), + items: z.array(fileBrowserItemSchema), + creators: z.array(fileBrowserCreatorSchema), + nextCursor: z.string().nullable(), + capabilities: projectFileCapabilitiesSchema, +}) +export type ListProjectFilesResponse = z.output + +export const listProjectFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files', + params: projectFilesParamsSchema, + query: listProjectFilesQuerySchema, + response: { mode: 'json', schema: listProjectFilesResponseSchema }, +}) + +export const getProjectFileResponseSchema = z.object({ + file: projectFileRecordSchema, + capabilities: projectFileCapabilitiesSchema, +}) +export type GetProjectFileResponse = z.output + +export const getProjectFileContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]', + params: projectFileParamsSchema, + response: { mode: 'json', schema: getProjectFileResponseSchema }, +}) + +export const createProjectFileBodySchema = createWorkspaceFileBodySchema.safeExtend({ + exactName: z.boolean().default(false), + folderPath: z.string().max(4096).optional(), +}) +export type CreateProjectFileBody = z.input + +export const createProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files', + params: projectFilesParamsSchema, + body: createProjectFileBodySchema, + response: { mode: 'json', schema: getProjectFileResponseSchema, status: 201 }, +}) + +export const updateProjectFileContentBodySchema = updateWorkspaceFileContentBodySchema + .safeExtend({ + expectedRevision: z.string().min(1).max(1024).optional(), + contentType: z.string().trim().min(1).max(255).optional(), + }) + .strict() +export type UpdateProjectFileContentBody = z.input + +export const updateProjectFileContentResponseSchema = getProjectFileResponseSchema.safeExtend({ + file: projectFileRecordSchema.extend({ currentVersion: z.number().int().positive() }), +}) +export type UpdateProjectFileContentResponse = z.output< + typeof updateProjectFileContentResponseSchema +> + +export const updateProjectFileContentContract = defineRouteContract({ + method: 'PUT', + path: '/api/projects/[id]/files/[fileId]/content', + params: projectFileParamsSchema, + body: updateProjectFileContentBodySchema, + response: { mode: 'json', schema: updateProjectFileContentResponseSchema }, +}) + +export const readProjectFileContentContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/content', + params: projectFileParamsSchema, + response: { mode: 'binary' }, +}) + +export const projectFileArtifactQuerySchema = z.object({ + preview: z.enum(['1', '0']).optional(), + v: z.string().max(128).optional(), + t: z.string().max(32).optional(), +}) +export type ProjectFileArtifactQuery = z.input + +export const readProjectFileArtifactContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/artifact', + params: projectFileParamsSchema, + query: projectFileArtifactQuerySchema, + response: { mode: 'binary' }, +}) + +export const getInlineProjectFileContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/inline', + params: projectFilesParamsSchema, + query: inlineFileRefQuerySchema, + response: { mode: 'binary' }, +}) + +export const getProjectCsvPreviewContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/csv-preview', + params: projectFileParamsSchema, + query: workspaceCsvPreviewQuerySchema, + response: { mode: 'json', schema: workspaceCsvPreviewResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/projects.ts b/apps/sim/lib/api/contracts/projects.ts index 0339cd07738..99abb40d820 100644 --- a/apps/sim/lib/api/contracts/projects.ts +++ b/apps/sim/lib/api/contracts/projects.ts @@ -23,6 +23,8 @@ const projectSchema = z.object({ environments: z.array(projectEnvironmentSchema), capabilities: z.object({ administer: z.boolean(), issues: z.boolean() }), }) +export type Project = z.output + const projectParamsSchema = z.object({ id: nonEmptyIdSchema }) const projectQuerySchema = z.object({ organizationId: organizationIdSchema.optional(), diff --git a/apps/sim/lib/api/contracts/realtime-file-lists.ts b/apps/sim/lib/api/contracts/realtime-file-lists.ts new file mode 100644 index 00000000000..0b68a6297af --- /dev/null +++ b/apps/sim/lib/api/contracts/realtime-file-lists.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const projectFileListAccessParamsSchema = z.object({ + projectId: z + .string() + .min(1) + .max(200) + .regex(/^[^/:\s]+$/), +}) + +const projectFileListAccessResponseSchema = z.object({ + projectId: z.string().min(1), + canRead: z.literal(true), +}) + +export const projectFileListAccessContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/project-file-list/[projectId]/access', + params: projectFileListAccessParamsSchema, + response: { mode: 'json', schema: projectFileListAccessResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index cbbe2e7bd26..323fcaf36f7 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -167,7 +167,7 @@ export const v2FileShareSchema = shareRecordSchema .meta({ id: 'V2FileShare', title: 'File share', - description: 'Public-safe share configuration for a workspace file.', + description: 'Public-safe share configuration for a file.', }) export type V2FileShare = z.output @@ -344,7 +344,7 @@ export const v2CreateFileBodySchema = z export type V2CreateFileBody = z.input /** Sortable file fields. `name` is the uploaded file name, not the storage key. */ -export const v2FileSortFields = ['name', 'size', 'uploadedAt', 'updatedAt'] as const +const v2FileSortFields = ['name', 'size', 'uploadedAt', 'updatedAt'] as const export type V2FileSortBy = (typeof v2FileSortFields)[number] diff --git a/apps/sim/lib/api/contracts/v2/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/list-pagination.test.ts index e594e674224..f11a22ae1bf 100644 --- a/apps/sim/lib/api/contracts/v2/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/list-pagination.test.ts @@ -64,6 +64,8 @@ const PAGED_LISTS = [ 'GET /api/v2/credentials', 'GET /api/v2/custom-tools', 'GET /api/v2/files', + 'GET /api/v2/projects/[projectId]/files', + 'GET /api/v2/projects/[projectId]/files/[fileId]/versions', 'GET /api/v2/files/[fileId]/versions', 'GET /api/v2/knowledge', 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors', @@ -124,6 +126,7 @@ const PAGED_LISTS = [ const FULL_SET_LISTS = [ 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', + 'GET /api/v2/projects/[projectId]/files/folders', 'GET /api/v2/knowledge/[knowledgeBaseId]/tags', 'GET /api/v2/knowledge/[knowledgeBaseId]/tags/usage', 'GET /api/v2/knowledge/folders', @@ -253,6 +256,15 @@ const CURSOR_BINDINGS: Record = { 'recursive', ], 'GET /api/v2/files/[fileId]/versions': ['sortBy', 'sortOrder'], + 'GET /api/v2/projects/[projectId]/files/[fileId]/versions': ['sortBy', 'sortOrder'], + 'GET /api/v2/projects/[projectId]/files': [ + 'scope', + 'folderPath', + 'recursive', + 'search', + 'sortBy', + 'sortOrder', + ], 'GET /api/v2/knowledge': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors': ['workspaceId', 'sortBy', 'sortOrder'], 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents': [ @@ -364,6 +376,8 @@ const CURSOR_BINDINGS: Record = { * resolves the path before fingerprinting it. */ const CURSOR_BOUND_PATH_PARAMS: Record = { + 'GET /api/v2/projects/[projectId]/files': ['projectId'], + 'GET /api/v2/projects/[projectId]/files/[fileId]/versions': ['projectId', 'fileId'], 'GET /api/v2/credentials/[credentialId]/members': ['credentialId'], 'GET /api/v2/organizations/[organizationId]/sso/providers': ['organizationId'], 'GET /api/v2/organizations/[organizationId]/domains': ['organizationId'], diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-folders.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-folders.ts new file mode 100644 index 00000000000..93ec5a77fec --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-folders.ts @@ -0,0 +1,159 @@ +import { + documentedSchema, + FULL_SET_LIST, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2CreateProjectFileFolderContract, + v2ListProjectFileFoldersContract, + v2RestoreProjectFileFolderContract, + v2UpdateProjectFileFolderContract, +} from '@/lib/api/contracts/v2/project-file-folders' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileFolderOpenApiRoutes = [ + defineOpenApiRoute( + v2ListProjectFileFoldersContract, + { + applicationOperation: projectFileOperations.listFolders, + operationId: 'listProjectFileFolders', + summary: 'List Project file folders', + description: `List the Project folder tree with stable identifiers. Use scope=archived to find folders eligible for restore. ${FULL_SET_LIST} ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { + description: 'The complete set of folders in the selected lifecycle scope.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListProjectFileFoldersContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: documentedSchema( + v2ListProjectFileFoldersContract.query, + 'ListProjectFileFoldersQuery', + 'Project folder list query', + 'Select active, archived, or all folders.' + ), + response: documentedSchema( + v2ListProjectFileFoldersContract.response.schema, + 'V2ProjectFileFolderListResponse', + 'Project folder list response', + 'Folders in their existing manual order; nextCursor is always null.' + ), + } + ), + defineOpenApiRoute( + v2CreateProjectFileFolderContract, + { + applicationOperation: projectFileOperations.createFolder, + operationId: 'createProjectFileFolder', + summary: 'Create Project file folder', + description: `Create a folder under an existing parent, or at the Project root when parentId is omitted. Sibling names must be unique. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'The created folder and its owner and creator attribution.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CreateProjectFileFolderContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: v2CreateProjectFileFolderContract.query, + body: documentedSchema( + v2CreateProjectFileFolderContract.body, + 'CreateProjectFileFolderRequest', + 'Create Project folder request', + 'The folder name and optional parent identifier.' + ), + response: documentedSchema( + v2CreateProjectFileFolderContract.response.schema, + 'V2ProjectFileFolderResponse', + 'Project folder response', + 'One folder with canonical ownership and creator attribution.' + ), + } + ), + defineOpenApiRoute( + v2UpdateProjectFileFolderContract, + { + applicationOperation: projectFileOperations.updateFolder, + operationId: 'updateProjectFileFolder', + summary: 'Update Project file folder', + description: `Rename, move, or reorder a folder while retaining its identity and descendants. Omitted fields stay unchanged; parentId=null moves the folder to the root. Cross-owner parents and cycles are rejected. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The updated folder.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2UpdateProjectFileFolderContract.params, + 'ProjectFileFolderParams', + 'Project folder identity', + 'The Project and folder identifiers.' + ), + query: v2UpdateProjectFileFolderContract.query, + body: documentedSchema( + v2UpdateProjectFileFolderContract.body, + 'UpdateProjectFileFolderRequest', + 'Update Project folder request', + 'The fields to change on the folder.' + ), + response: documentedSchema( + v2UpdateProjectFileFolderContract.response.schema, + 'V2ProjectFileFolderResponse', + 'Project folder response', + 'One folder with canonical ownership and creator attribution.' + ), + } + ), + defineOpenApiRoute( + v2RestoreProjectFileFolderContract, + { + applicationOperation: projectFileOperations.restoreFolder, + operationId: 'restoreProjectFileFolder', + summary: 'Restore Project file folder', + description: `Restore an archived folder and the files and subfolders archived with it. Find identifiers with List Project File Folders using scope=archived. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'The restored folder and affected item counts.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2RestoreProjectFileFolderContract.params, + 'ProjectFileFolderParams', + 'Project folder identity', + 'The Project and folder identifiers.' + ), + query: v2RestoreProjectFileFolderContract.query, + body: documentedSchema( + v2RestoreProjectFileFolderContract.body, + 'RestoreProjectFileFolderRequest', + 'Restore Project folder request', + 'An empty object; the folder is identified in the path.' + ), + response: documentedSchema( + v2RestoreProjectFileFolderContract.response.schema, + 'V2RestoreProjectFileFolderResponse', + 'Restore Project folder response', + 'The restored folder and affected item counts.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts new file mode 100644 index 00000000000..9a53e2820ba --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts @@ -0,0 +1,192 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2AbortProjectFileUploadContract, + v2CompleteProjectFileUploadContract, + v2CreateProjectFileUploadContract, + v2GetProjectFileUploadContract, + v2GetProjectFileUploadPartUrlsContract, +} from '@/lib/api/contracts/v2/project-file-uploads' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileUploadOpenApiRoutes = [ + defineOpenApiRoute( + v2CreateProjectFileUploadContract, + { + applicationOperation: projectFileOperations.uploadCreate, + operationId: 'createProjectFileUpload', + summary: 'Create Project file upload', + description: `Create a resumable Project file upload. The file is registered only after the signed transfer and completion succeed. Creation returns an upload-token. Send that token with the original API credential on subsequent control requests. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { description: 'Create Project File Upload result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2CreateProjectFileUploadContract.params, + 'ProjectFilesParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2CreateProjectFileUploadContract.query, + body: documentedSchema( + v2CreateProjectFileUploadContract.body, + 'ProjectUploadCreateRequest', + 'Create Project File Upload request', + 'The parameters for this upload operation.' + ), + response: documentedSchema( + v2CreateProjectFileUploadContract.response.schema, + 'V2CreateProjectFileUploadResponse', + 'Create Project File Upload response', + 'The authorized upload state or transfer instructions.' + ), + } + ), + defineOpenApiRoute( + v2GetProjectFileUploadContract, + { + applicationOperation: projectFileOperations.uploadRead, + operationId: 'getProjectFileUpload', + summary: 'Get Project file upload', + description: `Read the current state of a Project upload, including its file after completion. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Get Project File Upload result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetProjectFileUploadContract.params, + 'ProjectFileUploadParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2GetProjectFileUploadContract.query, + headers: documentedSchema( + v2GetProjectFileUploadContract.headers, + 'ProjectUploadTokenHeaders', + 'Project upload control token', + 'The signed token issued at upload creation, in addition to the original API credential.' + ), + response: documentedSchema( + v2GetProjectFileUploadContract.response.schema, + 'V2ProjectFileUploadResponse', + 'Project file upload response', + 'The authorized upload state or transfer instructions.' + ), + } + ), + defineOpenApiRoute( + v2AbortProjectFileUploadContract, + { + applicationOperation: projectFileOperations.uploadCancel, + operationId: 'abortProjectFileUpload', + summary: 'Abort Project file upload', + description: `Abort a pending Project upload and schedule its unregistered bytes for cleanup. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Abort Project File Upload result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2AbortProjectFileUploadContract.params, + 'ProjectFileUploadParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2AbortProjectFileUploadContract.query, + headers: documentedSchema( + v2AbortProjectFileUploadContract.headers, + 'ProjectUploadTokenHeaders', + 'Project upload control token', + 'The signed token issued at upload creation, in addition to the original API credential.' + ), + response: documentedSchema( + v2AbortProjectFileUploadContract.response.schema, + 'V2ProjectFileUploadResponse', + 'Project file upload response', + 'The authorized upload state or transfer instructions.' + ), + } + ), + defineOpenApiRoute( + v2CompleteProjectFileUploadContract, + { + applicationOperation: projectFileOperations.uploadComplete, + operationId: 'completeProjectFileUpload', + summary: 'Complete Project file upload', + description: `Finalize verified bytes and atomically register one Project file. Retrying completion returns the same file without billing twice. Current edit access is checked again. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'Complete Project File Upload result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2CompleteProjectFileUploadContract.params, + 'ProjectFileUploadParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2CompleteProjectFileUploadContract.query, + headers: documentedSchema( + v2CompleteProjectFileUploadContract.headers, + 'ProjectUploadTokenHeaders', + 'Project upload control token', + 'The signed token issued at upload creation, in addition to the original API credential.' + ), + response: documentedSchema( + v2CompleteProjectFileUploadContract.response.schema, + 'V2ProjectFileUploadResponse', + 'Project file upload response', + 'The authorized upload state or transfer instructions.' + ), + } + ), + defineOpenApiRoute( + v2GetProjectFileUploadPartUrlsContract, + { + applicationOperation: projectFileOperations.uploadParts, + operationId: 'getProjectFileUploadPartUrls', + summary: 'Get Project file upload part URLs', + description: `Request signed multipart transfer URLs for an active Project upload. Send exactly the returned transfer headers when uploading each part. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { + description: 'Get Project File Upload Part URLs result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2GetProjectFileUploadPartUrlsContract.params, + 'ProjectFileUploadParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2GetProjectFileUploadPartUrlsContract.query, + headers: documentedSchema( + v2GetProjectFileUploadPartUrlsContract.headers, + 'ProjectUploadTokenHeaders', + 'Project upload control token', + 'The signed token issued at upload creation, in addition to the original API credential.' + ), + body: documentedSchema( + v2GetProjectFileUploadPartUrlsContract.body, + 'ProjectFileUploadPartUrlsRequest', + 'Get Project File Upload Part URLs request', + 'The parameters for this upload operation.' + ), + response: documentedSchema( + v2GetProjectFileUploadPartUrlsContract.response.schema, + 'V2ProjectFileUploadPartUrlsResponse', + 'Get Project File Upload Part URLs response', + 'The authorized upload state or transfer instructions.' + ), + } + ), +] diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-versions.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-versions.ts new file mode 100644 index 00000000000..e843601dbee --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-versions.ts @@ -0,0 +1,172 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2DeleteProjectFileVersionContract, + v2GetProjectFileVersionContract, + v2ListProjectFileVersionsContract, + v2ReadProjectFileVersionContentContract, + v2RevertProjectFileVersionContract, +} from '@/lib/api/contracts/v2/project-file-versions' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileVersionOpenApiRoutes = [ + defineOpenApiRoute( + v2ListProjectFileVersionsContract, + { + applicationOperation: projectFileOperations.listVersions, + operationId: 'listProjectFileVersions', + summary: 'List Project File Versions', + description: `List recorded versions of a shared Project file, newest first by default. Retention follows the Project payer and preserves the newest ten versions; removed versions leave gaps in numbering. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { + description: 'A cursor-paginated page of Project file versions.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListProjectFileVersionsContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: documentedSchema( + v2ListProjectFileVersionsContract.query, + 'ListProjectFileVersionsQuery', + 'List Project file versions query', + 'Sort direction and cursor pagination within this file history.' + ), + response: documentedSchema( + v2ListProjectFileVersionsContract.response.schema, + 'V2ProjectFileVersionListResponse', + 'List Project File Versions response', + 'A cursor-paginated page of Project file versions.' + ), + } + ), + defineOpenApiRoute( + v2GetProjectFileVersionContract, + { + applicationOperation: projectFileOperations.readVersion, + operationId: 'getProjectFileVersion', + summary: 'Get Project File Version', + description: `Get metadata and author attribution for a recorded Project file version. Missing or permanently removed versions return \`404\`. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'Metadata for the selected version.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetProjectFileVersionContract.params, + 'ProjectFileVersionParams', + 'Project file version identity', + 'The Project, file, and selected version when applicable.' + ), + query: v2GetProjectFileVersionContract.query, + response: documentedSchema( + v2GetProjectFileVersionContract.response.schema, + 'V2ProjectFileVersionResponse', + 'Get Project File Version response', + 'Metadata for the selected version.' + ), + } + ), + defineOpenApiRoute( + v2ReadProjectFileVersionContentContract, + { + applicationOperation: projectFileOperations.readVersionContent, + operationId: 'readProjectFileVersionContent', + summary: 'Read Project File Version Content', + description: `Read the stored source bytes of a Project file version using the file’s current name. Generated documents return their editable source, rather than a compiled Office or page export. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'The selected version’s stored source bytes.', + headers: RATE_LIMIT_HEADERS, + contentTypes: ['application/octet-stream'], + }, + }, + { + params: documentedSchema( + v2ReadProjectFileVersionContentContract.params, + 'ProjectFileVersionParams', + 'Project file version identity', + 'The Project, file, and selected version when applicable.' + ), + query: v2ReadProjectFileVersionContentContract.query, + } + ), + defineOpenApiRoute( + v2RevertProjectFileVersionContract, + { + applicationOperation: projectFileOperations.revertVersion, + operationId: 'revertProjectFileVersion', + summary: 'Revert Project File Version', + description: `Make an earlier version current by recording its source bytes as a new revert version. Reverting to the current version is a no-op. Use expectedRevision to reject changes made since the last read; a stale revision or concurrent edit returns \`409\`. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'The file and current version after the revert.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2RevertProjectFileVersionContract.params, + 'ProjectFileVersionParams', + 'Project file version identity', + 'The Project, file, and selected version when applicable.' + ), + query: v2RevertProjectFileVersionContract.query, + body: documentedSchema( + v2RevertProjectFileVersionContract.body, + 'RevertProjectFileVersionRequest', + 'Revert Project file version request', + 'Optional revision or current-version preconditions.' + ), + response: documentedSchema( + v2RevertProjectFileVersionContract.response.schema, + 'V2ProjectFileVersionRevertResponse', + 'Revert Project File Version response', + 'The file and current version after the revert.' + ), + } + ), + defineOpenApiRoute( + v2DeleteProjectFileVersionContract, + { + applicationOperation: projectFileOperations.deleteVersion, + operationId: 'deleteProjectFileVersion', + summary: 'Delete Project File Version', + description: `Permanently remove one superseded Project file version from history. Deleting the current version returns \`409\`; other versions and the current file remain available. Stored-object cleanup is retried asynchronously when needed. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Deletion acknowledgement for the superseded version.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2DeleteProjectFileVersionContract.params, + 'ProjectFileVersionParams', + 'Project file version identity', + 'The Project, file, and selected version when applicable.' + ), + query: v2DeleteProjectFileVersionContract.query, + response: documentedSchema( + v2DeleteProjectFileVersionContract.response.schema, + 'V2ProjectFileVersionDeleteResponse', + 'Delete Project File Version response', + 'Deletion acknowledgement for the superseded version.' + ), + } + ), +] diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-files.ts b/apps/sim/lib/api/contracts/v2/openapi/project-files.ts new file mode 100644 index 00000000000..4af74a6231e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-files.ts @@ -0,0 +1,320 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2ArchiveProjectFileItemsContract, + v2MoveProjectFileItemsContract, + v2RenameProjectFileContract, + v2RestoreProjectFileContract, +} from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + v2CreateProjectFileContract, + v2GetProjectFileMetadataContract, + v2ListProjectFilesContract, + v2ReadProjectFileContentContract, + v2UpdateProjectFileContentContract, +} from '@/lib/api/contracts/v2/project-files' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileOpenApiRoutes = [ + defineOpenApiRoute( + v2RenameProjectFileContract, + { + applicationOperation: projectFileOperations.rename, + operationId: 'renameProjectFile', + summary: 'Rename Project File', + description: `Rename a shared Project file while retaining its identity and history. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Metadata for the renamed file.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2RenameProjectFileContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2RenameProjectFileContract.query, + body: documentedSchema( + v2RenameProjectFileContract.body, + 'RenameProjectFileRequest', + 'Rename Project File request', + 'The new file name.' + ), + response: documentedSchema( + v2RenameProjectFileContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), + defineOpenApiRoute( + v2MoveProjectFileItemsContract, + { + applicationOperation: projectFileOperations.moveItems, + operationId: 'moveProjectFileItems', + summary: 'Move Project File Items', + description: `Move selected files and folders into an existing folder within the same Project. Folder contents move with their parent. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Counts and identifiers of moved items.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2MoveProjectFileItemsContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: v2MoveProjectFileItemsContract.query, + body: documentedSchema( + v2MoveProjectFileItemsContract.body, + 'MoveProjectFileItemsRequest', + 'Move Project File Items request', + 'Selected files and folders and their destination.' + ), + response: documentedSchema( + v2MoveProjectFileItemsContract.response.schema, + 'V2MoveProjectFileItemsResponse', + 'Project file operation response', + 'Counts and identifiers of moved items.' + ), + } + ), + defineOpenApiRoute( + v2ArchiveProjectFileItemsContract, + { + applicationOperation: projectFileOperations.archiveItems, + operationId: 'archiveProjectFileItems', + summary: 'Archive Project File Items', + description: `Archive selected files and folders. Folder contents are archived recursively and remain recoverable until retention removes them. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Counts and identifiers of archived items.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ArchiveProjectFileItemsContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: v2ArchiveProjectFileItemsContract.query, + body: documentedSchema( + v2ArchiveProjectFileItemsContract.body, + 'ArchiveProjectFileItemsRequest', + 'Archive Project File Items request', + 'Files and folders to archive.' + ), + response: documentedSchema( + v2ArchiveProjectFileItemsContract.response.schema, + 'V2ArchiveProjectFileItemsResponse', + 'Project file operation response', + 'Counts and identifiers of archived items.' + ), + } + ), + defineOpenApiRoute( + v2RestoreProjectFileContract, + { + applicationOperation: projectFileOperations.restore, + operationId: 'restoreProjectFile', + summary: 'Restore Project File', + description: `Restore an archived Project file. If its former folder is unavailable, restore it to the Project root with an available name. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Metadata for the restored file.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2RestoreProjectFileContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2RestoreProjectFileContract.query, + body: documentedSchema( + v2RestoreProjectFileContract.body, + 'RestoreProjectFileRequest', + 'Restore Project file request', + 'An empty object; the file is identified by its path parameters.' + ), + response: documentedSchema( + v2RestoreProjectFileContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), + + defineOpenApiRoute( + v2CreateProjectFileContract, + { + applicationOperation: projectFileOperations.create, + operationId: 'createProjectFile', + summary: 'Create Project File', + description: `Create a shared Project file from inline text or base64 bytes. Names are exact; an existing sibling name returns a conflict. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { + description: 'The created file and its content revision.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CreateProjectFileContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: v2CreateProjectFileContract.query, + body: documentedSchema( + v2CreateProjectFileContract.body, + 'CreateProjectFileRequest', + 'Create Project file request', + 'Name, content, encoding, and containing folder within the Project.' + ), + response: documentedSchema( + v2CreateProjectFileContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), + defineOpenApiRoute( + v2ReadProjectFileContentContract, + { + applicationOperation: projectFileOperations.readContent, + operationId: 'readProjectFileContent', + summary: 'Read Project File Source', + description: `Read the stored source bytes of a Project file. Generated documents return their generation source; rendered downloads are separate. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'The current stored content bytes.', + headers: ['Content-Type', 'Content-Disposition', 'Content-Length'], + contentTypes: ['application/octet-stream'], + }, + }, + { + params: documentedSchema( + v2ReadProjectFileContentContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2ReadProjectFileContentContract.query, + } + ), + defineOpenApiRoute( + v2UpdateProjectFileContentContract, + { + applicationOperation: projectFileOperations.updateContent, + operationId: 'updateProjectFileContent', + summary: 'Replace Project File Content', + description: `Replace the complete content of a Project file. Supply expectedRevision to reject a stale edit with 409. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { + description: 'The updated file and its content revision.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2UpdateProjectFileContentContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2UpdateProjectFileContentContract.query, + body: documentedSchema( + v2UpdateProjectFileContentContract.body, + 'UpdateProjectFileContentRequest', + 'Project content replacement', + 'Complete replacement bytes and an optional optimistic concurrency revision.' + ), + response: documentedSchema( + v2UpdateProjectFileContentContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), + defineOpenApiRoute( + v2ListProjectFilesContract, + { + applicationOperation: projectFileOperations.list, + operationId: 'listProjectFiles', + summary: 'List Project Files', + description: `List shared Project files with cursor pagination. Use scope=archived to find archived files. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'A page of Project files.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2ListProjectFilesContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: documentedSchema( + v2ListProjectFilesContract.query, + 'ListProjectFilesQuery', + 'Project file list query', + 'Filters, sorting, and pagination within one Project.' + ), + response: documentedSchema( + v2ListProjectFilesContract.response.schema, + 'V2ProjectFileListResponse', + 'Project file list response', + 'A page of files owned by the requested Project.' + ), + } + ), + defineOpenApiRoute( + v2GetProjectFileMetadataContract, + { + applicationOperation: projectFileOperations.readMetadata, + operationId: 'getProjectFileMetadata', + summary: 'Get Project File Metadata', + description: `Get an active file's metadata and Project ownership. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'Project file metadata.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetProjectFileMetadataContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2GetProjectFileMetadataContract.query, + response: documentedSchema( + v2GetProjectFileMetadataContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts b/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts new file mode 100644 index 00000000000..b4f2c8acd91 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts @@ -0,0 +1,16 @@ +import { projectFileFolderOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-folders' +import { projectFileUploadOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-uploads' +import { projectFileVersionOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-versions' +import { projectFileOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-files' + +/** Keep runtime contracts and launch-ready definitions while Projects remains unreleased. */ +export const unpublishedProjectFileDocumentation = { + reason: + 'Project files are unreleased. Publish these routes and the Projects CLI documentation together at launch.', + routes: [ + ...projectFileOpenApiRoutes, + ...projectFileVersionOpenApiRoutes, + ...projectFileFolderOpenApiRoutes, + ...projectFileUploadOpenApiRoutes, + ], +} diff --git a/apps/sim/lib/api/contracts/v2/project-file-folders.ts b/apps/sim/lib/api/contracts/v2/project-file-folders.ts new file mode 100644 index 00000000000..a923438bf80 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-folders.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' +import { noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { + createProjectFileFolderBodySchema, + listProjectFileFoldersQuerySchema, + projectFileFolderRecordSchema, + updateProjectFileFolderBodySchema, +} from '@/lib/api/contracts/project-file-folders' +import { restoreProjectFileFolderResponseSchema } from '@/lib/api/contracts/project-file-lifecycle' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2ProjectFilesParamsSchema } from '@/lib/api/contracts/v2/project-files' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2ProjectFileFolderParamsSchema = v2ProjectFilesParamsSchema.extend({ + folderId: nonEmptyIdSchema.describe('Folder identifier within the Project.'), +}) +export type V2ProjectFileFolderParams = z.input + +export const v2ProjectFileFolderSchema = projectFileFolderRecordSchema.extend({ + id: nonEmptyIdSchema.describe('Stable folder identifier.'), + owner: projectFileFolderRecordSchema.shape.owner + .extend({ + entityType: z.literal('project').describe('The folder is owned by a Project.'), + entityId: nonEmptyIdSchema.describe('Identifier of the owning Project.'), + }) + .strict() + .describe('Canonical Project owner of the folder.'), + name: z.string().describe('Folder name.'), + parentId: nonEmptyIdSchema.nullable().describe('Parent folder identifier, or null at the root.'), + path: z.string().describe('Display path with slash characters in folder names escaped.'), + sortOrder: z.number().describe('Position within its parent folder.'), + userId: nonEmptyIdSchema.describe('Creator or successor identifier.'), + createdAt: z.iso.datetime().describe('Time the folder was created.'), + updatedAt: z.iso.datetime().describe('Time the folder was last changed.'), + deletedAt: z.iso.datetime().nullable().describe('Archive time, or null for an active folder.'), +}) +export type V2ProjectFileFolder = z.output + +export const v2ListProjectFileFoldersQuerySchema = listProjectFileFoldersQuerySchema + .extend({ + scope: listProjectFileFoldersQuerySchema.shape.scope.describe('Folder lifecycle scope.'), + }) + .strict() +export type V2ListProjectFileFoldersQuery = z.output + +export const v2CreateProjectFileFolderBodySchema = createProjectFileFolderBodySchema + .extend({ + name: createProjectFileFolderBodySchema.shape.name.describe('Name for the new folder.'), + parentId: nonEmptyIdSchema + .nullable() + .optional() + .describe('Parent folder identifier; omit or use null for the root.'), + }) + .strict() +export type V2CreateProjectFileFolderBody = z.input + +export const v2UpdateProjectFileFolderBodySchema = updateProjectFileFolderBodySchema + .extend({ + name: updateProjectFileFolderBodySchema.shape.name.describe( + 'New folder name; omit to leave unchanged.' + ), + parentId: nonEmptyIdSchema + .nullable() + .optional() + .describe( + 'New parent folder identifier; null moves to the root, omission leaves the parent unchanged.' + ), + sortOrder: updateProjectFileFolderBodySchema.shape.sortOrder.describe( + 'New manual position; omit to leave unchanged.' + ), + }) + .strict() +export type V2UpdateProjectFileFolderBody = z.input + +export const v2RestoreProjectFileFolderResponseSchema = + restoreProjectFileFolderResponseSchema.extend({ + folder: v2ProjectFileFolderSchema.describe('Restored Project folder.'), + }) +export type V2RestoreProjectFileFolderResponse = z.output< + typeof v2RestoreProjectFileFolderResponseSchema +> + +export const v2ListProjectFileFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/folders', + params: v2ProjectFilesParamsSchema, + query: v2ListProjectFileFoldersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ProjectFileFolderSchema, { paged: false }), + }, +}) + +export const v2CreateProjectFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/folders', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2CreateProjectFileFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileFolderSchema), status: 201 }, +}) + +export const v2UpdateProjectFileFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/folders/[folderId]', + params: v2ProjectFileFolderParamsSchema, + query: noInputSchema, + body: v2UpdateProjectFileFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileFolderSchema) }, +}) + +export const v2RestoreProjectFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/folders/[folderId]/restore', + params: v2ProjectFileFolderParamsSchema, + query: noInputSchema, + body: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2RestoreProjectFileFolderResponseSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts b/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts new file mode 100644 index 00000000000..68f782f1177 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts @@ -0,0 +1,77 @@ +import type { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { + archiveProjectFileItemsBodySchema, + archiveProjectFileItemsResponseSchema, + moveProjectFileItemsResponseSchema, +} from '@/lib/api/contracts/project-file-lifecycle' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2MoveFileItemsBodySchema, v2RenameFileBodySchema } from '@/lib/api/contracts/v2/files' +import { + v2ProjectFileParamsSchema, + v2ProjectFileSchema, + v2ProjectFilesParamsSchema, +} from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2RenameProjectFileBodySchema = v2RenameFileBodySchema.omit({ workspaceId: true }) +export type V2RenameProjectFileBody = z.input + +export const v2ArchiveProjectFileItemsBodySchema = archiveProjectFileItemsBodySchema.safeExtend({ + fileIds: archiveProjectFileItemsBodySchema.shape.fileIds.describe( + 'Identifiers of the files to archive.' + ), + folderIds: archiveProjectFileItemsBodySchema.shape.folderIds.describe( + 'Identifiers of folders to archive recursively, including their files and descendants.' + ), +}) +export type V2ArchiveProjectFileItemsBody = z.input + +export const v2MoveProjectFileItemsBodySchema = archiveProjectFileItemsBodySchema.safeExtend({ + fileIds: archiveProjectFileItemsBodySchema.shape.fileIds.describe( + 'Identifiers of the files to move.' + ), + folderIds: archiveProjectFileItemsBodySchema.shape.folderIds.describe( + 'Identifiers of folders to move with their contents.' + ), + targetFolderPath: v2MoveFileItemsBodySchema.shape.targetFolderPath.describe( + 'Existing destination folder path within the Project. Omit to move items to the Project root.' + ), +}) +export type V2MoveProjectFileItemsBody = z.input + +export const v2RenameProjectFileContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: v2RenameProjectFileBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema) }, +}) + +export const v2MoveProjectFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/move', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2MoveProjectFileItemsBodySchema, + response: { mode: 'json', schema: v2DataResponse(moveProjectFileItemsResponseSchema) }, +}) + +export const v2ArchiveProjectFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/archive', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2ArchiveProjectFileItemsBodySchema, + response: { mode: 'json', schema: v2DataResponse(archiveProjectFileItemsResponseSchema) }, +}) + +export const v2RestoreProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/restore', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-uploads.ts b/apps/sim/lib/api/contracts/v2/project-file-uploads.ts new file mode 100644 index 00000000000..108c9403236 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-uploads.ts @@ -0,0 +1,98 @@ +import type { z } from 'zod' +import { noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { createProjectFileUploadBodySchema } from '@/lib/api/contracts/project-file-uploads' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CreateFileUploadDataSchema, v2FileUploadSchema } from '@/lib/api/contracts/v2/files' +import { + v2ProjectFileSchema, + v2ProjectFilesParamsSchema, +} from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const v2ProjectFileUploadParamsSchema = v2ProjectFilesParamsSchema.extend({ + uploadId: nonEmptyIdSchema.describe('Upload session identifier within the Project.'), +}) +export type V2ProjectFileUploadParams = z.input + +export const v2CreateProjectFileUploadBodySchema = createProjectFileUploadBodySchema.extend({ + folderId: createProjectFileUploadBodySchema.shape.folderId.describe( + 'Destination folder identifier; omit or use null for the Project root.' + ), + folderPath: createProjectFileUploadBodySchema.shape.folderPath.describe( + 'Canonical destination folder path. Specify either folderId or folderPath, not both.' + ), +}) +export type V2CreateProjectFileUploadBody = z.input + +export const v2ProjectFileUploadSchema = v2FileUploadSchema + .extend({ + file: v2ProjectFileSchema + .nullable() + .describe( + 'Registered Project file after finalization, or null before registration or after archival.' + ), + }) + .meta({ id: 'V2ProjectFileUpload', title: 'Project file upload session' }) +export type V2ProjectFileUpload = z.output + +export const v2CreateProjectFileUploadDataSchema = v2CreateFileUploadDataSchema + .extend({ + session: v2ProjectFileUploadSchema.describe('New Project upload session.'), + }) + .meta({ id: 'V2CreateProjectFileUploadData', title: 'Create Project file upload data' }) +export type V2CreateProjectFileUploadData = z.output + +export const v2CreateProjectFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2CreateProjectFileUploadBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CreateProjectFileUploadDataSchema), + status: 201, + }, +}) + +export const v2GetProjectFileUploadContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', + params: v2ProjectFileUploadParamsSchema, + query: noInputSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileUploadSchema) }, +}) + +export const v2AbortProjectFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', + params: v2ProjectFileUploadParamsSchema, + query: noInputSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileUploadSchema) }, +}) + +export const v2CompleteProjectFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete', + params: v2ProjectFileUploadParamsSchema, + query: noInputSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileUploadSchema) }, +}) + +export const v2GetProjectFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts', + params: v2ProjectFileUploadParamsSchema, + query: noInputSchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-versions.ts b/apps/sim/lib/api/contracts/v2/project-file-versions.ts new file mode 100644 index 00000000000..5c6fb93d17c --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-versions.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' +import { noInputSchema, versionNumberPathSchema } from '@/lib/api/contracts/primitives' +import { + deleteProjectFileVersionResponseSchema, + listProjectFileVersionsQuerySchema, + projectFileVersionSchema, + revertProjectFileVersionBodySchema, +} from '@/lib/api/contracts/project-file-versions' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { writtenFileRevisionSchema } from '@/lib/api/contracts/v2/files' +import { + v2ProjectFileParamsSchema, + v2ProjectFileSchema, +} from '@/lib/api/contracts/v2/project-files' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2ProjectFileVersionParamsSchema = v2ProjectFileParamsSchema.extend({ + version: versionNumberPathSchema.describe('Version number.'), +}) +export type V2ProjectFileVersionParams = z.input +export const v2RevertProjectFileVersionResultSchema = z.object({ + reverted: z.boolean().describe('False if the selected version is already current.'), + file: v2ProjectFileSchema, + version: projectFileVersionSchema.describe('The current version after the revert.'), + revision: writtenFileRevisionSchema, +}) +export type V2RevertProjectFileVersionResult = z.output< + typeof v2RevertProjectFileVersionResultSchema +> + +export const v2ListProjectFileVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions', + params: v2ProjectFileParamsSchema, + query: listProjectFileVersionsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(projectFileVersionSchema) }, +}) +export const v2GetProjectFileVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]', + params: v2ProjectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(projectFileVersionSchema) }, +}) +export const v2ReadProjectFileVersionContentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content', + params: v2ProjectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'binary' }, +}) +export const v2RevertProjectFileVersionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert', + params: v2ProjectFileVersionParamsSchema, + query: noInputSchema, + body: revertProjectFileVersionBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2RevertProjectFileVersionResultSchema) }, +}) +export const v2DeleteProjectFileVersionContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]', + params: v2ProjectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(deleteProjectFileVersionResponseSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-files.ts b/apps/sim/lib/api/contracts/v2/project-files.ts new file mode 100644 index 00000000000..901c6db07aa --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-files.ts @@ -0,0 +1,128 @@ +import { z } from 'zod' +import { isCanonicalBase64, noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { projectFileRecordSchema } from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CreateFileBodySchema, + v2FileSchema, + v2ListFilesQuerySchema, + v2UpdateFileContentBodySchema, + writtenFileRevisionSchema, +} from '@/lib/api/contracts/v2/files' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, +} from '@/lib/api/contracts/v2/shared' + +export const v2ProjectFilesParamsSchema = z.object({ + projectId: nonEmptyIdSchema.describe('Project identifier.'), +}) +export type V2ProjectFilesParams = z.input + +export const v2ProjectFileParamsSchema = v2ProjectFilesParamsSchema.extend({ + fileId: nonEmptyIdSchema.describe('File identifier within the Project.'), +}) +export type V2ProjectFileParams = z.input + +export const v2ProjectFileSchema = v2FileSchema + .omit({ uploadedByEmail: true, webUrl: true }) + .extend({ + owner: projectFileRecordSchema.shape.owner + .extend({ + entityType: z.literal('project').describe('The file owner is a Project.'), + entityId: nonEmptyIdSchema.describe('Identifier of the owning Project.'), + }) + .describe('Canonical owner of the shared file.'), + uploadedBy: nonEmptyIdSchema.describe('Creator or successor identifier.'), + revision: writtenFileRevisionSchema, + folderPath: v2FileSchema.shape.folderPath.describe( + 'Canonical containing-folder path. `/` is the Project root.' + ), + }) + .meta({ id: 'V2ProjectFile', title: 'Project file' }) +export type V2ProjectFile = z.output + +export const v2ListProjectFilesQuerySchema = v2ListFilesQuerySchema + .omit({ workspaceId: true }) + .extend(v2PaginationFields({ max: 1000, fallback: 100, description: 'Maximum files per page.' })) +export type V2ListProjectFilesQuery = z.output + +export const v2ListProjectFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files', + params: v2ProjectFilesParamsSchema, + query: v2ListProjectFilesQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2ProjectFileSchema) }, +}) + +export const v2GetProjectFileMetadataContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/metadata', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema) }, +}) + +function validateContentEncoding( + value: { content: string; encoding: 'utf-8' | 'base64' }, + context: z.RefinementCtx +) { + if (value.encoding === 'base64' && !isCanonicalBase64(value.content)) { + context.addIssue({ code: 'custom', path: ['content'], message: 'content must be valid base64' }) + } +} + +export const v2CreateProjectFileBodySchema = z + .object({ + name: v2CreateFileBodySchema.shape.name, + contentType: v2CreateFileBodySchema.shape.contentType, + content: v2CreateFileBodySchema.shape.content.describe( + 'Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 70 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The decoded-file limit is 50 MiB. Use an upload session for larger payloads.' + ), + encoding: v2CreateFileBodySchema.shape.encoding, + folderPath: v2CreateFileBodySchema.shape.folderPath.describe( + 'Canonical containing-folder path. Omit for the Project root.' + ), + }) + .strict() + .superRefine(validateContentEncoding) +export type V2CreateProjectFileBody = z.input + +export const v2UpdateProjectFileContentBodySchema = z + .object({ + content: v2UpdateFileContentBodySchema.shape.content.describe( + 'Complete replacement content for the file. The complete JSON request is capped at 70 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The decoded-file limit is 50 MiB.' + ), + encoding: v2UpdateFileContentBodySchema.shape.encoding, + expectedRevision: v2UpdateFileContentBodySchema.shape.expectedRevision, + }) + .strict() + .superRefine(validateContentEncoding) +export type V2UpdateProjectFileContentBody = z.input + +export const v2CreateProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2CreateProjectFileBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema), status: 201 }, +}) + +export const v2ReadProjectFileContentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/content', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + response: { mode: 'binary' }, +}) + +export const v2UpdateProjectFileContentContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/projects/[projectId]/files/[fileId]/content', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: v2UpdateProjectFileContentBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/workspace-file-versions.ts b/apps/sim/lib/api/contracts/workspace-file-versions.ts new file mode 100644 index 00000000000..98b1f258bd7 --- /dev/null +++ b/apps/sim/lib/api/contracts/workspace-file-versions.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' +import { noInputSchema, versionNumberPathSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2FileVersionSchema, + v2ListFileVersionsQuerySchema, + v2RevertFileVersionBodySchema, +} from '@/lib/api/contracts/v2/file-versions' +import { writtenFileRevisionSchema } from '@/lib/api/contracts/v2/files' +import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' + +const workspaceFileVersionParamsSchema = workspaceFileParamsSchema.extend({ + version: versionNumberPathSchema, +}) +const listWorkspaceFileVersionsQuerySchema = v2ListFileVersionsQuerySchema.omit({ + workspaceId: true, +}) +const listWorkspaceFileVersionsResponseSchema = z.object({ + versions: z.array(v2FileVersionSchema), + revision: writtenFileRevisionSchema, + nextCursor: z.string().nullable(), +}) +const revertWorkspaceFileVersionBodySchema = v2RevertFileVersionBodySchema + .omit({ workspaceId: true }) + .extend({ expectedRevision: z.string().min(1).max(1024).optional() }) +const revertWorkspaceFileVersionResponseSchema = z.object({ + reverted: z.boolean(), + revision: writtenFileRevisionSchema, +}) + +export const listWorkspaceFileVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/files/[fileId]/versions', + params: workspaceFileParamsSchema, + query: listWorkspaceFileVersionsQuerySchema, + response: { mode: 'json', schema: listWorkspaceFileVersionsResponseSchema }, +}) +export const downloadWorkspaceFileVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/files/[fileId]/versions/[version]/content', + params: workspaceFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'binary' }, +}) +export const revertWorkspaceFileVersionContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/files/[fileId]/versions/[version]/revert', + params: workspaceFileVersionParamsSchema, + query: noInputSchema, + body: revertWorkspaceFileVersionBodySchema, + response: { mode: 'json', schema: revertWorkspaceFileVersionResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 1ccf7436c4f..3dc8ba11c6c 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -182,8 +182,6 @@ export const extractWorkspaceFileResponseSchema = workspaceFileSuccessSchema.ext skippedCount: z.number().int().nonnegative(), }) -export type ExtractWorkspaceFileResponse = z.output - export const listWorkspaceFilesContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/files', diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index d1d08288c99..ab5c3a6dab8 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -244,9 +244,15 @@ export function sortDirection(order: ListSortOrder): typeof asc { * On a paginated list these are the keyset's keys; on a single-page list they * are just the sort plus its tiebreaker. */ -export function listOrderBy(keys: readonly SQLWrapper[], order: ListSortOrder): SQL[] { - const direction = sortDirection(order) - return keys.map((key) => direction(key)) +export function listOrderBy( + keys: readonly SQLWrapper[], + order: ListSortOrder | readonly ListSortOrder[] +): SQL[] { + if (typeof order !== 'string' && order.length !== keys.length) + throw new Error('Sort directions must match key columns') + return keys.map((key, index) => + sortDirection(typeof order === 'string' ? order : order[index])(key) + ) } /** `ORDER BY` for a keyset, each key in its own {@link KeysetKey.direction} or the requested one. */ @@ -332,9 +338,10 @@ export function keysetPage( export function keysetAfter( keys: readonly KeysetKey[], values: CursorKey[], - order: ListSortOrder + order: ListSortOrder | readonly ListSortOrder[] ): SQL | null { - if (values.length !== keys.length) return null + if (values.length !== keys.length || (typeof order !== 'string' && order.length !== keys.length)) + return null const bound: SQL[] = [] for (const [i, key] of keys.entries()) { @@ -346,12 +353,16 @@ export function keysetAfter( const clauses = keys.map((key, i) => and( ...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), - (keyDirection(key, order) === 'asc' ? gt : lt)(key.expr, bound[i]) + (keyDirection(key, typeof order === 'string' ? order : order[i]) === 'asc' ? gt : lt)( + key.expr, + bound[i] + ) ) ) const after = or(...clauses) if (!after) return null if (keys.length === 1 || !keys[0].boundsScan) return after - const atOrBeyond = keyDirection(keys[0], order) === 'asc' ? gte : lte + const atOrBeyond = + keyDirection(keys[0], typeof order === 'string' ? order : order[0]) === 'asc' ? gte : lte return and(atOrBeyond(keys[0].expr, bound[0]), after) ?? null } diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index a585e7f3629..a52f2a6b33d 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -185,6 +185,37 @@ import { v2RemovePermissionGroupMemberContract, v2UpdatePermissionGroupContract, } from '@/lib/api/contracts/v2/permission-groups' +import { + v2CreateProjectFileFolderContract, + v2ListProjectFileFoldersContract, + v2RestoreProjectFileFolderContract, + v2UpdateProjectFileFolderContract, +} from '@/lib/api/contracts/v2/project-file-folders' +import { + v2ArchiveProjectFileItemsContract, + v2MoveProjectFileItemsContract, + v2RenameProjectFileContract, + v2RestoreProjectFileContract, +} from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + v2AbortProjectFileUploadContract, + v2CompleteProjectFileUploadContract, + v2CreateProjectFileUploadContract, + v2GetProjectFileUploadContract, + v2GetProjectFileUploadPartUrlsContract, +} from '@/lib/api/contracts/v2/project-file-uploads' +import { + v2DeleteProjectFileVersionContract, + v2GetProjectFileVersionContract, + v2ListProjectFileVersionsContract, + v2RevertProjectFileVersionContract, +} from '@/lib/api/contracts/v2/project-file-versions' +import { + v2CreateProjectFileContract, + v2GetProjectFileMetadataContract, + v2ListProjectFilesContract, + v2UpdateProjectFileContentContract, +} from '@/lib/api/contracts/v2/project-files' import { v2CreateSandboxContract, v2DeleteSandboxContract, @@ -372,6 +403,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.DELETE ), }, + abortProjectFileUpload: { + contract: v2AbortProjectFileUploadContract, + summary: 'Abort Project file upload', + description: + 'Abort a pending Project upload and schedule its unregistered bytes for cleanup. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route').then( + (route) => route.DELETE + ), + }, activateWorkflowVersion: { contract: v2ActivateWorkflowVersionContract, summary: 'Activate workflow version', @@ -448,6 +490,15 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/workflows/[workflowId]/variables/route').then((route) => route.PATCH), }, + archiveProjectFileItems: { + contract: v2ArchiveProjectFileItemsContract, + summary: 'Archive Project File Items', + description: + 'Archive selected files and folders. Folder contents are archived recursively and remain recoverable until retention removes them. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/archive/route').then((route) => route.POST), + }, bulkAddPermissionGroupMembers: { contract: v2BulkAddPermissionGroupMembersContract, summary: 'Bulk add permission group members', @@ -611,6 +662,17 @@ export const V2_MCP_OPERATIONS = { '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route' ).then((route) => route.POST), }, + completeProjectFileUpload: { + contract: v2CompleteProjectFileUploadContract, + summary: 'Complete Project file upload', + description: + 'Finalize verified bytes and atomically register one Project file. Retrying completion returns the same file without billing twice. Current edit access is checked again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route').then( + (route) => route.POST + ), + }, completeTableImport: { contract: v2CompleteTableImportContract, summary: 'Complete table import upload', @@ -767,6 +829,33 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + createProjectFile: { + contract: v2CreateProjectFileContract, + summary: 'Create Project File', + description: + 'Create a shared Project file from inline text or base64 bytes. Names are exact; an existing sibling name returns a conflict. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/route').then((route) => route.POST), + }, + createProjectFileFolder: { + contract: v2CreateProjectFileFolderContract, + summary: 'Create Project file folder', + description: + 'Create a folder under an existing parent, or at the Project root when parentId is omitted. Sibling names must be unique. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/folders/route').then((route) => route.POST), + }, + createProjectFileUpload: { + contract: v2CreateProjectFileUploadContract, + summary: 'Create Project file upload', + description: + 'Create a resumable Project file upload. The file is registered only after the signed transfer and completion succeed. Creation returns an upload-token. Send that token with the original API credential on subsequent control requests. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/route').then((route) => route.POST), + }, createSandbox: { contract: v2CreateSandboxContract, summary: 'Create sandbox', @@ -1017,6 +1106,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.DELETE ), }, + deleteProjectFileVersion: { + contract: v2DeleteProjectFileVersionContract, + summary: 'Delete Project File Version', + description: + 'Permanently remove one superseded Project file version from history. Deleting the current version returns `409`; other versions and the current file remain available. Stored-object cleanup is retried asynchronously when needed. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route').then( + (route) => route.DELETE + ), + }, deleteSandbox: { contract: v2DeleteSandboxContract, summary: 'Delete sandbox', @@ -1443,6 +1543,50 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + getProjectFileMetadata: { + contract: v2GetProjectFileMetadataContract, + summary: 'Get Project File Metadata', + description: + "Get an active file's metadata and Project ownership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route').then( + (route) => route.GET + ), + }, + getProjectFileUpload: { + contract: v2GetProjectFileUploadContract, + summary: 'Get Project file upload', + description: + 'Read the current state of a Project upload, including its file after completion. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route').then( + (route) => route.GET + ), + }, + getProjectFileUploadPartUrls: { + contract: v2GetProjectFileUploadPartUrlsContract, + summary: 'Get Project file upload part URLs', + description: + 'Request signed multipart transfer URLs for an active Project upload. Send exactly the returned transfer headers when uploading each part. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route').then( + (route) => route.POST + ), + }, + getProjectFileVersion: { + contract: v2GetProjectFileVersionContract, + summary: 'Get Project File Version', + description: + 'Get metadata and author attribution for a recorded Project file version. Missing or permanently removed versions return `404`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route').then( + (route) => route.GET + ), + }, getRowEnrichment: { contract: v2GetRowEnrichmentContract, summary: 'Get row group run', @@ -2000,6 +2144,35 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + listProjectFileFolders: { + contract: v2ListProjectFileFoldersContract, + summary: 'List Project file folders', + description: + 'List the Project folder tree with stable identifiers. Use scope=archived to find folders eligible for restore. Returns one page; `nextCursor` is always null. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/folders/route').then((route) => route.GET), + }, + listProjectFiles: { + contract: v2ListProjectFilesContract, + summary: 'List Project Files', + description: + 'List shared Project files with cursor pagination. Use scope=archived to find archived files. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/route').then((route) => route.GET), + }, + listProjectFileVersions: { + contract: v2ListProjectFileVersionsContract, + summary: 'List Project File Versions', + description: + 'List recorded versions of a shared Project file, newest first by default. Retention follows the Project payer and preserves the newest ten versions; removed versions leave gaps in numbering. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/route').then( + (route) => route.GET + ), + }, listSandboxes: { contract: v2ListSandboxesContract, summary: 'List sandboxes', @@ -2197,6 +2370,15 @@ export const V2_MCP_OPERATIONS = { 'Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/files/move/route').then((route) => route.POST), }, + moveProjectFileItems: { + contract: v2MoveProjectFileItemsContract, + summary: 'Move Project File Items', + description: + 'Move selected files and folders into an existing folder within the same Project. Folder contents move with their parent. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/move/route').then((route) => route.POST), + }, moveTables: { contract: v2MoveTablesContract, summary: 'Move tables and folders', @@ -2392,6 +2574,15 @@ export const V2_MCP_OPERATIONS = { 'Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/files/[fileId]/route').then((route) => route.PATCH), }, + renameProjectFile: { + contract: v2RenameProjectFileContract, + summary: 'Rename Project File', + description: + 'Rename a shared Project file while retaining its identity and history. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/route').then((route) => route.PATCH), + }, replaceWorkflowChatDeployment: { contract: v2ReplaceWorkflowChatDeploymentContract, summary: 'Create or replace workflow chat deployment', @@ -2456,6 +2647,28 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/knowledge/[knowledgeBaseId]/restore/route').then((route) => route.POST), }, + restoreProjectFile: { + contract: v2RestoreProjectFileContract, + summary: 'Restore Project File', + description: + 'Restore an archived Project file. If its former folder is unavailable, restore it to the Project root with an available name. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/restore/route').then( + (route) => route.POST + ), + }, + restoreProjectFileFolder: { + contract: v2RestoreProjectFileFolderContract, + summary: 'Restore Project file folder', + description: + 'Restore an archived folder and the files and subfolders archived with it. Find identifiers with List Project File Folders using scope=archived. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route').then( + (route) => route.POST + ), + }, restoreTable: { contract: v2RestoreTableContract, summary: 'Restore table', @@ -2499,6 +2712,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + revertProjectFileVersion: { + contract: v2RevertProjectFileVersionContract, + summary: 'Revert Project File Version', + description: + 'Make an earlier version current by recording its source bytes as a new revert version. Reverting to the current version is a no-op. Use expectedRevision to reject changes made since the last read; a stale revision or concurrent edit returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route' + ).then((route) => route.POST), + }, revertWorkflowVersion: { contract: v2RevertWorkflowVersionContract, summary: 'Revert workflow to version', @@ -2808,6 +3032,28 @@ export const V2_MCP_OPERATIONS = { (route) => route.PATCH ), }, + updateProjectFileContent: { + contract: v2UpdateProjectFileContentContract, + summary: 'Replace Project File Content', + description: + 'Replace the complete content of a Project file. Supply expectedRevision to reject a stale edit with 409. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/content/route').then( + (route) => route.PUT + ), + }, + updateProjectFileFolder: { + contract: v2UpdateProjectFileFolderContract, + summary: 'Update Project file folder', + description: + 'Rename, move, or reorder a folder while retaining its identity and descendants. Omitted fields stay unchanged; parentId=null moves the folder to the root. Cross-owner parents and cycles are rejected. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/folders/[folderId]/route').then( + (route) => route.PATCH + ), + }, updateRowsByFilter: { contract: v2UpdateRowsByFilterContract, summary: 'Update rows by filter', diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.test.ts b/apps/sim/lib/api/server/routes/internal-binary-route.test.ts new file mode 100644 index 00000000000..d71c7aeb1fb --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-binary-route.test.ts @@ -0,0 +1,51 @@ +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { defineRouteContract } from '@/lib/api/contracts' +import { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route' +import { + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes/internal-json-route' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const contract = defineRouteContract({ + method: 'GET', + path: '/api/test/download', + response: { mode: 'binary' }, +}) +const operation = { id: 'test.download', capability: 'none' } as const + +describe('internal download HEAD authorization', () => { + it.each([true, false])( + 'checks access without starting a download when allowed=%s', + async (allowed) => { + const handler = defineInternalBinaryRoute({ + contract, + operation, + headSafe: false, + auth: { authenticate: async () => createSessionPrincipal() }, + rateLimit: internalRateLimits.none({ + reason: 'No network admission needed in this fixture', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async authorize() { + if (!allowed) throw new OrchestrationError('forbidden', 'Access denied') + }, + async execute(): Promise { + throw new Error('HEAD must not reach download side effects') + }, + }, + present: (body) => ({ body, contentType: 'application/octet-stream' }), + }) + const response = await handler( + new NextRequest('http://localhost/api/test/download', { method: 'HEAD' }) + ) + expect(response.status).toBe(allowed ? 200 : 403) + if (allowed) expect(await response.text()).toBe('') + } + ) +}) diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts index 44ed6713897..ed5729c45f1 100644 --- a/apps/sim/lib/api/server/routes/internal-binary-route.ts +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -20,7 +20,7 @@ import type { JsonNextRouteHandler, JsonRouteContext, } from '@/lib/api/server/routes/types' -import type { ParsedRequest } from '@/lib/api/server/validation' +import type { ParsedRequest, ParseRequestOptions } from '@/lib/api/server/validation' import { parseRequest } from '@/lib/api/server/validation' import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -53,6 +53,8 @@ interface InternalBinaryRouteOptions< auth: typeof internalSessionAuth rateLimit: InternalBinaryRateLimitPolicy errorPolicy: InternalErrorPolicy + parseOptions?: Pick + headSafe?: boolean onSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise } @@ -74,6 +76,10 @@ export function defineInternalBinaryRoute< options.useCase.operation ) + if (options.headSafe === false && typeof options.useCase.authorize !== 'function') { + throw new Error('A binary route with headSafe: false requires an authorize phase') + } + const wrapped = withRouteHandler( async (request, context) => { if (!methodMatchesContract(request.method, options.contract.method)) { @@ -94,11 +100,24 @@ export function defineInternalBinaryRoute< setRequestAuth(describePrincipalAuth(principal)) await options.rateLimit.enforce(request, principal) - const parsed = await parseRequest(options.contract, request, context ?? {}) + const parsed = await parseRequest( + options.contract, + request, + context ?? {}, + options.parseOptions + ) if (!parsed.success) return responseWithRequestId(parsed.response) try { const input = options.mapInput(parsed.data) + if (request.method === 'HEAD' && options.headSafe === false) { + if (!options.useCase.authorize) throw new Error('Missing HEAD authorization phase') + await options.useCase.authorize({ principal, input, request }) + return new NextResponse(null, { + status: successStatus, + headers: { 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' }, + }) + } const result = await options.useCase.execute({ principal, input, request }) const descriptor = await options.present(result) await options.onSuccess?.({ principal, input, result }) @@ -125,7 +144,14 @@ export function defineInternalBinaryRoute< } ) - return async (request, context) => wrapped(request, context) + return async (request, context) => { + const response = await wrapped(request, context) + if (response.status >= 400) { + response.headers.set('Cache-Control', 'private, no-store') + response.headers.set('X-Content-Type-Options', 'nosniff') + } + return response + } } function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextResponse { diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 8429d8f70e8..2c96433066e 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -8,6 +8,7 @@ vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock) const mockEnforceUserRateLimit = rateLimiterMockFns.mockEnforceUserRateLimit import { defineRouteContract } from '@/lib/api/contracts' +import { completeProjectFileUploadContract } from '@/lib/api/contracts/project-file-uploads' import { defineInternalJsonRoute, InternalUnauthenticatedError, @@ -23,7 +24,7 @@ class TestLockedError extends HttpError { readonly statusCode = 423 } -const operation = { id: 'test.read' } as const +const operation = { id: 'test.read', capability: 'none' } as const const auth = { authenticate: vi.fn(async () => ({ kind: 'session' as const, @@ -151,7 +152,7 @@ describe('defineInternalJsonRoute', () => { mapInput: ({ body }) => body.outcome, useCase: { operation, - async execute({ input }) { + async execute({ input }: { input: 'success' | 'failure' }): Promise<{ value: string }> { if (input === 'failure') throw new Error('Unhandled') return { value: 'ok' } }, @@ -233,7 +234,7 @@ describe('defineInternalJsonRoute', () => { }, useCase: { operation, - async execute({ input }) { + async execute({ input }: { input: string }): Promise<{ value: string }> { events.push('use-case') return { value: input } }, @@ -330,3 +331,27 @@ describe('defineInternalJsonRoute', () => { await expect(response.json()).resolves.toEqual({ error: 'Internal server error' }) }) }) + +describe('optional upload completion JSON', () => { + it.each([undefined, '{}'])('accepts an empty completion body: %s', async (body) => { + const handler = defineInternalJsonRoute({ + contract: defineRouteContract({ + method: 'POST', + path: '/api/test/complete', + body: completeProjectFileUploadContract.body, + response: { mode: 'json', schema: z.object({ completed: z.boolean() }) }, + }), + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Parser regression' }), + errorPolicy: internalOrchestrationErrorPolicy, + parseOptions: { optionalJsonBody: true }, + mapInput: () => undefined, + useCase: { operation, execute: async () => ({ completed: true }) }, + }) + const response = await handler( + new NextRequest('http://localhost/api/test/complete', { method: 'POST', body }) + ) + expect(response.status).toBe(200) + }) +}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index a3890a53fde..d5703f3ad30 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -238,7 +238,7 @@ export interface InternalJsonResponseFinalization { type InternalJsonParseOptions = Pick< ParseRequestOptions, - 'maxBodyBytes' | 'validationErrorResponse' + 'maxBodyBytes' | 'validationErrorResponse' | 'optionalJsonBody' > /** diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.test.ts b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts index 4833cfc2cf3..f797687a9b1 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts @@ -13,7 +13,7 @@ import { v2ApiKeyAuth, v2OrchestrationErrorPolicy, v2RateLimits } from '@/lib/ap import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth' import { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' -const operation = { id: 'widgets.download' } as const +const operation = { id: 'widgets.download', capability: 'none' } as const const principal: PersonalApiKeyPrincipal = { kind: 'personal_api_key', userId: 'user-1', @@ -24,6 +24,7 @@ const auth = { rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], rateLimitSubscription: null, keyType: 'personal', + keyExpiresAt: null, } satisfies V2ApiKeyAuthContext const resetAt = new Date('2026-08-08T20:00:00.000Z') const allowedRate = { allowed: true, remaining: 99, resetAt } @@ -84,6 +85,35 @@ describe('defineV2BinaryRoute', () => { v2RouteMocks.operationRate.mockResolvedValue(allowedRate) }) + it('honors an explicit binary request limit and keeps the v2 rejection envelope', async () => { + const handler = defineV2BinaryRoute({ + contract: defineRouteContract({ + method: 'POST', + path: '/api/v2/snapshot', + query: z.object({}), + body: z.object({ content: z.string() }), + response: { mode: 'binary' }, + }), + auth: v2ApiKeyAuth, + operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: 64 }, + mapInput: ({ body }) => body, + useCase: { operation, execute: async ({ input }) => input.content }, + present: (body) => ({ body, contentType: 'text/plain' }), + }) + const response = await handler( + new NextRequest('http://localhost/api/v2/snapshot', { + method: 'POST', + body: JSON.stringify({ content: 'x'.repeat(65) }), + headers: { 'content-type': 'application/json' }, + }) + ) + expect(response.status).toBe(413) + expect(await response.json()).toMatchObject({ error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + it('runs the use case for a HEAD when the route is head-safe', async () => { const execute = vi.fn(async () => ({ bytes: 'payload' })) const response = await createHandler({ execute })(request('HEAD'), context) diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts index 82de0fccc84..c1eee5d1ca2 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -19,6 +19,7 @@ import { type v2ApiKeyAuth, v2HeadAuthorizationResponse, } from '@/lib/api/server/routes/v2-json-route' +import type { ParseRequestOptions } from '@/lib/api/server/validation' import { parseRequest } from '@/lib/api/server/validation' import type { ApplicationOperation } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -33,6 +34,7 @@ interface V2BinaryRouteOptions< auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy errorPolicy: V2ErrorPolicy + parseOptions?: Pick /** * As on {@link defineV2JsonRoute}, whose `headSafe` option carries the * rationale; the bodiless answer is {@link v2HeadNoEffect}. A binary `GET` is @@ -74,6 +76,7 @@ export function defineV2BinaryRoute< const parsed = await parseRequest(options.contract, request, context ?? {}, { ...V2_PARSE_DEFAULTS, + ...options.parseOptions, }) if (!parsed.success) return parsed.response diff --git a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts index f48e826f28e..ef65bd3c03e 100644 --- a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts +++ b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts @@ -462,6 +462,80 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/organizations/{organizationId}/workspaces', load: () => import('@/app/api/v2/organizations/[organizationId]/workspaces/route'), }, + { + pattern: '/api/v2/projects/{projectId}/files', + load: () => import('@/app/api/v2/projects/[projectId]/files/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/content', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/content/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/metadata', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/restore', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/restore/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/versions', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/versions/{version}', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/content', + load: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/revert', + load: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/archive', + load: () => import('@/app/api/v2/projects/[projectId]/files/archive/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/folders', + load: () => import('@/app/api/v2/projects/[projectId]/files/folders/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/folders/{folderId}', + load: () => import('@/app/api/v2/projects/[projectId]/files/folders/[folderId]/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/folders/{folderId}/restore', + load: () => import('@/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/move', + load: () => import('@/app/api/v2/projects/[projectId]/files/move/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/uploads', + load: () => import('@/app/api/v2/projects/[projectId]/files/uploads/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/uploads/{uploadId}', + load: () => import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/uploads/{uploadId}/complete', + load: () => import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/uploads/{uploadId}/parts', + load: () => import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route'), + }, { pattern: '/api/v2/sandboxes', load: () => import('@/app/api/v2/sandboxes/route'), diff --git a/apps/sim/lib/api/server/validation.test.ts b/apps/sim/lib/api/server/validation.test.ts index 632b38e9488..86a619fad17 100644 --- a/apps/sim/lib/api/server/validation.test.ts +++ b/apps/sim/lib/api/server/validation.test.ts @@ -10,7 +10,7 @@ import { * Next.js truncates a proxied client body past `experimental.proxyClientMaxBodySize` * without signalling it, so this is the largest body a handler can actually receive. */ -const PROXY_CLIENT_MAX_BODY_BYTES = 10 * 1024 * 1024 +const PROXY_CLIENT_MAX_BODY_BYTES = 17 * 1024 * 1024 /** Mirrors `MAX_WORKSPACE_FILE_INLINE_BODY_BYTES` — an explicit override above the ceiling. */ const INLINE_FILE_BODY_BYTES = 70 * 1024 * 1024 @@ -40,9 +40,9 @@ describe('DEFAULT_MAX_JSON_BODY_BYTES', () => { }) describe('parseJsonBody default size boundary', () => { - it('accepts a body at the proxy cap', async () => { + it('accepts a body at the default cap', async () => { const result = await parseJsonBody( - requestDeclaring(PROXY_CLIENT_MAX_BODY_BYTES, JSON.stringify({ value: 'ok' })) + requestDeclaring(DEFAULT_MAX_JSON_BODY_BYTES, JSON.stringify({ value: 'ok' })) ) expect(result.success).toBe(true) @@ -75,6 +75,16 @@ describe('parseJsonBody default size boundary', () => { }) }) + it('accepts a document snapshot above the default cap with its explicit limit', async () => { + const body = JSON.stringify({ update: 'a'.repeat(12 * 1024 * 1024) }) + const result = await parseJsonBody( + requestDeclaring(Buffer.byteLength(body), body), + 'response', + 17 * 1024 * 1024 + ) + expect(result.success).toBe(true) + }) + it('leaves an override below the ceiling exactly as declared', async () => { const atLimit = await parseJsonBody( requestDeclaring(BELOW_CEILING_BODY_BYTES, JSON.stringify({ value: 'ok' })), diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 096b1a63656..0221dbeeb05 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -14,55 +14,20 @@ import { } from '@/lib/api/server/blank-query-values' import { nulByteValidationError } from '@/lib/api/server/nul-bytes' import { env } from '@/lib/core/config/env' +import { PROXY_CLIENT_MAX_BODY_BYTES } from '@/lib/core/config/request-limits' import { assertContentLengthWithinLimit, isPayloadSizeLimitError, readStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -/** - * Next.js buffers the client body for the proxy and *silently truncates* anything - * past `experimental.proxyClientMaxBodySize` (default 10 MB), and `apps/sim/proxy.ts` - * matches `/api/:path*`. A larger body therefore reaches the handler as a truncated - * prefix, which fails JSON parsing — so an oversized request has to be rejected on - * its declared size before it is read, or the truncation gets misreported as a - * malformed body. - */ -const PROXY_CLIENT_MAX_BODY_BYTES = 10 * 1024 * 1024 - -/** - * Default upper bound on the JSON request body that contract routes will read - * and parse into memory. Without a cap an unauthenticated caller could buffer a - * large body before schema validation runs. Override per-route via - * `ParseRequestOptions.maxBodyBytes`. - * - * Falls back to 50 MB if the env value is missing or non-numeric so a misconfig - * can never silently disable the cap (a NaN limit would never reject), then - * clamps to {@link PROXY_CLIENT_MAX_BODY_BYTES} because the app can never - * actually receive more than the proxy forwards. - */ +/** Ordinary JSON keeps its existing cap; larger authorized routes opt in explicitly. */ export const DEFAULT_MAX_JSON_BODY_BYTES = Math.min( Number.parseInt(env.API_MAX_JSON_BODY_BYTES, 10) || 50 * 1024 * 1024, - PROXY_CLIENT_MAX_BODY_BYTES + 10 * 1024 * 1024 ) -/** - * Clamps a per-route body cap to {@link PROXY_CLIENT_MAX_BODY_BYTES}. - * - * A route that raises `maxBodyBytes` above the proxy ceiling cannot actually - * receive a body that large: the proxy truncates the stream, the handler parses - * a prefix, and the caller gets `400 "Request body must be valid JSON"` for a - * request whose only fault was its size. Clamping at the point of use turns that - * into an accurate `413`; nothing that succeeds today changes, because a body - * over the ceiling already fails — just less honestly. - * - * Consequence worth keeping in view: `MAX_WORKSPACE_FILE_INLINE_BODY_BYTES` - * (70 MB) exists so a 50 MiB file can be sent inline as base64, and that ceiling - * stays unreachable until `experimental.proxyClientMaxBodySize` is raised in - * `apps/sim/next.config.ts`. Raising it changes the memory profile of every - * `/api` route, so it is a separate decision — this clamp only makes the limit - * that is actually in force report itself correctly. - */ +/** Reject before Next's proxy truncates the body and turns an oversize request into malformed JSON. */ function clampToProxyLimit(maxBytes: number): number { return Math.min(maxBytes, PROXY_CLIENT_MAX_BODY_BYTES) } diff --git a/apps/sim/lib/auth/realtime-file-delegation.ts b/apps/sim/lib/auth/realtime-file-delegation.ts new file mode 100644 index 00000000000..51f4b5ffa56 --- /dev/null +++ b/apps/sim/lib/auth/realtime-file-delegation.ts @@ -0,0 +1,42 @@ +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { FILE_DOC_INTERNAL_HEADERS } from '@sim/realtime-protocol/file-doc' +import { generateId } from '@sim/utils/id' +import { + type InternalAuthPolicy, + InternalUnauthenticatedError, +} from '@/lib/api/server/routes/internal-json-route' +import { checkInternalApiKey } from '@/lib/mothership/request/http' +import { PROJECT_FILE_DELEGATION_TTL_MS } from '@/lib/projects/files/application/operations' + +/** Internal relay requests carry the authenticated socket actor, bounded to exactly one Project file. */ +export const realtimeProjectFileAuth: InternalAuthPolicy = { + async authenticate(request, params) { + if (!checkInternalApiKey(request).success) throw new InternalUnauthenticatedError() + const subjectUserId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.userId) + const connectionId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.connectionId) + const projectId = params.projectId + const fileId = params.fileId + if ( + !subjectUserId?.trim() || + !connectionId?.trim() || + typeof projectId !== 'string' || + !projectId || + typeof fileId !== 'string' || + !fileId + ) { + throw new InternalUnauthenticatedError() + } + const now = Date.now() + return { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId, + invocation: { kind: 'realtime', connectionId }, + scope: { kind: 'entity', entityType: 'project', entityId: projectId, fileId }, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(now), + expiresAt: new Date(now + PROJECT_FILE_DELEGATION_TTL_MS), + } + }, +} diff --git a/apps/sim/lib/auth/realtime-file-list-delegation.ts b/apps/sim/lib/auth/realtime-file-list-delegation.ts new file mode 100644 index 00000000000..f3ed48db9c2 --- /dev/null +++ b/apps/sim/lib/auth/realtime-file-list-delegation.ts @@ -0,0 +1,39 @@ +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { FILE_DOC_INTERNAL_HEADERS } from '@sim/realtime-protocol/file-doc' +import { generateId } from '@sim/utils/id' +import { + type InternalAuthPolicy, + InternalUnauthenticatedError, +} from '@/lib/api/server/routes/internal-json-route' +import { checkInternalApiKey } from '@/lib/mothership/request/http' +import { PROJECT_FILE_DELEGATION_TTL_MS } from '@/lib/projects/files/application/operations' + +/** The relay attests the socket actor and connection, bounded to observing one Project collection. */ +export const realtimeProjectFileListAuth: InternalAuthPolicy = { + async authenticate(request, params) { + if (!checkInternalApiKey(request).success) throw new InternalUnauthenticatedError() + const subjectUserId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.userId) + const connectionId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.connectionId) + const projectId = params.projectId + if ( + !subjectUserId?.trim() || + !connectionId?.trim() || + typeof projectId !== 'string' || + !projectId + ) { + throw new InternalUnauthenticatedError() + } + const now = Date.now() + return { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId, + invocation: { kind: 'realtime', connectionId }, + scope: { kind: 'file_collection_observation', entityType: 'project', entityId: projectId }, + audience: 'sim:file-list-observation', + delegationId: generateId(), + issuedAt: new Date(now), + expiresAt: new Date(now + PROJECT_FILE_DELEGATION_TTL_MS), + } + }, +} diff --git a/apps/sim/lib/collab-doc/persist.ts b/apps/sim/lib/collab-doc/persist.ts index e894baad0da..94632287aa4 100644 --- a/apps/sim/lib/collab-doc/persist.ts +++ b/apps/sim/lib/collab-doc/persist.ts @@ -132,7 +132,7 @@ export async function persistFileDoc( * never sent to that relay, so they cannot be merged into saved state. For stale content writes, * use a throwaway merge only to prove the candidate does not omit durable content. */ -function preparePersistedState( +export function preparePersistedState( docState: Uint8Array, cached: CachedCollabDocState | null, markdown: Buffer, diff --git a/apps/sim/lib/collab-doc/seed.ts b/apps/sim/lib/collab-doc/seed.ts index b9685eaaca6..183c0232040 100644 --- a/apps/sim/lib/collab-doc/seed.ts +++ b/apps/sim/lib/collab-doc/seed.ts @@ -6,6 +6,7 @@ import * as Y from 'yjs' import { applyMarkdownToYDoc, markdownToYDoc } from '@/lib/collab-doc/converter' import { assertCollabDocStateSize, + type CachedCollabDocState, CollabDocStateConflictError, commitCollabDocState, hashMarkdown, @@ -86,23 +87,7 @@ export async function buildFileDocSeed( /** An unavailable cache is not an absent document: retry without minting a new history. */ const stored = await loadCollabDocState(fileId) seedSignal.throwIfAborted() - let update: Uint8Array - if (stored?.sourceHash === sourceHash) { - update = prepareCachedSeed(stored.docState) - } else { - const { frontmatter, body } = splitFrontmatter(buffer.toString('utf-8')) - const ydoc = resumeDocument(fileId, stored?.docState, body) - try { - const config = ydoc.getMap(FILE_DOC_SEED.configMap) - config.set(FILE_DOC_SEED.flag, true) - config.set(FILE_DOC_SEED.frontmatterKey, frontmatter) - ensureDocumentIdentity(ydoc) - update = Y.encodeStateAsUpdate(ydoc) - } finally { - ydoc.destroy() - } - } - assertCollabDocStateSize(update) + const update = prepareFileDocSeed(fileId, buffer, stored) seedSignal.throwIfAborted() const result = await commitCollabDocState(workspaceId, fileId, version, { @@ -117,6 +102,33 @@ export async function buildFileDocSeed( throw new CollabDocStateConflictError(fileId) } +/** Build a bounded seed while preserving the cached document identity across durable edits. */ +export function prepareFileDocSeed( + fileId: string, + buffer: Buffer, + stored: CachedCollabDocState | null +): Uint8Array { + const sourceHash = hashMarkdown(buffer) + let update: Uint8Array + if (stored?.sourceHash === sourceHash) { + update = prepareCachedSeed(stored.docState) + } else { + const { frontmatter, body } = splitFrontmatter(buffer.toString('utf-8')) + const ydoc = resumeDocument(fileId, stored?.docState, body) + try { + const config = ydoc.getMap(FILE_DOC_SEED.configMap) + config.set(FILE_DOC_SEED.flag, true) + config.set(FILE_DOC_SEED.frontmatterKey, frontmatter) + ensureDocumentIdentity(ydoc) + update = Y.encodeStateAsUpdate(ydoc) + } finally { + ydoc.destroy() + } + } + assertCollabDocStateSize(update) + return update +} + /** * Reconcile external content into the existing history. Recreating equivalent Markdown in a new * Y.Doc would assign unrelated item identities and duplicate content when old clients reconnect. diff --git a/apps/sim/lib/collab-doc/state.ts b/apps/sim/lib/collab-doc/state.ts index a1e065b9249..9af5c13e919 100644 --- a/apps/sim/lib/collab-doc/state.ts +++ b/apps/sim/lib/collab-doc/state.ts @@ -2,7 +2,7 @@ import { createHash } from 'crypto' import { db } from '@sim/db' import { workspaceFileCollabState, workspaceFiles } from '@sim/db/schema' import { and, eq, isNull, sql } from 'drizzle-orm' -import type { DbTransaction } from '@/lib/db/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' /** Matches the decoded size of the persist endpoint's 16 MiB base64 snapshot limit. */ export const MAX_COLLAB_DOC_STATE_BYTES = 12 * 1024 * 1024 @@ -51,7 +51,8 @@ export class CollabDocStateConflictError extends Error { */ export async function loadCollabDocState( fileId: string, - options?: { maxBytes: number } + options?: { maxBytes: number }, + executor: DbOrTx = db ): Promise { const maxBytes = Math.min( options?.maxBytes ?? MAX_COLLAB_DOC_STATE_BYTES, @@ -61,7 +62,7 @@ export async function loadCollabDocState( throw new RangeError('Collaborative document state byte limit must be a non-negative integer') } const byteCount = sql`octet_length(${workspaceFileCollabState.docState})` - const [row] = await db + const [row] = await executor .select({ byteCount, docState: sql`CASE WHEN ${byteCount} <= ${maxBytes} THEN ${workspaceFileCollabState.docState} END`, diff --git a/apps/sim/lib/core/application/content-delivery.test.ts b/apps/sim/lib/core/application/content-delivery.test.ts index c0db1b09b00..5cc74901232 100644 --- a/apps/sim/lib/core/application/content-delivery.test.ts +++ b/apps/sim/lib/core/application/content-delivery.test.ts @@ -38,6 +38,17 @@ function delegated(serviceId: DelegatedServiceId): Principal { } const CHAT = delegated('copilot') +const PROJECT_CHAT: Principal = { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + audience: 'sim:project-files', + delegationId: 'delegation-1', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + scope: { kind: 'entity', entityType: 'project', entityId: 'project-1' }, + invocation: { kind: 'workspace', workspaceId: 'workspace-1' }, +} const EXECUTOR = delegated('executor') const SESSION: Principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } @@ -80,6 +91,7 @@ function recordingGate() { describe('mustClassifyContent', () => { it('refuses Chat with no gate before anything loads', () => { expect(() => mustClassifyContent(CHAT)).toThrow(ContentDeliveryUnobservedError) + expect(() => mustClassifyContent(PROJECT_CHAT)).toThrow(ContentDeliveryUnobservedError) }) it('is true for Chat under a gate and false for every other caller, gate or not', () => { diff --git a/apps/sim/lib/core/application/content-delivery.ts b/apps/sim/lib/core/application/content-delivery.ts index d07902baf2a..1d6a1bdec59 100644 --- a/apps/sim/lib/core/application/content-delivery.ts +++ b/apps/sim/lib/core/application/content-delivery.ts @@ -137,10 +137,12 @@ class ContentDeliveryUndeclaredError extends Error { } } -/** Chat's own principals: the in-workspace delegation and the organization chat delegation. */ +/** Chat's own workspace, organization, and resource delegations. */ export function isCopilotPrincipal(principal: Principal): boolean { return ( - (principal.kind === 'delegated' || principal.kind === 'organization_delegated') && + (principal.kind === 'delegated' || + principal.kind === 'organization_delegated' || + principal.kind === 'resource_delegated') && principal.serviceId === 'copilot' ) } diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index a35b3522159..85aa56e1315 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -122,12 +122,12 @@ export type PrincipalKind = Exclude< * A principal kind a non-workspace operation may name. `delegated` is excluded * on purpose: a delegated principal is only meaningful alongside a * `delegatedServices` policy and the workspace, audience, and expiry re-checks - * that {@link defineWorkspaceOperation} exists to carry. An operation that needs - * delegation is a workspace operation. + * that {@link defineWorkspaceOperation} exists to carry. Organization and resource + * delegation likewise require their own domain authorization policies. */ export type UndelegatedPrincipalKind = Exclude< PrincipalKind, - 'delegated' | 'organization_delegated' + 'delegated' | 'organization_delegated' | 'resource_delegated' > /** diff --git a/apps/sim/lib/core/application/organization-authorization.ts b/apps/sim/lib/core/application/organization-authorization.ts index 30b115b5096..d3469969343 100644 --- a/apps/sim/lib/core/application/organization-authorization.ts +++ b/apps/sim/lib/core/application/organization-authorization.ts @@ -63,7 +63,8 @@ export async function requireOrganizationMembership( ) } -async function requireOrganizationSubjectMembership( +/** Rechecks membership for a subject whose principal and delegation were already validated. */ +export async function requireOrganizationSubjectMembership( userId: string, organizationId: string, minimumRole: 'member' | 'admin', diff --git a/apps/sim/lib/core/application/resource-delegation.test.ts b/apps/sim/lib/core/application/resource-delegation.test.ts new file mode 100644 index 00000000000..1f8714464ff --- /dev/null +++ b/apps/sim/lib/core/application/resource-delegation.test.ts @@ -0,0 +1,304 @@ +import { + parsePrincipal, + resolvePrincipalAttribution, + resolvePrincipalAuditAttribution, + resolvePrincipalSubjectUserId, + serializePrincipal, +} from '@sim/auth/principal' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' + +const NOW = new Date('2026-10-03T12:00:00Z') +const principal = { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: 'acting-user', + delegationId: 'tool-call', + audience: 'sim:files', + issuedAt: NOW, + expiresAt: new Date('2026-10-03T12:01:00Z'), + invocation: { kind: 'chat', chatId: 'chat' }, + scope: { kind: 'entity', entityType: 'project', entityId: 'owner' }, +} as const +const policy = { + audience: 'sim:files', + services: ['copilot', 'realtime'], + maxTtlMs: 60_000, + scope: principal.scope, +} as const + +describe('resource delegation authorization', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + }) + afterEach(() => vi.useRealTimers()) + + it('allows owner authority to narrow to a file and preserves the actual subject', () => { + expect( + requireResourceDelegation(principal, { + ...policy, + scope: { ...principal.scope, fileId: 'file' }, + }) + ).toBe('acting-user') + expect(resolvePrincipalSubjectUserId(principal)).toBe('acting-user') + expect( + resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: 'billing-owner' }) + .attributedUserId + ).toBe('acting-user') + expect(resolvePrincipalAuditAttribution(principal)).toMatchObject({ + actorId: 'acting-user', + actor: { kind: 'resource_delegated', serviceId: 'copilot', delegationId: 'tool-call' }, + }) + }) + + it('allows discovery without a selected workspace', () => { + expect( + requireResourceDelegation( + { ...principal, scope: { kind: 'project_discovery' } }, + { ...policy, scope: { kind: 'project_discovery' } } + ) + ).toBe('acting-user') + }) + + it.each([ + ['another principal kind', { kind: 'session', userId: 'acting-user', sessionId: 'session' }], + ['wrong audience', { ...principal, audience: 'sim:workflows' }], + ['wrong service', { ...principal, serviceId: 'executor' }], + ['missing subject', { ...principal, subjectUserId: '' }], + ['missing delegation ID', { ...principal, delegationId: ' ' }], + ['invalid issuance', { ...principal, issuedAt: new Date('invalid') }], + ['invalid expiry', { ...principal, expiresAt: new Date('invalid') }], + ['future issuance', { ...principal, issuedAt: new Date('2026-10-03T12:00:01Z') }], + ['expired grant', { ...principal, expiresAt: NOW }], + ['excessive lifetime', { ...principal, expiresAt: new Date('2026-10-03T12:01:01Z') }], + ['missing chat binding', { ...principal, invocation: { kind: 'chat', chatId: '' } }], + [ + 'missing workspace binding', + { ...principal, invocation: { kind: 'workspace', workspaceId: '' } }, + ], + [ + 'wrong invocation service', + { ...principal, invocation: { kind: 'realtime', connectionId: 'connection' } }, + ], + [ + 'another owner type', + { ...principal, scope: { ...principal.scope, entityType: 'workspace' } }, + ], + ['another owner ID', { ...principal, scope: { ...principal.scope, entityId: 'other' } }], + ['missing owner ID', { ...principal, scope: { ...principal.scope, entityId: '' } }], + ['empty file bound', { ...principal, scope: { ...principal.scope, fileId: '' } }], + ['discovery used for files', { ...principal, scope: { kind: 'project_discovery' } }], + [ + 'file bound widened to owner', + { ...principal, scope: { ...principal.scope, fileId: 'file' } }, + ], + ])('refuses %s before granting a subject', (_name, candidate) => { + expect(() => requireResourceDelegation(candidate as never, policy)).toThrow( + 'Resource delegation is no longer valid' + ) + }) + + it('refuses a service that the operation does not admit', () => { + expect(() => + requireResourceDelegation(principal, { ...policy, services: ['realtime'] }) + ).toThrow('Resource delegation is no longer valid') + }) + + it('does not let a file grant move to a sibling file', () => { + expect(() => + requireResourceDelegation( + { ...principal, scope: { ...principal.scope, fileId: 'first' } }, + { ...policy, scope: { ...principal.scope, fileId: 'second' } } + ) + ).toThrow('Resource delegation is no longer valid') + }) + + it('does not treat matching unknown owner types as authority', () => { + const scope = { kind: 'entity', entityType: 'unknown', entityId: 'owner' } + expect(() => + requireResourceDelegation({ ...principal, scope } as never, { ...policy, scope } as never) + ).toThrow() + }) + + it('does not let entity authority perform project discovery', () => { + expect(() => + requireResourceDelegation(principal, { ...policy, scope: { kind: 'project_discovery' } }) + ).toThrow('Resource delegation is no longer valid') + }) + + it('requires realtime authority to identify a connection and one file', () => { + const realtime = { + ...principal, + serviceId: 'realtime', + invocation: { kind: 'realtime', connectionId: 'connection' }, + scope: { ...principal.scope, fileId: 'file' }, + } as const + expect(requireResourceDelegation(realtime, { ...policy, scope: realtime.scope })).toBe( + 'acting-user' + ) + for (const candidate of [ + { ...realtime, scope: principal.scope }, + { ...realtime, scope: { kind: 'project_discovery' } }, + { ...realtime, invocation: principal.invocation }, + { ...realtime, invocation: { kind: 'realtime', connectionId: '' } }, + ]) { + expect(() => + requireResourceDelegation( + candidate as never, + { ...policy, scope: candidate.scope } as never + ) + ).toThrow('Resource delegation is no longer valid') + } + }) + + it('keeps realtime collection observation separate from entity and file authority', () => { + const scope = { + kind: 'file_collection_observation', + entityType: 'project', + entityId: 'owner', + } as const + const observer = { + ...principal, + audience: 'sim:file-list-observation', + serviceId: 'realtime', + invocation: { kind: 'realtime', connectionId: 'socket' }, + scope, + } as const + const observation = { + ...policy, + audience: observer.audience, + services: ['realtime'], + scope, + } as const + expect(requireResourceDelegation(observer, observation)).toBe('acting-user') + for (const required of [ + { kind: 'entity', entityType: 'project', entityId: 'owner' }, + { kind: 'entity', entityType: 'project', entityId: 'owner', fileId: 'file' }, + { ...scope, entityId: 'another-owner' }, + { kind: 'entity', entityType: 'workspace', entityId: 'owner' }, + ]) { + expect(() => + requireResourceDelegation(observer, { ...observation, scope: required } as never) + ).toThrow('Resource delegation is no longer valid') + } + expect(() => + requireResourceDelegation( + { + ...observer, + scope: { kind: 'entity', entityType: 'project', entityId: 'owner' }, + } as never, + observation + ) + ).toThrow('Resource delegation is no longer valid') + expect(() => + requireResourceDelegation( + { ...observer, scope: { ...scope, entityType: 'workspace' } } as never, + observation + ) + ).toThrow('Resource delegation is no longer valid') + expect(() => + requireResourceDelegation( + { ...observer, scope: { ...scope, fileId: 'invented-file' } } as never, + observation + ) + ).toThrow('Resource delegation is no longer valid') + expect(() => + requireResourceDelegation( + { ...observer, serviceId: 'copilot', invocation: principal.invocation }, + { ...observation, services: ['copilot'] } + ) + ).toThrow('Resource delegation is no longer valid') + }) + + it('cannot be admitted to workspace operations or persisted as workflow authority', () => { + expect(() => + defineWorkspaceOperation({ + id: 'files.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['resource_delegated'], + capability: 'none', + }) + ).toThrow('cannot accept resource delegation') + expect(() => serializePrincipal(principal as never)).toThrow( + 'Principal cannot be persisted for workflow execution' + ) + expect(() => parsePrincipal({ version: 1, principal })).toThrow( + 'Resource delegation cannot be persisted for workflow execution' + ) + }) + + it('binds copy to both owners, the exact requested selection, and one destination folder', () => { + const scope = { + kind: 'file_copy', + source: { + owner: { entityType: 'workspace', entityId: 'source-workspace' }, + fileIds: ['one', 'two'], + folderIds: ['folder'], + }, + destination: { + owner: { entityType: 'project', entityId: 'destination-project' }, + folderId: null, + }, + } as const + const copy = { ...principal, audience: 'sim:files:copy', scope } + const required = { ...policy, audience: copy.audience, services: ['copilot'], scope } as const + expect(requireResourceDelegation(copy, required)).toBe('acting-user') + expect( + requireResourceDelegation(copy, { + ...required, + scope: { ...scope, source: { ...scope.source, fileIds: ['two', 'one'] } }, + }) + ).toBe('acting-user') + for (const changed of [ + { + ...scope, + source: { ...scope.source, owner: { ...scope.source.owner, entityId: 'other' } }, + }, + { ...scope, source: { ...scope.source, fileIds: ['one'] } }, + { ...scope, source: { ...scope.source, fileIds: ['one', 'two', 'extra'] } }, + { ...scope, source: { ...scope.source, folderIds: ['other-folder'] } }, + { + ...scope, + destination: { + ...scope.destination, + owner: { ...scope.destination.owner, entityId: 'other' }, + }, + }, + { ...scope, destination: { ...scope.destination, folderId: 'other-folder' } }, + ]) + expect(() => requireResourceDelegation(copy, { ...required, scope: changed })).toThrow( + 'Resource delegation is no longer valid' + ) + for (const malformed of [ + { ...scope, source: { ...scope.source, fileIds: [], folderIds: [] } }, + { ...scope, source: { ...scope.source, fileIds: ['one', 'one'] } }, + { ...scope, source: { ...scope.source, folderIds: [''] } }, + { + ...scope, + source: { ...scope.source, owner: { entityType: 'organization', entityId: 'org' } }, + }, + { ...scope, source: { ...scope.source, extra: true } }, + { ...scope, destination: { owner: scope.destination.owner } }, + { ...scope, destination: { ...scope.destination, folderPath: 'unresolved' } }, + ]) + expect(() => + requireResourceDelegation({ ...copy, scope: malformed } as never, required) + ).toThrow('Resource delegation is no longer valid') + for (const candidate of [ + { ...principal, audience: copy.audience }, + { ...copy, serviceId: 'realtime', invocation: { kind: 'realtime', connectionId: 'socket' } }, + ]) + expect(() => requireResourceDelegation(candidate as never, required)).toThrow( + 'Resource delegation is no longer valid' + ) + expect(() => requireResourceDelegation(copy, { ...policy, audience: copy.audience })).toThrow( + 'Resource delegation is no longer valid' + ) + expect(() => + requireResourceDelegation(copy, { ...required, scope: { kind: 'project_discovery' } }) + ).toThrow('Resource delegation is no longer valid') + }) +}) diff --git a/apps/sim/lib/core/application/resource-delegation.ts b/apps/sim/lib/core/application/resource-delegation.ts new file mode 100644 index 00000000000..693aecb52c8 --- /dev/null +++ b/apps/sim/lib/core/application/resource-delegation.ts @@ -0,0 +1,179 @@ +import type { + Principal, + ResourceDelegatedPrincipal, + ResourceDelegationScope, + ResourceFileCopyScope, +} from '@sim/auth/principal' +import { isRecordLike } from '@sim/utils/object' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export interface ResourceDelegationPolicy { + audience: string + services: readonly ResourceDelegatedPrincipal['serviceId'][] + scope: ResourceDelegationScope + maxTtlMs: number +} + +function isIdentity(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value === value.trim() +} + +function isCopyOwner(value: unknown): boolean { + return ( + isRecordLike(value) && + (value.entityType === 'workspace' || value.entityType === 'project') && + isIdentity(value.entityId) && + Object.keys(value).length === 2 && + Object.keys(value).every((key) => key === 'entityType' || key === 'entityId') + ) +} + +function isSelectionIds(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length <= 1_000 && + Array.from(value).every(isIdentity) && + new Set(value).size === value.length + ) +} + +/** Validates the paired scope shared by delegation admission and the copy application boundary. */ +function isResourceFileCopyScope(scope: unknown): scope is ResourceFileCopyScope { + if (!isRecordLike(scope) || scope.kind !== 'file_copy') return false + const { source, destination } = scope + return ( + Object.keys(scope).length === 3 && + Object.keys(scope).every((key) => ['kind', 'source', 'destination'].includes(key)) && + isRecordLike(source) && + Object.keys(source).length === 3 && + Object.keys(source).every((key) => ['owner', 'fileIds', 'folderIds'].includes(key)) && + isCopyOwner(source.owner) && + isSelectionIds(source.fileIds) && + isSelectionIds(source.folderIds) && + source.fileIds.length + source.folderIds.length > 0 && + isRecordLike(destination) && + Object.keys(destination).length === 2 && + Object.keys(destination).every((key) => key === 'owner' || key === 'folderId') && + isCopyOwner(destination.owner) && + (destination.folderId === null || isIdentity(destination.folderId)) + ) +} + +function isValidScope(scope: unknown): scope is ResourceDelegationScope { + if (!isRecordLike(scope)) return false + if (scope.kind === 'project_discovery') return Object.keys(scope).length === 1 + if (scope.kind === 'file_copy') return isResourceFileCopyScope(scope) + if (scope.kind === 'file_collection_observation') { + return ( + scope.entityType === 'project' && + isIdentity(scope.entityId) && + Object.keys(scope).length === 3 && + Object.keys(scope).every((key) => ['kind', 'entityType', 'entityId'].includes(key)) + ) + } + return ( + scope.kind === 'entity' && + (scope.entityType === 'workspace' || + scope.entityType === 'project' || + scope.entityType === 'organization' || + scope.entityType === 'user') && + isIdentity(scope.entityId) && + (scope.fileId === undefined || isIdentity(scope.fileId)) && + Object.keys(scope).every((key) => ['kind', 'entityType', 'entityId', 'fileId'].includes(key)) + ) +} + +function isValidInvocation(principal: ResourceDelegatedPrincipal): boolean { + const invocation = principal.invocation + if (!isRecordLike(invocation) || Object.keys(invocation).length !== 2) return false + if (principal.serviceId === 'copilot') { + return ( + principal.scope.kind !== 'file_collection_observation' && + ((invocation.kind === 'chat' && isIdentity(invocation.chatId)) || + (invocation.kind === 'workspace' && isIdentity(invocation.workspaceId))) + ) + } + return ( + principal.serviceId === 'realtime' && + invocation.kind === 'realtime' && + isIdentity(invocation.connectionId) && + ((principal.scope.kind === 'entity' && isIdentity(principal.scope.fileId)) || + principal.scope.kind === 'file_collection_observation') + ) +} + +function matchesScope( + granted: ResourceDelegationScope, + required: ResourceDelegationScope +): boolean { + if (granted.kind !== required.kind) return false + if (granted.kind === 'project_discovery') return true + if (granted.kind === 'file_collection_observation') { + return ( + required.kind === 'file_collection_observation' && + granted.entityType === required.entityType && + granted.entityId === required.entityId + ) + } + if (granted.kind === 'file_copy') { + if (required.kind !== 'file_copy') return false + const requestedFiles = new Set(required.source.fileIds) + const requestedFolders = new Set(required.source.folderIds) + return ( + granted.source.owner.entityType === required.source.owner.entityType && + granted.source.owner.entityId === required.source.owner.entityId && + granted.destination.owner.entityType === required.destination.owner.entityType && + granted.destination.owner.entityId === required.destination.owner.entityId && + granted.destination.folderId === required.destination.folderId && + granted.source.fileIds.length === requestedFiles.size && + granted.source.fileIds.every((id) => requestedFiles.has(id)) && + granted.source.folderIds.length === requestedFolders.size && + granted.source.folderIds.every((id) => requestedFolders.has(id)) + ) + } + return ( + required.kind === 'entity' && + granted.entityType === required.entityType && + granted.entityId === required.entityId && + (granted.fileId === undefined || granted.fileId === required.fileId) + ) +} + +/** Checks delegation bounds; the domain must still authorize the invocation and current user access. */ +export function requireResourceDelegation( + principal: Principal, + policy: ResourceDelegationPolicy +): string { + if ( + !isIdentity(policy.audience) || + !Number.isSafeInteger(policy.maxTtlMs) || + policy.maxTtlMs <= 0 || + policy.services.length === 0 || + !policy.services.every((service) => service === 'copilot' || service === 'realtime') || + !isValidScope(policy.scope) + ) { + throw new Error('Resource delegation requires a bounded operation policy') + } + + const now = Date.now() + if ( + principal.kind !== 'resource_delegated' || + !policy.services.includes(principal.serviceId) || + principal.audience !== policy.audience || + !isIdentity(principal.subjectUserId) || + !isIdentity(principal.delegationId) || + !(principal.issuedAt instanceof Date) || + !(principal.expiresAt instanceof Date) || + !Number.isFinite(principal.issuedAt.getTime()) || + !Number.isFinite(principal.expiresAt.getTime()) || + principal.issuedAt.getTime() > now || + principal.expiresAt.getTime() <= now || + principal.expiresAt.getTime() - principal.issuedAt.getTime() > policy.maxTtlMs || + !isValidScope(principal.scope) || + !isValidInvocation(principal) || + !matchesScope(principal.scope, policy.scope) + ) { + throw new OrchestrationError('forbidden', 'Resource delegation is no longer valid') + } + return principal.subjectUserId +} diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index e6cd71faeda..721c59d7007 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -55,6 +55,7 @@ export function capabilityGovernedPrincipalUserId(principal: Principal): string case 'slack_installation': case 'slack_app': return null + case 'resource_delegated': case 'organization_delegated': return principal.subjectUserId case 'delegated': { @@ -425,6 +426,7 @@ export async function authorizeWorkspaceOperation { setEnvFlags({ isAppConfigEnabled: false }) envRef.AGENT_MEMORY_HISTORY = undefined envRef.DASHBOARDS = undefined + envRef.PROJECTS = undefined }) it('rolls dashboards out globally or by organization and defaults off locally', async () => { @@ -97,6 +98,18 @@ describe('getFeatureFlags', () => { envRef.DASHBOARDS = undefined }) + it('uses the projects AppConfig flag with PROJECTS only as an off-AppConfig fallback', async () => { + expect(await isFeatureEnabled('projects')).toBe(false) + envRef.PROJECTS = true + expect(await isFeatureEnabled('projects')).toBe(true) + withAppConfig({ projects: { enabled: false } }) + expect(await isFeatureEnabled('projects')).toBe(false) + envRef.PROJECTS = false + withAppConfig({ projects: { enabled: true } }) + expect(await isFeatureEnabled('projects')).toBe(true) + envRef.PROJECTS = undefined + }) + it('rolls Agent history out by workspace and retains a global capture switch', async () => { withAppConfig({ 'agent-memory-history': { workspaceIds: ['workspace-a'] } }) expect(await isFeatureEnabled('agent-memory-history', { workspaceId: 'workspace-a' })).toBe( diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 3bf0e51e3a9..b1f1e745d4a 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -104,7 +104,7 @@ const FEATURE_FLAGS = { }, projects: { description: - 'Expose the Project APIs once the membership backfill has validated. Global on/off only; ' + + 'Expose Projects and Project-scoped features, including files and memories. Global on/off only; ' + 'workspace creation assigns Projects and lifecycle protections apply either way. ' + 'Off-AppConfig falls back to PROJECTS.', fallback: 'PROJECTS', diff --git a/apps/sim/lib/core/config/request-limits.ts b/apps/sim/lib/core/config/request-limits.ts new file mode 100644 index 00000000000..ea877ba1521 --- /dev/null +++ b/apps/sim/lib/core/config/request-limits.ts @@ -0,0 +1,2 @@ +/** Next proxy buffer accommodates the base64 envelope for a 12 MiB collaborative snapshot. */ +export const PROXY_CLIENT_MAX_BODY_BYTES = 17 * 1024 * 1024 diff --git a/apps/sim/lib/core/outbox/handlers.ts b/apps/sim/lib/core/outbox/handlers.ts index 9affb53e250..433c908200c 100644 --- a/apps/sim/lib/core/outbox/handlers.ts +++ b/apps/sim/lib/core/outbox/handlers.ts @@ -199,10 +199,8 @@ export const OUTBOX_HANDLER_GROUPS: readonly LazyOutboxHandlerGroup[] = [ { events: [WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT], load: async () => { - const { workspaceFileLiveDocOutboxHandlers } = await import( - '@/lib/uploads/contexts/workspace/file-live-doc-outbox' - ) - return workspaceFileLiveDocOutboxHandlers + const { fileLiveDocOutboxHandlers } = await import('@/lib/uploads/server/live-doc-outbox') + return fileLiveDocOutboxHandlers }, }, { diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index d31e6e5bc2d..070b1b56878 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -12,6 +12,10 @@ import { isDev, isHosted, isReactGrabEnabled } from '../config/env-flags' * relative import that itself pulls in no `@/` paths (../../consent/constants). */ +/** File responses select CSP at runtime; JSON and error responses retain the main policy. */ +export const FILE_DELIVERY_CSP_PATH_PATTERN = + '/(?:api/files/serve/.+|api/files/public/[^/]+/(?:content|inline)|api/projects/[^/]+/files/(?:inline|download|[^/]+/(?:content|artifact|export|versions/[^/]+/content))|api/workspaces/[^/]+/files/(?:inline|download|[^/]+/versions/[^/]+/content)|api/v2/projects/[^/]+/files/(?:bulk-download|[^/]+/(?:content|export|versions/[^/]+/content))|api/v2/files/(?:bulk-download|[^/]+(?:/versions/[^/]+/content)?))' + const DEFAULT_SOCKET_URL = 'http://localhost:3002' const DEFAULT_OLLAMA_URL = 'http://localhost:11434' diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index e25de9a486a..47a35f4be7c 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -5,11 +5,14 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { hasExternalApiCredentials } from '@/lib/api/server/credential-headers' import { getRateLimitHeaders } from '@/lib/api/server/rate-limit-context' +import { FILE_DELIVERY_CSP_PATH_PATTERN, getMainCSPPolicy } from '@/lib/core/security/csp' import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' import { MAX_CALL_CHAIN_DEPTH, parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' +const fileDeliveryPath = new RegExp(`^${FILE_DELIVERY_CSP_PATH_PATTERN}$`) + const logger = createLogger('RouteHandler') type RouteHandler = ( @@ -70,6 +73,12 @@ function applyResponseHeaders( ): void { if (!response?.headers) return response.headers.set('x-request-id', requestId) + if ( + fileDeliveryPath.test(request.nextUrl?.pathname ?? '') && + !response.headers.has('Content-Security-Policy') + ) { + response.headers.set('Content-Security-Policy', getMainCSPPolicy()) + } const rateLimit = getRateLimitHeaders(request) if (!rateLimit) return for (const [name, value] of Object.entries(rateLimit)) { diff --git a/apps/sim/lib/projects/README.md b/apps/sim/lib/projects/README.md index acaa6bb7b61..fa3665a7ed8 100644 --- a/apps/sim/lib/projects/README.md +++ b/apps/sim/lib/projects/README.md @@ -2,38 +2,7 @@ A Project groups environments. An environment is an existing `workspace` record; there is no separate environment table. Every newly created Project starts with an environment. -Project APIs return HTTP 503 until the `projects` feature flag is on (AppConfig on hosted deployments; the `PROJECTS` secret elsewhere). The flag defaults off and gates only the Project APIs: workspace creation always assigns a Project atomically, and assigned Projects keep their lifecycle protections (fork inheritance, disconnect) either way. - -## Storage and consistency - -Each workspace belongs to exactly one Project through the required `workspace.project_id` -foreign key. One Project can contain many workspaces, including disconnected roots; there -is no membership connector table. Referenced Projects cannot be deleted while their -workspaces remain. `project.updated_at` is Project-record metadata, not a workspace -activity counter or a concurrency version. - -Native composite foreign keys enforce two structural invariants: - -- Workspace and Project organizations agree, including personal (`NULL`) scope. Both - tables have a stored generated `organization_scope_key`: `personal` for NULL and - `organization:` followed by the organization ID otherwise. Writers cannot supply this - key independently. A unique Project `(id, organization_scope_key)` constraint supports - the workspace `(project_id, organization_scope_key)` foreign key. -- A connected fork and its parent share a Project. A unique workspace `(id, project_id)` - constraint supports `(forked_from_workspace_id, project_id)` referencing that pair. - The parent-ID foreign key uses `ON DELETE SET NULL`, clearing only the parent pointer. - The composite foreign key uses `NO ACTION`; Project membership never cascades automatically. - -Both composite foreign keys are `DEFERRABLE INITIALLY DEFERRED`, allowing atomic -organization transfers and subtree moves before validation. Drizzle records the columns, -indexes, and relationships; the shared schema finalizer sets deferred timing after a fresh -schema push because Drizzle does not represent that timing. - -Application transactions own creation with the first workspace, nonempty Projects, -archive lifecycle, workflow admission/restoration, and subtree disconnection. Their -Project, lineage, edge, and workspace locks protect these business operations. PostgreSQL -owns referential-integrity concurrency through the native constraints; no custom Project -integrity triggers or artificial Project row touches are required. +Project APIs return HTTP 503 until the `projects` feature flag is on (AppConfig on hosted deployments; the `PROJECTS` secret elsewhere). The flag defaults off and gates Project and Project-file APIs through `requireProjectApiEnabled()`: workspace creation always assigns a Project atomically, and assigned Projects keep their lifecycle protections (fork inheritance, disconnect) either way. Disabling the flag does not disable retention or cleanup of existing Project data. ## Storage and consistency diff --git a/apps/sim/lib/projects/__integration__/foundation.integration.ts b/apps/sim/lib/projects/__integration__/foundation.integration.ts index e69f1107753..d94ad56c2c6 100644 --- a/apps/sim/lib/projects/__integration__/foundation.integration.ts +++ b/apps/sim/lib/projects/__integration__/foundation.integration.ts @@ -1,7 +1,9 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { + copilotChats, member, organization, permissionGroup, @@ -151,6 +153,24 @@ function check(name: string, run: () => Promise) { }) } +/** Failure modes: foreign workspace discovery, expired/wrong-audience authority, and CRUD escalation. */ +function discoveryPrincipal( + userId: string, + workspaceId: string +): Extract { + return { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: userId, + delegationId: generateId(), + audience: 'sim:projects:discovery', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + invocation: { kind: 'workspace', workspaceId }, + scope: { kind: 'project_discovery' }, + } +} + async function fixture(org = true, count = 2) { const ownerId = generateId() const teammateId = generateId() @@ -1374,3 +1394,121 @@ describe('Project foundation at the database and application boundary', () => { } ) }) + +describe('Project discovery delegation', () => { + check( + 'binds organization discovery to the current owned chat and rechecks membership', + async () => { + const first = await fixture() + const second = await fixture() + const chatId = generateId() + await db.insert(copilotChats).values({ + id: chatId, + userId: first.ownerId, + organizationId: first.organizationId, + type: 'mothership', + config: { conversationMode: 'agent' }, + }) + await db.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: second.ids[0], + userId: first.ownerId, + permissionType: 'admin', + }) + const principal: ResourceDelegatedPrincipal = { + ...discoveryPrincipal(first.ownerId, first.ids[0]), + serviceId: 'copilot', + invocation: { kind: 'chat', chatId }, + } + const result = await listProjects.execute({ principal, input: { limit: 100 } }) + expect(result.projects.map((entry) => entry.id)).toEqual([first.projectId]) + await expect( + listProjects.execute({ + principal, + input: { limit: 100, organizationId: second.organizationId ?? undefined }, + }) + ).rejects.toThrow() + await expect( + listProjects.execute({ + principal: { ...principal, subjectUserId: first.outsiderId }, + input: { limit: 100 }, + }) + ).rejects.toThrow() + await db + .update(copilotChats) + .set({ config: { conversationMode: 'assistant' } }) + .where(eq(copilotChats.id, chatId)) + await expect(listProjects.execute({ principal, input: { limit: 100 } })).rejects.toThrow() + await db + .update(copilotChats) + .set({ config: { conversationMode: 'agent' } }) + .where(eq(copilotChats.id, chatId)) + if (first.organizationId) + await db + .delete(member) + .where( + and(eq(member.organizationId, first.organizationId), eq(member.userId, first.ownerId)) + ) + await expect(listProjects.execute({ principal, input: { limit: 100 } })).rejects.toThrow() + } + ) + + check( + 'keeps discovery inside the origin organization without granting lifecycle access', + async () => { + const first = await fixture() + const second = await fixture() + await db.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: second.ids[0], + userId: first.ownerId, + permissionType: 'admin', + }) + const principal = discoveryPrincipal(first.ownerId, first.ids[0]) + const result = await listProjects.execute({ principal, input: { limit: 100 } }) + expect(result.projects.map((entry) => entry.id)).toEqual([first.projectId]) + await expect( + renameProject.execute({ principal, input: { projectId: first.projectId, name: 'Denied' } }) + ).rejects.toThrow() + await expect( + archiveProject.execute({ principal, input: { projectId: first.projectId } }) + ).rejects.toThrow() + await db + .delete(permissions) + .where(and(eq(permissions.userId, first.ownerId), inArray(permissions.entityId, first.ids))) + if (first.organizationId) + await db + .delete(member) + .where( + and(eq(member.organizationId, first.organizationId), eq(member.userId, first.ownerId)) + ) + await expect(listProjects.execute({ principal, input: { limit: 100 } })).rejects.toThrow() + } + ) + + check( + 'rejects expired, malformed, wrong-audience and entity-scoped discovery delegation', + async () => { + const state = await fixture() + const principal = discoveryPrincipal(state.ownerId, state.ids[0]) + for (const patch of [ + { expiresAt: new Date(0) }, + { issuedAt: new Date('invalid') }, + { audience: 'sim:workspace-files' }, + { + scope: { + kind: 'entity' as const, + entityType: 'project' as const, + entityId: state.projectId, + }, + }, + ]) { + await expect( + listProjects.execute({ principal: { ...principal, ...patch }, input: { limit: 10 } }) + ).rejects.toThrow() + } + } + ) +}) diff --git a/apps/sim/lib/projects/application/authorization.ts b/apps/sim/lib/projects/application/authorization.ts index cc8c193b35c..d300aca2a7c 100644 --- a/apps/sim/lib/projects/application/authorization.ts +++ b/apps/sim/lib/projects/application/authorization.ts @@ -1,8 +1,14 @@ -import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { + type Principal, + type ResourceDelegatedPrincipal, + requirePrincipalSubjectUserId, + type SessionPrincipal, +} from '@sim/auth/principal' import { member, permissionGroup, permissions, project, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, asc, eq, inArray, sql } from 'drizzle-orm' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { textArrayLiteral } from '@/lib/db/arrays' @@ -10,17 +16,33 @@ import type { DbTransaction } from '@/lib/db/types' import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { resolveVerifiedUserAccessControlContext } from '@/lib/permission-groups/resolve.server' -import { type ProjectOperation, projectOperations } from '@/lib/projects/application/operations' +import { + PROJECT_DISCOVERY_DELEGATION_TTL_MS, + type ProjectOperation, + projectOperations, +} from '@/lib/projects/application/operations' import { lockProject } from '@/lib/projects/membership' const logger = createLogger('ProjectAuthorization') -export function requireProjectPrincipal( +export function requireProjectPrincipal< + O extends Pick, +>( principal: Principal, - operation: Pick -): asserts principal is SessionPrincipal { - if (principal.kind !== 'session') + operation: O +): asserts principal is Extract { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + if (principal.kind === 'resource_delegated') { + if (operation.id !== 'projects.list' || !operation.delegationAudience) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + requireResourceDelegation(principal, { + audience: operation.delegationAudience, + services: ['copilot'], + scope: { kind: 'project_discovery' }, + maxTtlMs: PROJECT_DISCOVERY_DELEGATION_TTL_MS, + }) + } } type ProjectRecord = typeof project.$inferSelect @@ -31,6 +53,7 @@ interface ProjectEnvironmentAccess { organizationId: string | null archivedAt: Date | null parentId: string | null + allowPersonalApiKeys: boolean permission: string | null } @@ -45,14 +68,14 @@ export interface ProjectAuthorizationInput { */ type ProjectAccessMode = 'hold' | 'snapshot' -type ProjectAccess = Awaited> +type ProjectAccess = Awaited> /** * Loads the caller's org role and every environment with its grant for `records` in three * queries, whatever their count. A snapshot read also learns, in one more query, which * records any permission group restricts, so unrestricted ones skip per-environment policy. */ -async function loadProjectAccess( +async function loadProjectMemberships( tx: DbTransaction, userId: string, records: ProjectRecord[], @@ -67,7 +90,7 @@ async function loadProjectAccess( const [membership] = records.some((record) => record.organizationId) ? await (lock ? memberQuery.for('share') : memberQuery) : [] - const environments = await tx + const environmentQuery = tx .select({ projectId: workspace.projectId, id: workspace.id, @@ -75,6 +98,7 @@ async function loadProjectAccess( organizationId: workspace.organizationId, archivedAt: workspace.archivedAt, parentId: workspace.forkedFromWorkspaceId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, }) .from(workspace) .where( @@ -84,6 +108,9 @@ async function loadProjectAccess( ) ) .orderBy(asc(workspace.id)) + const environments = await (lock + ? environmentQuery.for('share', { of: workspace }) + : environmentQuery) const grantQuery = tx .select({ id: permissions.entityId, permission: permissions.permissionType }) .from(permissions) @@ -141,22 +168,15 @@ async function loadProjectAccess( } } -/** Applies the access rules to one loaded Project; hidden environments never enter the result. */ -async function evaluateProjectAccess( - tx: DbTransaction, - principal: SessionPrincipal, - operation: ProjectOperation, +function projectVisibility( record: ProjectRecord, access: ProjectAccess, - input: ProjectAuthorizationInput, - mode: ProjectAccessMode + input: ProjectAuthorizationInput ) { const orgAdmin = access.isOrgAdmin(record.organizationId) const rows = access.environmentsFor(record.id) if (rows.some((row) => row.organizationId !== record.organizationId)) throw new OrchestrationError('conflict', 'Project ownership needs reconciliation') - if (mode === 'hold' && operation.access === 'issues' && record.organizationId) - await acquirePermissionGroupOrgLock(tx, record.organizationId) const active = rows.filter((row) => !row.archivedAt) const visible = active.filter((row) => orgAdmin || row.permission !== null) const canAdminister = @@ -165,6 +185,42 @@ async function evaluateProjectAccess( throw new OrchestrationError('not_found', 'Project not found') if (input.workspaceId && !visible.some((row) => row.id === input.workspaceId)) throw new OrchestrationError('not_found', 'Project not found') + return { record, rows, active, visible, orgAdmin, canAdminister } +} + +/** Loads and holds canonical Project membership for a resource-specific access policy. */ +export async function loadProjectAccess( + tx: DbTransaction, + userId: string, + input: ProjectAuthorizationInput & { projectId: string } +) { + await lockProject(tx, input.projectId) + const [record] = await tx.select().from(project).where(eq(project.id, input.projectId)).limit(1) + if ( + !record || + (input.organizationId !== undefined && input.organizationId !== record.organizationId) + ) + throw new OrchestrationError('not_found', 'Project not found') + return projectVisibility( + record, + await loadProjectMemberships(tx, userId, [record], 'hold'), + input + ) +} + +/** Applies the access rules to one loaded Project; hidden environments never enter the result. */ +async function evaluateProjectAccess( + tx: DbTransaction, + principal: SessionPrincipal | ResourceDelegatedPrincipal, + operation: ProjectOperation, + record: ProjectRecord, + access: ProjectAccess, + input: ProjectAuthorizationInput, + mode: ProjectAccessMode +) { + const { active, visible, canAdminister } = projectVisibility(record, access, input) + if (mode === 'hold' && operation.access === 'issues' && record.organizationId) + await acquirePermissionGroupOrgLock(tx, record.organizationId) if (operation.access === 'admin' && !canAdminister) throw new OrchestrationError( 'forbidden', @@ -179,7 +235,7 @@ async function evaluateProjectAccess( ) { for (const environment of visible) { const { config } = await resolveVerifiedUserAccessControlContext( - principal.userId, + requirePrincipalSubjectUserId(principal), environment.id, record.organizationId, tx @@ -213,11 +269,12 @@ export type AuthorizedProject = Awaited /** Authorizes one Project for `operation`; see {@link ProjectAccessMode} for locking. */ export async function authorizeProject( tx: DbTransaction, - principal: SessionPrincipal, + principal: SessionPrincipal | ResourceDelegatedPrincipal, operation: ProjectOperation, input: ProjectAuthorizationInput & { projectId: string }, mode: ProjectAccessMode ): Promise { + requireProjectPrincipal(principal, operation) if (mode === 'hold') await lockProject(tx, input.projectId) const [record] = await tx.select().from(project).where(eq(project.id, input.projectId)).limit(1) if ( @@ -226,23 +283,34 @@ export async function authorizeProject( ) { throw new OrchestrationError('not_found', 'Project not found') } - const access = await loadProjectAccess(tx, principal.userId, [record], mode) + const access = await loadProjectMemberships( + tx, + requirePrincipalSubjectUserId(principal), + [record], + mode + ) return evaluateProjectAccess(tx, principal, operation, record, access, input, mode) } /** Authorizes a listed page of Projects in id order inside the caller's read-only snapshot. */ export async function authorizeProjectsForRead( tx: DbTransaction, - principal: SessionPrincipal, + principal: SessionPrincipal | ResourceDelegatedPrincipal, projectIds: string[] ): Promise { + requireProjectPrincipal(principal, projectOperations.list) if (!projectIds.length) return [] const records = await tx .select() .from(project) .where(inArray(project.id, projectIds)) .orderBy(asc(project.id)) - const access = await loadProjectAccess(tx, principal.userId, records, 'snapshot') + const access = await loadProjectMemberships( + tx, + requirePrincipalSubjectUserId(principal), + records, + 'snapshot' + ) const authorized: AuthorizedProject[] = [] for (const record of records) { /** One inconsistent Project must not hide the rest of the caller's page. */ diff --git a/apps/sim/lib/projects/application/discovery.ts b/apps/sim/lib/projects/application/discovery.ts new file mode 100644 index 00000000000..d8ec0a1773d --- /dev/null +++ b/apps/sim/lib/projects/application/discovery.ts @@ -0,0 +1,68 @@ +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { getActivelyBannedUserIds } from '@/lib/auth/ban' +import { requireOrganizationSubjectMembership } from '@/lib/core/application/organization-authorization' +import { requireCurrentHumanRole } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' + +/** Project access stays inside the current, server-resolved conversation owner. */ +export async function resolveCopilotProjectScope( + principal: ResourceDelegatedPrincipal, + assertedOrganizationId?: string +): Promise<{ organizationId: string | null; workspaceId?: string }> { + if (principal.serviceId !== 'copilot') + throw new OrchestrationError('forbidden', 'Project access requires Copilot delegation') + const userId = principal.subjectUserId + let workspaceId: string + if (principal.invocation.kind === 'chat') { + const chat = await resolveOwnedChatContext(principal, principal.invocation.chatId) + if (chat.organizationId) { + if (chat.mode !== 'agent' && chat.mode !== 'plan') + throw new OrchestrationError('forbidden', 'Project access requires organization agent mode') + if (assertedOrganizationId && assertedOrganizationId !== chat.organizationId) + throw new OrchestrationError('not_found', 'Organization not found in this conversation') + await requireOrganizationSubjectMembership( + userId, + chat.organizationId, + 'member', + 'copilot.use', + undefined, + { executor: db } + ) + return { organizationId: chat.organizationId } + } + if (!chat.workspaceId) throw new OrchestrationError('not_found', 'Workspace not found') + workspaceId = chat.workspaceId + } else { + workspaceId = principal.invocation.workspaceId + if ((await getActivelyBannedUserIds([userId])).length > 0) + throw new OrchestrationError('forbidden', 'User account is suspended') + } + const [context] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + if ( + !context || + (assertedOrganizationId && assertedOrganizationId !== context.workspaceOrganizationId) + ) + throw new OrchestrationError('not_found', 'Workspace not found in this conversation') + await requireCurrentHumanRole(userId, context, 'read') + await assertWorkspaceCapability( + userId, + workspaceId, + 'copilot.use', + context.workspaceOrganizationId, + db + ) + return { organizationId: context.workspaceOrganizationId, workspaceId } +} diff --git a/apps/sim/lib/projects/application/operations.ts b/apps/sim/lib/projects/application/operations.ts index 4158cd61008..ba73e90e5e7 100644 --- a/apps/sim/lib/projects/application/operations.ts +++ b/apps/sim/lib/projects/application/operations.ts @@ -2,14 +2,26 @@ import type { ApplicationOperation } from '@/lib/core/application/operation' import { assertOperationCapability, defineOperation } from '@/lib/core/application/operation' export interface ProjectOperation extends ApplicationOperation { - readonly principalKinds: readonly ['session'] + readonly principalKinds: readonly ('session' | 'resource_delegated')[] readonly access: 'read' | 'admin' | 'issues' + readonly delegationAudience?: string + readonly delegatedServices?: readonly ['copilot'] } +export const PROJECT_DISCOVERY_DELEGATION_TTL_MS = 60_000 + /** Project operations cannot borrow authority from workspace keys or an arbitrary root. */ function defineProjectOperation(operation: O): O { assertOperationCapability(operation) + if ( + operation.principalKinds.includes('resource_delegated') && + (operation.id !== 'projects.list' || + operation.access !== 'read' || + !operation.delegationAudience) + ) + throw new Error('Resource delegation is available only for bounded Project discovery') Object.freeze(operation.principalKinds) + if (operation.delegatedServices) Object.freeze(operation.delegatedServices) return Object.freeze(operation) } @@ -22,9 +34,11 @@ export const projectOperations = { // permission-group-exempt: navigation exposes only Projects containing accessible environments. list: defineProjectOperation({ id: 'projects.list', - principalKinds: ['session'], + principalKinds: ['session', 'resource_delegated'], access: 'read', capability: 'none', + delegationAudience: 'sim:projects:discovery', + delegatedServices: ['copilot'], }), // permission-group-exempt: Project metadata does not grant access to its Issues or environments. get: defineProjectOperation({ diff --git a/apps/sim/lib/projects/application/use-cases.ts b/apps/sim/lib/projects/application/use-cases.ts index 2bddc64ba58..871fc3fce59 100644 --- a/apps/sim/lib/projects/application/use-cases.ts +++ b/apps/sim/lib/projects/application/use-cases.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' import { member, permissions, project, workspace } from '@sim/db/schema' import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace' @@ -13,6 +14,7 @@ import { type ProjectAuthorizationInput, requireProjectPrincipal, } from '@/lib/projects/application/authorization' +import { resolveCopilotProjectScope } from '@/lib/projects/application/discovery' import { projectOperations } from '@/lib/projects/application/operations' import { archiveProjectInTransaction, finishProjectArchive } from '@/lib/projects/lifecycle' import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' @@ -90,17 +92,24 @@ export const listProjects: OperationUseCase< async execute({ principal, input }) { requireProjectPrincipal(principal, projectOperations.list) await requireProjectApiEnabled() + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) + throw new OrchestrationError('validation', 'Limit must be between 1 and 100') + const userId = requirePrincipalSubjectUserId(principal) + const scope = + principal.kind === 'resource_delegated' + ? await resolveCopilotProjectScope(principal, input.organizationId) + : { organizationId: input.organizationId } return db.transaction(async (tx) => { /** Driven from the caller's grants and admin organization, so cost tracks their reach. */ const candidates = await tx.execute<{ id: string }>(sql` WITH accessible AS ( SELECT ${permissions.entityId} AS workspace_id FROM ${permissions} - WHERE ${permissions.userId} = ${principal.userId} + WHERE ${permissions.userId} = ${userId} AND ${permissions.entityType} = 'workspace' UNION SELECT ${workspace.id} FROM ${member} JOIN ${workspace} ON ${workspace.organizationId} = ${member.organizationId} - WHERE ${member.userId} = ${principal.userId} AND ${inArray(member.role, ORG_ADMIN_ROLES)} + WHERE ${member.userId} = ${userId} AND ${inArray(member.role, ORG_ADMIN_ROLES)} ) SELECT DISTINCT ${workspace.projectId} AS id FROM accessible @@ -109,7 +118,7 @@ export const listProjects: OperationUseCase< JOIN ${project} ON ${project.id} = ${workspace.projectId} AND ${project.archivedAt} IS NULL WHERE TRUE - ${input.organizationId ? sql`AND ${project.organizationId} = ${input.organizationId}` : sql``} + ${scope.organizationId !== undefined ? sql`AND ${project.organizationId} IS NOT DISTINCT FROM ${scope.organizationId}` : sql``} ${input.cursor ? sql`AND ${workspace.projectId} > ${input.cursor}` : sql``} ORDER BY 1 LIMIT ${input.limit + 1} diff --git a/apps/sim/lib/projects/files/__integration__/authorization.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts new file mode 100644 index 00000000000..5c76fac41ee --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts @@ -0,0 +1,1185 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import type { OAuthAccessTokenPrincipal, ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + copilotChats, + folder, + member, + organization, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + user, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { + createExecutorPrincipal, + createPersonalApiKeyPrincipal, + createSessionPrincipal, + createWorkspaceApiKeyPrincipal, +} from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DbTransaction } from '@/lib/db/types' +import { renameProject } from '@/lib/projects/application' +import { + createProjectFileFolder, + getProjectFileMetadata, + listProjectFileFolders, + listProjectFiles, + resolveProjectFileReference, + updateProjectFileFolder, +} from '@/lib/projects/files/application' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { resolveFileFolderTarget } from '@/lib/uploads/contexts/workspace' +import { resolveArchivedFileFolderScope } from '@/lib/uploads/contexts/workspace/file-folder-manager' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const readAccess = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.list, + async execute({ context }) { + return { projectId: context.projectId, canWrite: context.canWrite } + }, +}) +const writeAccess = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.create, + async execute({ context }) { + return { projectId: context.projectId } + }, +}) +const readFile = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readContent, + async execute({ context }) { + return { fileId: context.file?.id } + }, +}) +const resolveFolderTarget = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.create, + { projectId: string; folderId?: string | null; folderPath?: string }, + Awaited> +>({ + operation: projectFileOperations.create, + async execute({ + input, + context, + tx, + }: { + input: { projectId: string; folderId?: string | null; folderPath?: string } + context: { owner: { entityType: 'project'; entityId: string } } + tx: DbTransaction + }) { + return resolveFileFolderTarget(context.owner, input, tx) + }, +}) + +const fixtures: { + users: string[] + organizationId: string + projectId: string + workspaces: string[] +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const readerId = generateId() + const organizationId = generateId() + const projectId = generateId() + const workspaces = [generateId(), generateId()] + fixtures.push({ users: [ownerId, readerId], organizationId, projectId, workspaces }) + await db.insert(user).values( + [ownerId, readerId].map((id) => ({ + id, + name: 'Project file fixture', + email: `${id}@files.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Project files', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values([ + { id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() }, + { id: generateId(), organizationId, userId: readerId, role: 'member', createdAt: new Date() }, + ]) + await db.transaction(async (tx) => { + await tx + .insert(project) + .values({ id: projectId, organizationId, ownerId, name: 'Project files' }) + await tx.insert(workspace).values( + workspaces.map((id, index) => ({ + id, + organizationId, + ownerId, + billedAccountUserId: ownerId, + workspaceMode: 'organization' as const, + name: 'Environment', + projectId, + forkedFromWorkspaceId: index ? workspaces[0] : null, + })) + ) + }) + await db.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: workspaces[0], + userId: readerId, + permissionType: 'read', + }) + return { + ownerId, + readerId, + organizationId, + projectId, + workspaces, + reader: createSessionPrincipal({ userId: readerId }), + } +} + +async function grant( + userId: string, + workspaceId: string, + permissionType: 'read' | 'write' | 'admin' +) { + await db + .delete(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType, + }) +} + +function delegated( + userId: string, + projectId: string, + workspaceId: string +): Extract { + return { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: userId, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { kind: 'entity', entityType: 'project', entityId: projectId }, + invocation: { kind: 'workspace', workspaceId }, + } +} + +afterAll(async () => { + const reportPath = + process.env.PROJECT_FILE_AUTHORIZATION_REPORT_PATH ?? + resolve('test-results/project-file-authorization.json') + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) + for (const f of fixtures) { + const memberships = await db + .select({ projectId: workspace.projectId }) + .from(workspace) + .where(inArray(workspace.id, f.workspaces)) + const projectIds = [f.projectId, ...memberships.map((row) => row.projectId)] + await db.delete(workspaceFiles).where(inArray(workspaceFiles.projectId, projectIds)) + await db.delete(folder).where(inArray(folder.projectId, projectIds)) + await deleteWorkspaceFixture(db, inArray(workspace.id, f.workspaces)) + await db.delete(project).where(inArray(project.id, projectIds)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, f.users)) + } +}) + +describe('Project file authority at the database boundary', () => { + check('Project files follow the projects flag without a separate readiness switch', async () => { + const f = await fixture() + const request = { principal: f.reader, input: { projectId: f.projectId } } + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) + await expect(readAccess.execute(request)).rejects.toMatchObject({ statusCode: 503 }) + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + await expect(readAccess.execute(request)).resolves.toMatchObject({ projectId: f.projectId }) + }) + + check( + 'folder writes preserve hierarchy and attribution while enforcing writer authority', + async () => { + const f = await fixture() + const args = { principal: f.reader, input: { projectId: f.projectId, name: 'Docs' } } + await expect(createProjectFileFolder.execute(args)).rejects.toMatchObject({ + code: 'forbidden', + }) + await grant(f.readerId, f.workspaces[0], 'admin') + const { folder: root } = await createProjectFileFolder.execute(args) + const { folder: child } = await createProjectFileFolder.execute({ + ...args, + input: { ...args.input, name: 'Before', parentId: root.id }, + }) + const { folder: destination } = await createProjectFileFolder.execute({ + ...args, + input: { ...args.input, name: 'Resources' }, + }) + const result = await updateProjectFileFolder.execute({ + ...args, + input: { + projectId: f.projectId, + folderId: child.id, + name: 'Architecture', + parentId: destination.id, + }, + }) + expect(result.folder).toMatchObject({ + id: child.id, + owner: { entityType: 'project', entityId: f.projectId }, + userId: f.readerId, + parentId: destination.id, + path: 'Resources/Architecture', + }) + const listing = await listProjectFileFolders.execute({ + principal: f.reader, + input: { projectId: f.projectId }, + }) + expect(listing.folders.map((folder) => folder.id).sort()).toEqual( + [root.id, child.id, destination.id].sort() + ) + expect( + await resolveFolderTarget.execute({ + principal: f.reader, + input: { projectId: f.projectId, folderPath: '/Resources/Architecture' }, + }) + ).toMatchObject({ id: child.id }) + expect( + await resolveFolderTarget.execute({ + principal: f.reader, + input: { projectId: f.projectId, folderId: child.id }, + }) + ).toMatchObject({ id: child.id }) + expect( + await resolveFolderTarget.execute({ + principal: f.reader, + input: { projectId: f.projectId, folderPath: '/' }, + }) + ).toBeNull() + } + ) + + check( + 'folder mutations refuse foreign owners, duplicate siblings, cycles and ambiguous targets', + async () => { + const f = await fixture() + const other = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const principal = f.reader + const { folder: root } = await createProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, name: 'Docs' }, + }) + const { folder: child } = await createProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, name: 'Nested', parentId: root.id }, + }) + const { folder: foreign } = await createProjectFileFolder.execute({ + principal: createSessionPrincipal({ userId: other.ownerId }), + input: { projectId: other.projectId, name: 'Docs' }, + }) + await expect( + createProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, name: 'Docs' }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + await expect( + createProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, name: 'Invalid', parentId: foreign.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + for (const input of [ + { projectId: f.projectId, folderId: foreign.id, name: 'Invalid' }, + { projectId: f.projectId, folderId: child.id, parentId: foreign.id }, + ]) + await expect(updateProjectFileFolder.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + for (const parentId of [root.id, child.id]) + await expect( + updateProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, folderId: root.id, parentId }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + resolveFolderTarget.execute({ + principal, + input: { projectId: f.projectId, folderId: root.id, folderPath: '/Docs' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + resolveFolderTarget.execute({ + principal, + input: { projectId: f.projectId, folderPath: '/Missing' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + resolveFolderTarget.execute({ + principal, + input: { projectId: f.projectId, folderId: foreign.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + const listing = await listProjectFileFolders.execute({ + principal, + input: { projectId: f.projectId }, + }) + expect( + listing.folders.map((folder) => ({ id: folder.id, parentId: folder.parentId })) + ).toEqual( + expect.arrayContaining([ + { id: root.id, parentId: null }, + { id: child.id, parentId: root.id }, + ]) + ) + } + ) + + check( + 'file listing isolates owners and preserves pagination and nested folder attribution', + async () => { + const f = await fixture() + const other = await fixture() + const rootFileId = generateId() + const nestedFileId = generateId() + const folderId = generateId() + await db.insert(folder).values({ + id: folderId, + projectId: f.projectId, + resourceType: 'file', + name: 'Docs', + userId: f.ownerId, + }) + await db.insert(workspaceFiles).values([ + { + id: rootFileId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + key: `project/${f.projectId}/${rootFileId}`, + originalName: 'a.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: nestedFileId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + folderId, + key: `project/${f.projectId}/${nestedFileId}`, + originalName: 'b.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: generateId(), + userId: f.ownerId, + workspaceId: f.workspaces[0], + context: 'workspace', + key: `workspace/${f.workspaces[0]}/a.md`, + originalName: 'a.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: generateId(), + userId: other.ownerId, + projectId: other.projectId, + context: 'project', + key: `project/${other.projectId}/a.md`, + originalName: 'a.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + ]) + const input = { projectId: f.projectId, limit: 1, sortBy: 'name', sortOrder: 'asc' } as const + const first = await listProjectFiles.execute({ principal: f.reader, input }) + expect(first.capabilities).toEqual({ canRead: true, canWrite: false }) + expect(first.files.map((file) => file.id)).toEqual([rootFileId]) + expect(first.files[0]).toMatchObject({ + owner: { entityType: 'project', entityId: f.projectId }, + uploadedBy: f.ownerId, + folderId: null, + folderPath: null, + }) + if (!first.nextKeys) throw new Error('The next Project file page must be available') + const second = await listProjectFiles.execute({ + principal: f.reader, + input: { ...input, after: first.nextKeys }, + }) + expect(second.files.map((file) => file.id)).toEqual([nestedFileId]) + expect(second.files[0]).toMatchObject({ folderId, folderPath: 'Docs' }) + expect(second.nextKeys).toBeNull() + } + ) + + check('folder path pages preserve shallow, recursive, root, and missing-path scope', async () => { + const f = await fixture() + const docsId = generateId() + const nestedId = generateId() + const siblingId = generateId() + await db.insert(folder).values([ + { + id: docsId, + projectId: f.projectId, + resourceType: 'file', + name: 'Docs', + userId: f.ownerId, + }, + { + id: nestedId, + projectId: f.projectId, + resourceType: 'file', + name: 'Q3/Q4', + parentId: docsId, + userId: f.ownerId, + }, + { + id: siblingId, + projectId: f.projectId, + resourceType: 'file', + name: 'Elsewhere', + userId: f.ownerId, + }, + ]) + const fileIds = [generateId(), generateId(), generateId(), generateId()] + await db.insert(workspaceFiles).values( + fileIds.map((id, index) => ({ + id, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + folderId: [null, docsId, nestedId, siblingId][index], + key: `project/${f.projectId}/${id}`, + originalName: `${index}.md`, + contentType: 'text/markdown', + sizeBytes: 0, + })) + ) + const base = { projectId: f.projectId, limit: 100, sortBy: 'name', sortOrder: 'asc' } as const + const page = (filter: { folderPath?: string; recursive?: boolean; folderId?: string | null }) => + listProjectFiles.execute({ principal: f.reader, input: { ...base, ...filter } }) + expect((await page({ folderPath: '/Docs' })).files.map((file) => file.id)).toEqual([fileIds[1]]) + expect( + (await page({ folderPath: '/Docs', recursive: true })).files.map((file) => file.id) + ).toEqual([fileIds[1], fileIds[2]]) + expect((await page({ folderPath: '/Docs/Q3%2FQ4' })).files.map((file) => file.id)).toEqual([ + fileIds[2], + ]) + expect((await page({ folderPath: '/' })).files.map((file) => file.id)).toEqual([fileIds[0]]) + expect((await page({ folderPath: '/', recursive: true })).files.map((file) => file.id)).toEqual( + fileIds + ) + expect((await page({ recursive: false })).files.map((file) => file.id)).toEqual(fileIds) + expect(await page({ folderPath: '/Missing' })).toMatchObject({ files: [], nextKeys: null }) + await expect(page({ folderPath: '/Docs', folderId: siblingId })).rejects.toMatchObject({ + code: 'validation', + }) + const first = await listProjectFiles.execute({ + principal: f.reader, + input: { ...base, folderPath: '/Docs', recursive: true, limit: 1 }, + }) + if (!first.nextKeys) throw new Error('The nested folder page must have a cursor') + const next = await listProjectFiles.execute({ + principal: f.reader, + input: { ...base, folderPath: '/Docs', recursive: true, limit: 1, after: first.nextKeys }, + }) + expect(next.files.map((file) => file.id)).toEqual([fileIds[2]]) + expect(next.nextKeys).toBeNull() + expect( + (await listProjectFiles.execute({ principal: f.reader, input: { ...base, limit: 1000 } })) + .files + ).toHaveLength(4) + + const archivedAt = new Date() + await db.update(folder).set({ deletedAt: archivedAt }).where(eq(folder.id, nestedId)) + await db + .update(workspaceFiles) + .set({ deletedAt: archivedAt }) + .where(inArray(workspaceFiles.id, [fileIds[1], fileIds[2]])) + const archivedDocsId = generateId() + const replacementNestedId = generateId() + await db.insert(folder).values([ + { + id: archivedDocsId, + projectId: f.projectId, + resourceType: 'file', + name: 'Docs', + userId: f.ownerId, + deletedAt: archivedAt, + }, + { + id: replacementNestedId, + projectId: f.projectId, + resourceType: 'file', + name: 'Q3/Q4', + parentId: docsId, + userId: f.ownerId, + }, + ]) + const archivedIds = [generateId(), generateId()] + await db.insert(workspaceFiles).values( + archivedIds.map((id, index) => ({ + id, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + folderId: [archivedDocsId, replacementNestedId][index], + key: `project/${f.projectId}/${id}`, + originalName: `${index + 4}.md`, + contentType: 'text/markdown', + sizeBytes: 0, + deletedAt: archivedAt, + })) + ) + const archivedPage = (folderPath: string, recursive = false) => + listProjectFiles.execute({ + principal: f.reader, + input: { ...base, scope: 'archived', folderPath, recursive }, + }) + expect( + await resolveArchivedFileFolderScope( + { entityType: 'project', entityId: f.projectId }, + '/Docs', + false, + 2, + db + ) + ).toEqual(expect.arrayContaining([docsId, archivedDocsId])) + expect((await archivedPage('/Docs')).files.map((file) => file.id)).toEqual([ + fileIds[1], + archivedIds[0], + ]) + expect((await archivedPage('/Docs/Q3%2FQ4')).files.map((file) => file.id)).toEqual([ + fileIds[2], + archivedIds[1], + ]) + expect((await archivedPage('/Docs', true)).files.map((file) => file.id)).toEqual([ + fileIds[1], + fileIds[2], + ...archivedIds, + ]) + expect((await archivedPage('/Missing', true)).files).toEqual([]) + expect((await archivedPage('/')).files).toEqual([]) + expect((await archivedPage('/', true)).files.map((file) => file.id)).toEqual([ + fileIds[1], + fileIds[2], + ...archivedIds, + ]) + }) + + check( + 'file references resolve exact owner paths and conceal foreign or archived identities', + async () => { + const f = await fixture() + const other = await fixture() + const folderId = generateId() + await db.insert(folder).values({ + id: folderId, + projectId: f.projectId, + resourceType: 'file', + name: 'Q3/Q4', + userId: f.ownerId, + }) + const fileId = generateId() + const foreignId = generateId() + const archivedId = generateId() + await db.insert(workspaceFiles).values([ + { + id: fileId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + folderId, + key: `project/${f.projectId}/${fileId}`, + originalName: 'Architecture overview.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: foreignId, + userId: other.ownerId, + projectId: other.projectId, + context: 'project', + key: `project/${other.projectId}/${foreignId}`, + originalName: 'Architecture overview.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: archivedId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + key: `project/${f.projectId}/${archivedId}`, + originalName: 'old.md', + contentType: 'text/markdown', + sizeBytes: 0, + deletedAt: new Date(), + }, + ]) + const resolve = (fileReference: string) => + resolveProjectFileReference.execute({ + principal: f.reader, + input: { projectId: f.projectId, fileReference }, + }) + const vfsPath = `projects/${f.projectId}/files/Q3%2FQ4/Architecture%20overview.md` + for (const reference of [fileId, 'files/Q3%2FQ4/Architecture%20overview.md', vfsPath]) { + const result = await resolve(reference) + expect(result.file.id).toBe(fileId) + expect(result.file.path).toBe(`/api/projects/${f.projectId}/files/${fileId}/content`) + expect(result.vfsPath).toBe(vfsPath) + } + for (const name of ['content', 'meta.json']) { + await db + .update(workspaceFiles) + .set({ originalName: name }) + .where(eq(workspaceFiles.id, fileId)) + expect((await resolve(`files/Q3%2FQ4/${name}`)).file.id).toBe(fileId) + } + await db + .update(workspaceFiles) + .set({ originalName: 'Architecture overview.md' }) + .where(eq(workspaceFiles.id, fileId)) + for (const reference of [ + foreignId, + archivedId, + 'files/Q3%2FQ4/architecture%20overview.md', + 'files/Architecture%20overview.md', + ]) { + await expect(resolve(reference)).rejects.toMatchObject({ code: 'not_found' }) + } + for (const reference of [ + `projects/${other.projectId}/files/Architecture%20overview.md`, + `workspaces/${f.workspaces[0]}/files/example.md`, + 'files/../old.md', + 'files/Q3%2FQ4/%broken', + ]) { + await expect(resolve(reference)).rejects.toMatchObject({ code: 'validation' }) + } + } + ) + + check( + 'file metadata returns creator attribution and conceals a foreign Project file ID', + async () => { + const f = await fixture() + const other = await fixture() + const fileId = generateId() + const foreignFileId = generateId() + await db.insert(workspaceFiles).values([ + { + id: fileId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + key: `project/${f.projectId}/${fileId}`, + originalName: 'architecture.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: foreignFileId, + userId: other.ownerId, + projectId: other.projectId, + context: 'project', + key: `project/${other.projectId}/${foreignFileId}`, + originalName: 'architecture.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + ]) + const result = await getProjectFileMetadata.execute({ + principal: f.reader, + input: { projectId: f.projectId, fileId }, + }) + expect(result.capabilities).toEqual({ canRead: true, canWrite: false }) + expect(result.file).toMatchObject({ + id: fileId, + owner: { entityType: 'project', entityId: f.projectId }, + name: 'architecture.md', + uploadedBy: f.ownerId, + }) + await expect( + getProjectFileMetadata.execute({ + principal: f.reader, + input: { projectId: f.projectId, fileId: foreignFileId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check('partial readers can read but cannot write or administer the Project', async () => { + const f = await fixture() + const args = { principal: f.reader, input: { projectId: f.projectId } } + expect(await readAccess.execute(args)).toEqual({ projectId: f.projectId, canWrite: false }) + await expect(writeAccess.execute(args)).rejects.toMatchObject({ code: 'forbidden' }) + await grant(f.readerId, f.workspaces[0], 'admin') + expect(await writeAccess.execute(args)).toEqual({ projectId: f.projectId }) + await expect( + renameProject.execute({ + ...args, + input: { projectId: f.projectId, name: 'Forbidden rename' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + }) + + check( + 'write requires every active environment unless an environment admin or org admin', + async () => { + const f = await fixture() + const args = { principal: f.reader, input: { projectId: f.projectId } } + await grant(f.readerId, f.workspaces[0], 'write') + await expect(writeAccess.execute(args)).rejects.toMatchObject({ code: 'forbidden' }) + await grant(f.readerId, f.workspaces[1], 'write') + expect(await writeAccess.execute(args)).toEqual({ projectId: f.projectId }) + await grant(f.readerId, f.workspaces[1], 'read') + await expect(writeAccess.execute(args)).rejects.toMatchObject({ code: 'forbidden' }) + expect( + await writeAccess.execute({ + ...args, + principal: createSessionPrincipal({ userId: f.ownerId }), + }) + ).toEqual({ projectId: f.projectId }) + await grant(f.readerId, f.workspaces[1], 'admin') + await db + .update(workspace) + .set({ archivedAt: new Date() }) + .where(eq(workspace.id, f.workspaces[1])) + expect(await writeAccess.execute(args)).toEqual({ projectId: f.projectId }) + await grant(f.readerId, f.workspaces[0], 'read') + expect(await readAccess.execute(args)).toEqual({ projectId: f.projectId, canWrite: false }) + await expect(writeAccess.execute(args)).rejects.toMatchObject({ code: 'forbidden' }) + await grant(f.readerId, f.workspaces[0], 'admin') + expect(await writeAccess.execute(args)).toEqual({ projectId: f.projectId }) + await db.transaction(async (tx) => { + await tx + .update(workspace) + .set({ archivedAt: new Date() }) + .where(inArray(workspace.id, f.workspaces)) + await tx.update(project).set({ archivedAt: new Date() }).where(eq(project.id, f.projectId)) + }) + await expect( + writeAccess.execute({ ...args, principal: createSessionPrincipal({ userId: f.ownerId }) }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) + + check( + 'credential policy aggregates accessible environments and rechecks OAuth scope', + async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const principal = createPersonalApiKeyPrincipal({ userId: f.readerId }) + const input = { projectId: f.projectId } + await db + .update(workspace) + .set({ allowPersonalApiKeys: false }) + .where(eq(workspace.id, f.workspaces[1])) + expect(await writeAccess.execute({ principal, input })).toEqual({ projectId: f.projectId }) + await grant(f.readerId, f.workspaces[1], 'read') + await expect(writeAccess.execute({ principal, input })).rejects.toMatchObject({ + detailCode: 'PERSONAL_API_KEYS_DISABLED', + }) + const oauth: OAuthAccessTokenPrincipal = { + kind: 'oauth_access_token', + userId: f.readerId, + clientId: 'fixture-client', + tokenId: generateId(), + scopes: ['api:read'], + expiresAt: new Date(Date.now() + 60_000), + } + await expect(writeAccess.execute({ principal: oauth, input })).rejects.toMatchObject({ + detailCode: 'INSUFFICIENT_SCOPE', + }) + } + ) + + check( + 'Files permission policy aggregates visible environments without importing the Issues gate', + async () => { + const f = await fixture() + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'Files disabled', + config: { hideFilesTab: true }, + membershipMode: 'inherit', + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: f.workspaces[1], + organizationId: f.organizationId, + }) + const args = { principal: f.reader, input: { projectId: f.projectId } } + expect(await readAccess.execute(args)).toEqual({ projectId: f.projectId, canWrite: false }) + await grant(f.readerId, f.workspaces[1], 'read') + await expect(readAccess.execute(args)).rejects.toMatchObject({ + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + await db + .update(permissionGroup) + .set({ config: { deniedPartialAccessProjectIssues: [f.projectId] } }) + .where(eq(permissionGroup.id, groupId)) + expect(await readAccess.execute(args)).toEqual({ projectId: f.projectId, canWrite: false }) + } + ) + + check( + 'workspace keys and executor authority cannot be upgraded into Project access', + async () => { + const f = await fixture() + const input = { projectId: f.projectId } + for (const principal of [ + createWorkspaceApiKeyPrincipal({ workspaceId: f.workspaces[0] }), + createExecutorPrincipal({ subjectUserId: f.ownerId, workspaceId: f.workspaces[0] }), + ]) { + await expect(readAccess.execute({ principal, input })).rejects.toMatchObject({ + detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED', + }) + } + } + ) + + check( + 'Copilot delegation binds its current environment and rejects revoked access or forged scope', + async () => { + const f = await fixture() + const principal = delegated(f.readerId, f.projectId, f.workspaces[0]) + const input = { projectId: f.projectId } + expect(await readAccess.execute({ principal, input })).toEqual({ + projectId: f.projectId, + canWrite: false, + }) + await expect( + readAccess.execute({ principal: { ...principal, expiresAt: new Date(0) }, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + const fileId = generateId() + await expect( + readAccess.execute({ + principal: { + ...principal, + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId, fileId }, + }, + input: { ...input, fileId }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + readAccess.execute({ + principal: { + ...principal, + scope: { kind: 'entity', entityType: 'project', entityId: generateId() }, + }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await db.delete(permissions).where(eq(permissions.userId, f.readerId)) + await expect(readAccess.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + } + ) + + check( + 'organization conversations can reach authorized Projects without selecting an environment', + async () => { + const f = await fixture() + const chatId = generateId() + await db.insert(copilotChats).values({ + id: chatId, + userId: f.readerId, + organizationId: f.organizationId, + workspaceId: null, + type: 'mothership', + config: { conversationMode: 'agent' }, + title: 'Project context', + }) + const principal: ResourceDelegatedPrincipal = { + ...delegated(f.readerId, f.projectId, f.workspaces[0]), + serviceId: 'copilot', + invocation: { kind: 'chat', chatId }, + } + expect(await readAccess.execute({ principal, input: { projectId: f.projectId } })).toEqual({ + projectId: f.projectId, + canWrite: false, + }) + await db + .delete(member) + .where(and(eq(member.userId, f.readerId), eq(member.organizationId, f.organizationId))) + await expect( + readAccess.execute({ principal, input: { projectId: f.projectId } }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check('checked environment key policy cannot change before a file mutation commits', async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const entered = createDeferred() + const release = createDeferred() + const mutation = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.create, + async execute() { + entered.resolve() + await release.promise + }, + }).execute({ + principal: createPersonalApiKeyPrincipal({ userId: f.readerId }), + input: { projectId: f.projectId }, + }) + await entered.promise + try { + const policyUpdate = db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '100ms'`) + await tx + .update(workspace) + .set({ allowPersonalApiKeys: false }) + .where(eq(workspace.id, f.workspaces[0])) + }) + expect(await policyUpdate.then(() => null, getPostgresErrorCode)).toBe('55P03') + } finally { + release.resolve() + await mutation + } + }) + + check( + 'file-bound realtime grants cannot become listing authority or cross-file reads', + async () => { + const f = await fixture() + const fileId = generateId() + const folderId = generateId() + await db.insert(folder).values({ + id: folderId, + userId: f.ownerId, + projectId: f.projectId, + resourceType: 'file', + name: 'Architecture', + }) + await db.insert(workspaceFiles).values({ + id: fileId, + projectId: f.projectId, + userId: f.ownerId, + context: 'project', + folderId, + key: `project/${f.projectId}/${fileId}`, + originalName: 'architecture.md', + contentType: 'text/markdown', + sizeBytes: 0, + }) + const principal: ResourceDelegatedPrincipal = { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId: f.readerId, + delegationId: generateId(), + audience: 'sim:project-files', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId, fileId }, + invocation: { kind: 'realtime', connectionId: 'fixture-connection' }, + } + expect( + await readFile.execute({ principal, input: { projectId: f.projectId, fileId } }) + ).toEqual({ fileId }) + await expect( + readAccess.execute({ principal, input: { projectId: f.projectId } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + readFile.execute({ principal, input: { projectId: f.projectId, fileId: generateId() } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } + ) +}) + +describe('shared file identity and current actor authority', () => { + check('creator attribution grants neither scope access nor access after departure', async () => { + const f = await fixture() + const workspaceFileId = generateId() + const projectFileId = generateId() + await db.insert(workspaceFiles).values([ + { + id: workspaceFileId, + userId: f.readerId, + workspaceId: f.workspaces[0], + context: 'workspace', + key: `workspace/${f.workspaces[0]}/${workspaceFileId}`, + originalName: 'workspace.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: projectFileId, + userId: f.readerId, + projectId: f.projectId, + context: 'project', + key: `project/${f.projectId}/${projectFileId}`, + originalName: 'project.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + ]) + const workspaceArgs = { principal: f.reader, input: { fileId: workspaceFileId } } + const projectArgs = { + principal: f.reader, + input: { projectId: f.projectId, fileId: projectFileId }, + } + await readWorkspaceFileMetadata.authorize(workspaceArgs) + await getProjectFileMetadata.authorize(projectArgs) + await expect( + readWorkspaceFileMetadata.authorize({ + ...workspaceArgs, + input: { fileId: workspaceFileId, assertedWorkspaceId: f.workspaces[1] }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + readWorkspaceFileMetadata.authorize({ ...workspaceArgs, input: { fileId: projectFileId } }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + getProjectFileMetadata.authorize({ + ...projectArgs, + input: { projectId: f.projectId, fileId: workspaceFileId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db.delete(permissions).where(eq(permissions.userId, f.readerId)) + await expect(readWorkspaceFileMetadata.authorize(workspaceArgs)).rejects.toMatchObject({ + code: 'forbidden', + }) + await expect(getProjectFileMetadata.authorize(projectArgs)).rejects.toMatchObject({ + code: 'not_found', + }) + const collaborator = createSessionPrincipal({ userId: f.ownerId }) + await readWorkspaceFileMetadata.authorize({ ...workspaceArgs, principal: collaborator }) + await getProjectFileMetadata.authorize({ ...projectArgs, principal: collaborator }) + const rows = await db + .select({ userId: workspaceFiles.userId }) + .from(workspaceFiles) + .where(inArray(workspaceFiles.id, [workspaceFileId, projectFileId])) + expect(rows.map((row) => row.userId)).toEqual([f.readerId, f.readerId]) + }) + + check( + 'workspace metadata keeps deleted-file opt-in separate from workspace archival', + async () => { + const f = await fixture() + const fileId = generateId() + await db.insert(workspaceFiles).values({ + id: fileId, + userId: f.ownerId, + workspaceId: f.workspaces[0], + context: 'workspace', + key: `workspace/${f.workspaces[0]}/${fileId}`, + originalName: 'archived.md', + contentType: 'text/markdown', + sizeBytes: 0, + deletedAt: new Date(), + }) + const args = { principal: f.reader, input: { fileId } } + await expect(readWorkspaceFileMetadata.authorize(args)).rejects.toMatchObject({ + code: 'not_found', + }) + await readWorkspaceFileMetadata.authorize({ + ...args, + input: { fileId, includeDeleted: true }, + }) + await db + .update(workspace) + .set({ archivedAt: new Date() }) + .where(eq(workspace.id, f.workspaces[0])) + await expect( + readWorkspaceFileMetadata.authorize({ ...args, input: { fileId, includeDeleted: true } }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'a ban or permission revocation during preparation prevents a committed write', + async () => { + for (const revoke of ['permission', 'ban'] as const) { + const f = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const folderId = generateId() + const mutation = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.createFolder, + async prepare() { + if (revoke === 'ban') + await db.update(user).set({ banned: true }).where(eq(user.id, f.readerId)) + else await grant(f.readerId, f.workspaces[0], 'read') + return folderId + }, + async execute({ tx, prepared }) { + if (!prepared) throw new Error('Prepared folder ID missing') + await tx.insert(folder).values({ + id: prepared, + projectId: f.projectId, + userId: f.readerId, + resourceType: 'file', + name: 'Must not commit', + }) + }, + }) + await expect( + mutation.execute({ principal: f.reader, input: { projectId: f.projectId } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect( + await db.select({ id: folder.id }).from(folder).where(eq(folder.id, folderId)) + ).toEqual([]) + } + } + ) +}) diff --git a/apps/sim/lib/projects/files/__integration__/browser.integration.ts b/apps/sim/lib/projects/files/__integration__/browser.integration.ts new file mode 100644 index 00000000000..e04088b9e84 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/browser.integration.ts @@ -0,0 +1,333 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { db, runOutsideTransactionContext } from '@sim/db' +import { folder, permissions, project, user, workspace, workspaceFiles } from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { listProjectFileItems } from '@/lib/projects/files/application' +import { deleteUserAccount } from '@/lib/users/account-deletion' +import type { FileBrowserQuery } from '@/lib/workspace-files/browser-query' +import * as browserQuery from '@/lib/workspace-files/browser-query' + +interface BrowserFixture { + workspaceId: string + projectId?: string + users: string[] + fileIds: string[] + folderIds: string[] +} +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const fixtures: BrowserFixture[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') +}) +function check(name: string, run: () => Promise) { + it(name, async () => { + const start = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + throw error + } + }) +} +async function fixture() { + const ownerId = generateId() + const readerId = generateId() + const creatorId = generateId() + const workspaceId = generateId() + const owned: BrowserFixture = { + workspaceId, + users: [ownerId, readerId, creatorId], + fileIds: [], + folderIds: [], + } + fixtures.push(owned) + await db.insert(user).values( + [ownerId, readerId, creatorId].map((id, index) => ({ + id, + name: ['Owner', 'Reader', 'Other creator'][index], + email: `${id}@browser.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'File browser fixture', + ownerId, + billedAccountUserId: ownerId, + workspaceMode: 'personal', + }) + const [membership] = await db + .select({ projectId: workspace.projectId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + if (!membership) throw new Error('Fixture Project missing') + const projectId = membership.projectId + owned.projectId = projectId + await db.insert(permissions).values([ + { + id: generateId(), + userId: ownerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }, + { + id: generateId(), + userId: readerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'read', + }, + ]) + const reader = createSessionPrincipal({ userId: readerId }) + const list = (input: Partial = {}) => + listProjectFileItems.execute({ + principal: reader, + input: { projectId, scope: 'active', sortBy: 'name', sortOrder: 'asc', limit: 100, ...input }, + }) + const addFile = async ( + name: string, + size: number, + options: { folderId?: string; userId?: string; type?: string; archived?: boolean } = {} + ) => { + const id = generateId() + owned.fileIds.push(id) + await db.insert(workspaceFiles).values({ + id, + projectId: projectId, + context: 'project', + workspaceId: null, + userId: options.userId ?? ownerId, + folderId: options.folderId, + key: `project/${projectId}/${id}`, + originalName: name, + contentType: options.type ?? 'application/octet-stream', + sizeBytes: size, + deletedAt: options.archived ? new Date() : null, + }) + return id + } + const addFolder = async (name: string, parentId?: string, userId = ownerId) => { + const id = generateId() + owned.folderIds.push(id) + await db.insert(folder).values({ + id, + projectId: projectId, + workspaceId: null, + resourceType: 'file', + name, + userId, + parentId, + }) + return id + } + return { ownerId, readerId, creatorId, workspaceId, projectId, reader, list, addFile, addFolder } +} +afterAll(async () => { + const cleanup: { workspaceId: string; status: 'passed' | 'failed'; error?: string }[] = [] + try { + for (const fixture of fixtures) { + try { + await db.transaction(async (tx) => { + if (fixture.projectId) { + if (fixture.fileIds.length) + await tx + .delete(workspaceFiles) + .where( + and( + eq(workspaceFiles.projectId, fixture.projectId), + inArray(workspaceFiles.id, fixture.fileIds) + ) + ) + if (fixture.folderIds.length) + await tx + .delete(folder) + .where( + and( + eq(folder.projectId, fixture.projectId), + inArray(folder.id, fixture.folderIds) + ) + ) + } + await deleteWorkspaceFixture(tx, eq(workspace.id, fixture.workspaceId)) + if (fixture.projectId) { + await tx.delete(project).where(eq(project.id, fixture.projectId)) + } + await tx.delete(user).where(inArray(user.id, fixture.users)) + }) + cleanup.push({ workspaceId: fixture.workspaceId, status: 'passed' }) + } catch (error) { + cleanup.push({ + workspaceId: fixture.workspaceId, + status: 'failed', + error: getErrorMessage(error), + }) + } + } + } finally { + const report = process.env.PROJECT_FILE_BROWSER_REPORT_PATH + if (report) { + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks, cleanup }, null, 2)) + } + } + const failures = cleanup.filter((result) => result.status === 'failed') + if (failures.length) + throw new Error(`Browser fixture cleanup failed: ${JSON.stringify(failures)}`) +}) + +describe('Project browser real mixed collection', () => { + check('file records preserve page order and snapshot through a concurrent archive', async () => { + const f = await fixture() + const small = await f.addFile('a-small.txt', 1) + const large = await f.addFile('z-large.txt', 20) + const original = browserQuery.queryFileBrowserItems + const query = vi + .spyOn(browserQuery, 'queryFileBrowserItems') + .mockImplementationOnce(async (...args) => { + const page = await original(...args) + await runOutsideTransactionContext(() => + db + .update(workspaceFiles) + .set({ deletedAt: new Date() }) + .where(eq(workspaceFiles.id, large)) + ) + return page + }) + try { + const page = await f.list({ sortBy: 'size', sortOrder: 'desc' }) + expect(page.items.map((item) => item.id)).toEqual([large, small]) + expect(page.files.map((file) => file.id)).toEqual([large, small]) + expect((await f.list()).files.map((file) => file.id)).toEqual([small]) + } finally { + query.mockRestore() + } + }) + + check( + 'type, size and creator predicates select the complete collection before pagination', + async () => { + const f = await fixture() + for (let index = 0; index < 5; index++) await f.addFile(`a${index}.txt`, 1) + const expected = await f.addFile('z-image.png', 1_048_576, { userId: f.creatorId }) + const page = await f.list({ + types: ['image'], + sizes: ['medium'], + creatorIds: [f.creatorId], + limit: 1, + }) + expect(page.items.map((item) => item.id)).toEqual([expected]) + expect(page.files.map((file) => file.id)).toEqual([expected]) + expect(page.nextKeys).toBeNull() + expect(page.capabilities.canWrite).toBe(false) + } + ) + check('effective MIME and exact size boundaries retain workspace product semantics', async () => { + const f = await fixture() + const small = await f.addFile('small.webm', 1_048_575) + const minimum = await f.addFile('minimum.webm', 1_048_576) + const maximum = await f.addFile('maximum.webm', 10_485_760) + const large = await f.addFile('large.webm', 10_485_761) + expect( + (await f.list({ types: ['video'], sizes: ['small'] })).items.map((item) => item.id) + ).toEqual([small]) + expect( + (await f.list({ types: ['video'], sizes: ['medium'] })).items.map((item) => item.id).sort() + ).toEqual([minimum, maximum].sort()) + expect( + (await f.list({ types: ['video'], sizes: ['large'] })).items.map((item) => item.id) + ).toEqual([large]) + expect((await f.list({ types: ['audio'] })).items).toEqual([]) + const audio = await f.addFile('voice.bin', 10, { type: 'Audio/WebM; codecs=opus' }) + const video = await f.addFile('movie.bin', 10, { type: 'video/mp4; codecs=avc1' }) + const image = await f.addFile('picture.bin', 10, { type: 'IMAGE/PNG' }) + expect((await f.list({ types: ['audio'] })).files.map((file) => file.id)).toEqual([audio]) + expect((await f.list({ types: ['video'] })).files.map((file) => file.id)).toContain(video) + expect((await f.list({ types: ['image'] })).files.map((file) => file.id)).toEqual([image]) + }) + check( + 'mixed size pages keep ascending name ties in both directions and include descendant rollup', + async () => { + const f = await fixture() + const a = await f.addFolder('a-folder') + const child = await f.addFolder('child', a) + await f.addFile('nested.txt', 10, { folderId: child }) + await f.addFile('ignored.txt', 1000, { folderId: child, archived: true }) + const b = await f.addFile('b-file.txt', 10) + const c = await f.addFile('c-file.txt', 20) + for (const sortOrder of ['asc', 'desc'] as const) { + const ids: string[] = [] + let after: FileBrowserQuery['after'] + for (let index = 0; index < 4; index++) { + const page = await f.list({ folderId: null, sortBy: 'size', sortOrder, limit: 1, after }) + ids.push(...page.items.map((item) => item.id)) + if (!page.nextKeys) break + after = page.nextKeys + } + expect(ids).toEqual(sortOrder === 'asc' ? [a, b, c] : [c, a, b]) + } + } + ) + check( + 'creator handoff updates filter choices and preserves file and folder pagination', + async () => { + const f = await fixture() + const other = await fixture() + await other.addFile('unrelated.png', 1, { userId: other.creatorId }) + const deleted = await f.addFile('deleted.txt', 1, { userId: f.creatorId }) + const deletedFolder = await f.addFolder('deleted-folder', undefined, f.creatorId) + const live = await f.addFile('live.txt', 1) + await deleteUserAccount(f.creatorId) + const page = await f.list() + expect(page.items.find((item) => item.id === deleted)?.creator).toMatchObject({ + id: f.ownerId, + }) + expect(page.items.find((item) => item.id === deletedFolder)?.creator).toMatchObject({ + id: f.ownerId, + }) + expect(page.files.find((file) => file.id === deleted)?.uploadedBy).toBe(f.ownerId) + expect(page.creators.map((creator) => creator.id)).toEqual([f.ownerId]) + expect(page.creators.some((creator) => creator.id === other.creatorId)).toBe(false) + expect((await f.list({ creatorIds: [f.creatorId] })).files).toEqual([]) + for (const sortOrder of ['asc', 'desc'] as const) { + const ids: string[] = [] + let after: FileBrowserQuery['after'] + for (let index = 0; index < 4; index++) { + const page = await f.list({ sortBy: 'owner', sortOrder, limit: 1, after }) + ids.push(...page.items.map((item) => item.id)) + if (!page.nextKeys) break + after = page.nextKeys + } + expect(ids).toEqual([deletedFolder, deleted, live]) + } + } + ) + check('live read revocation denies both item rows and creator metadata', async () => { + const f = await fixture() + await f.addFile('readable.txt', 1) + expect((await f.list()).items).toHaveLength(1) + await db + .delete(permissions) + .where(and(eq(permissions.userId, f.readerId), eq(permissions.entityId, f.workspaceId))) + await expect(f.list()).rejects.toMatchObject({ code: 'not_found' }) + }) +}) diff --git a/apps/sim/lib/projects/files/__integration__/content.integration.ts b/apps/sim/lib/projects/files/__integration__/content.integration.ts new file mode 100644 index 00000000000..46de0b30753 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -0,0 +1,1661 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + folder, + idempotencyKey, + member, + organization, + outboxEvent, + permissions, + subscription, + user, + workspace, + workspaceFileSecretProvenance, + workspaceFiles, + workspaceFileVersion, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { emailMailerMock } from '@sim/testing/mocks/email-mailer.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) +vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) + +import * as tracking from '@/lib/billing/storage/tracking' +import { + ContentDeliveryUnobservedError, + ContentRefusedError, + observeContentDelivery, +} from '@/lib/core/application/content-delivery' +import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { processOutboxEventById } from '@/lib/core/outbox/service' +import { + createProjectFile, + downloadProjectFileContent, + readProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import { createProjectFileFolder } from '@/lib/projects/files/application/folders' +import { + downloadProjectFileVersionContent, + readProjectFileVersionContent, +} from '@/lib/projects/files/application/versions' +import * as storageCleanup from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox' +import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox' +import * as storage from '@/lib/uploads/core/storage-service' +import { deleteUserAccount } from '@/lib/users/account-deletion' +import { observeWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { verifyFileAccess } from '@/app/api/files/authorization' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const localStorageRoot = mkdtempSync(join(tmpdir(), 'sim-project-content-')) +setUploadDirServer(localStorageRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + vi.restoreAllMocks() + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@content.invalid`, + name: 'File content fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Content', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Content environment', + }) + const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) + if (!binding) throw new Error('Project fixture missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + const ids = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(ids) + return { ...ids, principal: createSessionPrincipal({ userId: editorId }) } +} + +function createInput(projectId: string, content = 'Project architecture') { + return { + projectId, + name: 'architecture.md', + contentType: 'text/markdown', + content, + encoding: 'utf-8' as const, + exactName: true, + } +} + +async function keys(projectId: string) { + return readdir(join(localStorageRoot, 'project', projectId)).catch(() => []) +} + +async function ledger(organizationId: string) { + const [row] = await db + .select({ bytes: organization.storageUsedBytes }) + .from(organization) + .where(eq(organization.id, organizationId)) + return row?.bytes +} + +async function rows(projectId: string) { + return db.select().from(workspaceFiles).where(eq(workspaceFiles.projectId, projectId)) +} + +describe('Project file content against PostgreSQL and the local object store', () => { + for (const mutation of ['upload', 'update'] as const) { + check( + `workspace ${mutation} waits for Project authority before locking shared file resources`, + async () => { + const { uploadWorkspaceFile, updateWorkspaceFileContent } = await import( + '@/lib/uploads/contexts/workspace/file-manager' + ) + const { defineAuthorizedProjectFileUseCase } = await import( + '@/lib/projects/files/application/authorized-use-case' + ) + const { projectFileOperations } = await import( + '@/lib/projects/files/application/operations' + ) + const f = await fixture() + const initial = await uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.from('old'), + 'original.txt', + 'text/plain', + { notifyWorkspaceChange: false } + ) + const ready = createDeferred() + const release = createDeferred() + const authority = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.create, + async execute({ tx }) { + const [connection] = await tx.execute<{ pid: number }>( + sql`SELECT pg_backend_pid() AS pid` + ) + ready.resolve(connection.pid) + await release.promise + }, + }).execute({ principal: f.principal, input: { projectId: f.projectId } }) + const pid = await Promise.race([ + ready.promise, + authority.then(() => { + throw new Error('Authority exited early') + }), + ]) + const pending = + mutation === 'upload' + ? uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.from('next'), + 'new.txt', + 'text/plain', + { notifyWorkspaceChange: false } + ) + : updateWorkspaceFileContent( + f.workspaceId, + initial.id, + f.editorId, + Buffer.from('next'), + 'text/plain', + { + version: { source: 'user', authorUserId: f.editorId }, + syncLiveDoc: false, + } + ) + const observed = pending.then( + () => null, + (error: unknown) => error + ) + try { + let waiting: string | null = null + for (let attempt = 0; attempt < 100; attempt++) { + const [state] = await db.execute<{ wait_event: string | null }>(sql` + SELECT wait_event FROM pg_stat_activity + WHERE ${pid} = ANY(pg_blocking_pids(pid)) AND wait_event_type = 'Lock' + LIMIT 1 + `) + if (state?.wait_event) { + waiting = state.wait_event + break + } + await sleep(10) + } + expect(waiting).toBe('advisory') + } finally { + release.resolve() + await authority + expect(await observed).toBeNull() + } + expect(await ledger(f.organizationId)).toBe(mutation === 'upload' ? 7 : 4) + } + ) + } + + check( + 'workspace copy billing resolves the current plan within its owning transaction', + async () => { + const { resolveStorageBillingContext } = await import('@/lib/billing/storage/context') + const f = await fixture() + try { + await db.transaction(async (tx) => { + await tx.insert(subscription).values({ + id: generateId(), + referenceId: f.organizationId, + plan: 'enterprise', + status: 'active', + metadata: { customStorageLimitGB: 42 }, + }) + const billing = await resolveStorageBillingContext(f.workspaceId, tx) + expect(billing).toMatchObject({ + billingEntity: { type: 'organization', id: f.organizationId }, + plan: 'enterprise', + customStorageLimitGB: 42, + }) + }) + } finally { + await db.delete(subscription).where(eq(subscription.referenceId, f.organizationId)) + } + } + ) + + check( + 'a lost staging response retains a durable cleanup record before any file is admitted', + async () => { + const f = await fixture() + const upload = storage.uploadFile + let attemptedKey = '' + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const uploaded = await upload(options) + attemptedKey = uploaded.key + throw new Error('Lost provider response') + }) + vi.spyOn(storage, 'deleteFile').mockRejectedValueOnce(new Error('Cleanup unavailable')) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toThrow('Lost provider response') + expect(await rows(f.projectId)).toEqual([]) + expect(await ledger(f.organizationId)).toBe(0) + const [event] = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' = ${attemptedKey}`) + expect(event?.payload).toMatchObject({ key: attemptedKey, context: 'project' }) + expect(event.status).toBe('pending') + expect((await readFile(join(localStorageRoot, attemptedKey))).length).toBeGreaterThan(0) + await db + .update(outboxEvent) + .set({ availableAt: new Date(0) }) + .where(eq(outboxEvent.id, event.id)) + await expect( + processOutboxEventById(event.id, workspaceFileStorageCleanupOutboxHandlers) + ).resolves.toBe('completed') + await expect(readFile(join(localStorageRoot, attemptedKey))).rejects.toMatchObject({ + code: 'ENOENT', + }) + } + ) + + check( + 'Project history preserves source provenance and pagination through a version revert', + async () => { + const history = await import('@/lib/projects/files/application/versions') + const f = await fixture() + const secret = { + status: 'exact' as const, + entries: [ + { + name: 'TOKEN', + encryptedValue: 'encrypted-fixture', + sourceUserId: f.editorId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const created = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'classified original'), secretProvenance: secret }, + }) + const target = { projectId: f.projectId, fileId: created.file.id } + const changed = await updateProjectFileContent.execute({ + principal: f.principal, + input: { ...target, content: 'new', encoding: 'utf-8' }, + }) + const page = await history.listProjectFileVersions.execute({ + principal: f.principal, + input: { ...target, sortOrder: 'desc', limit: 1 }, + }) + expect(page.versions.map((version) => version.version)).toEqual([2]) + expect(page.nextKeys).not.toBeNull() + const older = await history.listProjectFileVersions.execute({ + principal: f.principal, + input: { ...target, sortOrder: 'desc', limit: 1, after: page.nextKeys ?? undefined }, + }) + expect(older.versions.map((version) => version.version)).toEqual([1]) + const read = await history.readProjectFileVersionContent.execute({ + principal: f.principal, + input: { ...target, version: 1 }, + }) + expect(read.content.toString()).toBe('classified original') + expect(read.secretProvenance).toEqual(secret) + const reverted = await history.revertProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 1, expectedCurrentVersion: 2 }, + }) + expect(reverted).toMatchObject({ + reverted: true, + revertedFrom: 2, + version: { + version: 3, + source: 'revert', + restoredFromVersion: 1, + authorUserIds: [f.editorId], + }, + }) + const current = await readProjectFileContent.execute({ + principal: f.principal, + input: { ...target, includeSecretProvenance: true }, + }) + expect(current.secretProvenance).toEqual(secret) + expect(current.content.toString()).toBe('classified original') + expect(await ledger(f.organizationId)).toBe(current.content.length) + expect(current.file.key).not.toBe(changed.file.key) + const noOp = await history.revertProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 3, expectedCurrentVersion: 3 }, + }) + expect(noOp.reverted).toBe(false) + expect(noOp.file.key).toBe(current.file.key) + const other = await fixture() + await expect( + history.listProjectFileVersions.execute({ + principal: f.principal, + input: { + projectId: other.projectId, + fileId: created.file.id, + sortOrder: 'desc', + limit: 1, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'a Project revert refuses a concurrent edit after staging and releases only its orphan', + async () => { + const history = await import('@/lib/projects/files/application/versions') + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'first'), + }) + const target = { projectId: f.projectId, fileId: created.file.id } + await updateProjectFileContent.execute({ + principal: f.principal, + input: { ...target, content: 'second', encoding: 'utf-8' }, + }) + const upload = storage.uploadFile + let orphanKey = '' + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const staged = await upload(options) + orphanKey = staged.key + await updateProjectFileContent.execute({ + principal: f.principal, + input: { ...target, content: 'concurrent winner', encoding: 'utf-8' }, + }) + return staged + }) + await expect( + history.revertProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 1, expectedCurrentVersion: 2 }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(orphanKey).not.toBe('') + await expect(readFile(join(localStorageRoot, orphanKey))).rejects.toMatchObject({ + code: 'ENOENT', + }) + const current = await readProjectFileContent.execute({ + principal: f.principal, + input: target, + }) + expect(current.content.toString()).toBe('concurrent winner') + expect(await ledger(f.organizationId)).toBe(current.content.length) + expect( + ( + await history.listProjectFileVersions.execute({ + principal: f.principal, + input: { ...target, limit: 10, sortOrder: 'desc' }, + }) + ).versions + ).toHaveLength(3) + } + ) + + check( + 'Project history deletion protects the current version and durably cleans superseded bytes', + async () => { + const history = await import('@/lib/projects/files/application/versions') + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'first'), + }) + const target = { projectId: f.projectId, fileId: created.file.id } + await updateProjectFileContent.execute({ + principal: f.principal, + input: { ...target, content: 'second', encoding: 'utf-8' }, + }) + await expect( + history.deleteProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 2 }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + vi.spyOn(storage, 'deleteFile').mockRejectedValueOnce(new Error('provider unavailable')) + await history.deleteProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 1 }, + }) + await expect( + history.readProjectFileVersionContent.execute({ + principal: f.principal, + input: { ...target, version: 1 }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + const events = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' = ${created.file.key}`) + expect(events).toHaveLength(1) + expect(events[0].payload).toMatchObject({ context: 'project', key: created.file.key }) + expect(await readFile(join(localStorageRoot, created.file.key), 'utf8')).toBe('first') + await db + .update(outboxEvent) + .set({ availableAt: new Date(0) }) + .where(eq(outboxEvent.id, events[0].id)) + await expect( + processOutboxEventById(events[0].id, workspaceFileStorageCleanupOutboxHandlers) + ).resolves.toBe('completed') + await expect(readFile(join(localStorageRoot, created.file.key))).rejects.toMatchObject({ + code: 'ENOENT', + }) + expect(await ledger(f.organizationId)).toBe(6) + } + ) + + check( + 'archived Project history releases bytes with canonical storage context in the purge transaction', + async () => { + const { releaseWorkspaceFileVersionsForPurgeInTx } = await import( + '@/lib/uploads/contexts/workspace/file-version-store' + ) + const { archiveProjectFileItems } = await import('@/lib/projects/files/application/lifecycle') + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'first'), + }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'second', + encoding: 'utf-8', + }, + }) + await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [created.file.id] }, + }) + await db.transaction(async (tx) => { + await releaseWorkspaceFileVersionsForPurgeInTx( + tx, + [created.file.id], + new Date(Date.now() + 1000) + ) + await tx.delete(workspaceFiles).where(eq(workspaceFiles.id, created.file.id)) + }) + const events = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' = ${created.file.key}`) + expect(events).toHaveLength(1) + expect(events[0].payload).toMatchObject({ context: 'project', key: created.file.key }) + await processOutboxEventById(events[0].id, workspaceFileStorageCleanupOutboxHandlers) + await expect(readFile(join(localStorageRoot, created.file.key))).rejects.toMatchObject({ + code: 'ENOENT', + }) + } + ) + + check( + 'preserves workspace bytes, current folder paths, history, and accounting through shared storage', + async () => { + const f = await fixture() + const { createWorkspaceFile, createWorkspaceFileFromBuffer } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { updateWorkspaceFileContent } = await import( + '@/lib/workspace-files/application/update-workspace-file-content' + ) + const { readWorkspaceFileContent } = await import( + '@/lib/workspace-files/application/read-workspace-file-content' + ) + const { archiveWorkspaceFileItemsOperation } = await import( + '@/lib/workspace-files/application/archive-workspace-file-items' + ) + const { restoreWorkspaceFileOperation } = await import( + '@/lib/workspace-files/application/restore-workspace-file' + ) + const { createWorkspaceFileFolderOperation, updateWorkspaceFileFolderOperation } = + await import('@/lib/workspace-files/application/folders') + const originalFolder = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Design' }, + }) + const upload = storage.uploadFile + let replacementFolderId = '' + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const stored = await upload(options) + await updateWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, folderId: originalFolder.folder.id, name: 'Prior' }, + }) + const replacement = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Design' }, + }) + replacementFolderId = replacement.folder.id + return stored + }) + const created = await createWorkspaceFile.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'architecture.md', + content: 'first', + contentType: 'text/markdown', + encoding: 'utf-8', + exactName: true, + folderPath: '/Design', + }, + }) + expect(created.file.folderId).toBe(replacementFolderId) + const binary = await createWorkspaceFileFromBuffer.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'image.bin', + content: Buffer.from([1, 0, 255]), + contentType: 'application/octet-stream', + exactName: true, + }, + }) + const updated = await updateWorkspaceFileContent.execute({ + principal: f.principal, + input: { + fileId: created.file.id, + assertedWorkspaceId: f.workspaceId, + content: 'second version', + encoding: 'utf-8', + syncLiveDoc: false, + }, + }) + expect(updated.file.currentVersion).toBe(2) + const read = await readWorkspaceFileContent.execute({ + principal: f.principal, + input: { + fileId: created.file.id, + assertedWorkspaceId: f.workspaceId, + includeSecretProvenance: true, + }, + }) + expect(read.content.toString()).toBe('second version') + expect(read.secretProvenance).toEqual({ status: 'exact', entries: [] }) + const binaryRead = await readWorkspaceFileContent.execute({ + principal: f.principal, + input: { fileId: binary.file.id, assertedWorkspaceId: f.workspaceId }, + }) + expect(binaryRead.content).toEqual(Buffer.from([1, 0, 255])) + expect(await ledger(f.organizationId)).toBe(17) + await archiveWorkspaceFileItemsOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, folderIds: [replacementFolderId] }, + }) + await expect( + readWorkspaceFileContent.execute({ + principal: f.principal, + input: { fileId: created.file.id, assertedWorkspaceId: f.workspaceId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + const restored = await restoreWorkspaceFileOperation.execute({ + principal: f.principal, + input: { fileId: created.file.id, assertedWorkspaceId: f.workspaceId }, + }) + expect(restored.file.folderId).toBeNull() + expect(restored.file.key).toBe(updated.file.key) + expect(restored.file.uploadedBy).toBe(f.editorId) + expect(await ledger(f.organizationId)).toBe(17) + } + ) + + check( + 'creates binary and text files in the canonical owner, attributes the actor, and bills the organization once', + async () => { + const f = await fixture() + const directory = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Design' }, + }) + const bytes = Buffer.from([0, 255, 18, 1]) + const created = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + name: 'diagram.bin', + contentType: 'application/octet-stream', + content: bytes.toString('base64'), + encoding: 'base64', + folderId: directory.folder.id, + }, + }) + expect(created.file).toMatchObject({ + owner: { entityType: 'project', entityId: f.projectId }, + uploadedBy: f.editorId, + size: 4, + folderPath: 'Design', + }) + expect(created.file.key.startsWith(`project/${f.projectId}/`)).toBe(true) + const read = await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: created.file.id, includeSecretProvenance: true }, + }) + expect(read.content).toEqual(bytes) + expect(read.secretProvenance).toEqual({ status: 'exact', entries: [] }) + expect(await ledger(f.organizationId)).toBe(4) + const [stored] = await rows(f.projectId) + expect(stored).toMatchObject({ + workspaceId: null, + organizationId: null, + context: 'project', + secretProvenanceVersion: 1, + userId: f.editorId, + }) + await expect( + verifyFileAccess(created.file.key, f.editorId, undefined, 'general') + ).resolves.toBe(false) + await expect( + verifyFileAccess(created.file.key, f.ownerId, undefined, 'workspace') + ).resolves.toBe(false) + } + ) + + check( + 'revocation while a blob is staged prevents metadata admission and cleans the uncommitted object', + async () => { + const f = await fixture() + const upload = storage.uploadFile + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const result = await upload(options) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + return result + }) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(await rows(f.projectId)).toEqual([]) + expect(await ledger(f.organizationId)).toBe(0) + expect(await keys(f.projectId)).toEqual([]) + } + ) + + check( + 'foreign folders and duplicate names cannot commit a blob or debit either owner', + async () => { + const f = await fixture() + const other = await fixture() + const foreign = await createProjectFileFolder.execute({ + principal: other.principal, + input: { projectId: other.projectId, name: 'Private' }, + }) + await expect( + createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), folderId: foreign.folder.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([created.file.id]) + expect(await ledger(f.organizationId)).toBe(created.file.size) + expect(await ledger(other.organizationId)).toBe(0) + expect((await keys(f.projectId)).filter((key) => !key.endsWith('.json'))).toHaveLength(1) + } + ) + + check( + 'concurrent writes sharing one revision yield one winner and preserve provenance origins in history', + async () => { + const f = await fixture() + const secret = { + status: 'exact' as const, + entries: [ + { + name: 'TOKEN', + encryptedValue: 'fixture-ciphertext', + sourceUserId: f.editorId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const created = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'sensitive architecture'), secretProvenance: secret }, + }) + const outcomes = await Promise.allSettled( + ['replacement one', 'replacement two'].map((content) => + updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content, + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + provenanceMode: 'preserve', + }, + }) + ) + ) + expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(outcomes.filter((result) => result.status === 'rejected')).toHaveLength(1) + const current = await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: created.file.id, includeSecretProvenance: true }, + }) + expect(current.secretProvenance).toEqual(secret) + expect(await ledger(f.organizationId)).toBe(current.content.length) + const history = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, created.file.id)) + expect(history).toHaveLength(2) + expect( + history.every( + (version) => version.workspaceId === null && version.secretProvenanceStatus === 'exact' + ) + ).toBe(true) + expect(history.flatMap((version) => version.secretProvenanceEntries ?? [])).toContainEqual( + expect.objectContaining({ sourceUserId: f.editorId, sourceWorkspaceId: f.workspaceId }) + ) + expect((await keys(f.projectId)).filter((key) => !key.endsWith('.json'))).toHaveLength(2) + } + ) + + check( + 'quota failure rolls back metadata, provenance, versions and counters, retaining previous bytes', + async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'before'), + }) + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '1') + await db + .update(organization) + .set({ storageUsedBytes: 1024 ** 3 }) + .where(eq(organization.id, f.organizationId)) + await expect( + updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'larger replacement', + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + }, + }) + ).rejects.toMatchObject({ name: 'StorageLimitExceededError' }) + const [current] = await rows(f.projectId) + expect(current.key).toBe(created.file.key) + expect(await readFile(join(localStorageRoot, current.key), 'utf8')).toBe('before') + expect(await ledger(f.organizationId)).toBe(1024 ** 3) + expect( + await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, current.id)) + ).toEqual([]) + const [provenance] = await db + .select() + .from(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, current.id)) + expect(provenance.contentUpdatedAt.getTime()).toBe(created.file.contentUpdatedAt.getTime()) + expect((await keys(f.projectId)).filter((key) => !key.endsWith('.json'))).toHaveLength(1) + } + ) + + check( + 'missing Project tracking cannot become exact-empty when a derived write preserves provenance', + async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + await db + .delete(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, created.file.id)) + await db + .update(workspaceFiles) + .set({ secretProvenanceVersion: null }) + .where(eq(workspaceFiles.id, created.file.id)) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'derived content', + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + provenanceMode: 'preserve', + }, + }) + const read = await readProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + includeSecretProvenance: true, + }, + }) + expect(read.secretProvenance).toEqual({ status: 'unknown' }) + const [current] = await rows(f.projectId) + expect(current.secretProvenanceVersion).toBe(1) + const history = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, created.file.id)) + expect(history.every((version) => version.secretProvenanceStatus === 'unknown')).toBe(true) + } + ) + + check( + 'private delivery receives bound provenance and can refuse bytes before the caller receives them', + async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + secretProvenance: { status: 'unknown' }, + }, + }) + await expect( + observeWorkspaceFileDelivery( + async (provenance) => { + if (provenance?.status !== 'exact') + throw new Error('Private delivery refuses unknown provenance') + }, + () => + readProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + }, + }) + ) + ).rejects.toThrow('Private delivery refuses unknown provenance') + for (const includeSecretProvenance of [false, true]) { + const observed = vi.fn() + const result = await observeWorkspaceFileDelivery(observed, () => + readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: created.file.id, includeSecretProvenance }, + }) + ) + expect(observed).toHaveBeenCalledWith({ status: 'unknown' }, created.file.id) + expect('secretProvenance' in result).toBe(includeSecretProvenance) + if (includeSecretProvenance) expect(result.secretProvenance).toEqual({ status: 'unknown' }) + } + } + ) + + check( + 'shared content survives creator departure and deletion without changing stored history or its payer', + async () => { + const f = await fixture() + const directory = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Authored' }, + }) + const created = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), folderId: directory.folder.id }, + }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'Documented before departure', + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + }, + }) + await db + .delete(member) + .where(and(eq(member.organizationId, f.organizationId), eq(member.userId, f.editorId))) + await db + .delete(permissions) + .where(and(eq(permissions.entityId, f.workspaceId), eq(permissions.userId, f.editorId))) + await expect( + readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: created.file.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect((await rows(f.projectId))[0].userId).toBe(f.editorId) + await deleteUserAccount(f.editorId) + const principal = createSessionPrincipal({ userId: f.ownerId }) + const read = await readProjectFileContent.execute({ + principal, + input: { projectId: f.projectId, fileId: created.file.id }, + }) + expect(read.file).toMatchObject({ + uploadedBy: f.ownerId, + folderPath: 'Authored', + }) + const [retainedFolder] = await db + .select() + .from(folder) + .where(eq(folder.id, directory.folder.id)) + expect(retainedFolder).toMatchObject({ userId: f.ownerId, projectId: f.projectId }) + await updateProjectFileContent.execute({ + principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'Maintained by another editor', + encoding: 'utf-8', + expectedUpdatedAt: read.file.contentUpdatedAt, + }, + }) + const history = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, created.file.id)) + expect(history.find((version) => version.version === 1)?.authorUserIds).toEqual([f.editorId]) + const [maintained] = await rows(f.projectId) + expect(maintained).toMatchObject({ + id: created.file.id, + userId: f.ownerId, + projectId: f.projectId, + folderId: directory.folder.id, + }) + expect(await readFile(join(localStorageRoot, maintained.key), 'utf8')).toBe( + 'Maintained by another editor' + ) + expect(await ledger(f.organizationId)).toBe(maintained.sizeBytes) + } + ) + + check( + 'a postcommit notification failure never deletes admitted bytes or their accounting', + async () => { + const f = await fixture() + vi.spyOn(tracking, 'maybeNotifyStorageLimitForBillingContext').mockRejectedValueOnce( + new Error('notification service unavailable') + ) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toThrow('notification service unavailable') + const [file] = await rows(f.projectId) + expect(file).toBeDefined() + expect(await readFile(join(localStorageRoot, file.key), 'utf8')).toBe('Project architecture') + expect(await ledger(f.organizationId)).toBe(file.sizeBytes) + const [provenance] = await db + .select() + .from(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, file.id)) + expect(provenance.status).toBe('exact') + } + ) + + check( + 'failed staged-object deletion is durable and an outbox retry removes only uncommitted bytes', + async () => { + const f = await fixture() + const upload = storage.uploadFile + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const result = await upload(options) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + return result + }) + vi.spyOn(storage, 'deleteFile').mockRejectedValueOnce( + new Error('temporary object storage outage') + ) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(await rows(f.projectId)).toEqual([]) + expect((await keys(f.projectId)).length).toBeGreaterThan(0) + const [event] = await db + .select() + .from(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`} OR ${outboxEvent.payload}::jsonb -> 'owner' ->> 'entityId' = ${f.projectId}` + ) + expect(event.payload).toMatchObject({ context: 'project' }) + expect(event.status).toBe('pending') + await db + .update(outboxEvent) + .set({ availableAt: new Date(0) }) + .where(eq(outboxEvent.id, event.id)) + await expect( + processOutboxEventById(event.id, workspaceFileStorageCleanupOutboxHandlers) + ).resolves.toBe('completed') + expect(await keys(f.projectId)).toEqual([]) + expect(await ledger(f.organizationId)).toBe(0) + } + ) + + check( + 'content changed while being downloaded cannot be returned with a newer revision or classification', + async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const download = storage.downloadFile + vi.spyOn(storage, 'downloadFile').mockImplementationOnce(async (options) => { + const content = await download(options) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'updated during download', + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + }, + }) + return content + }) + await expect( + readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: created.file.id, includeSecretProvenance: true }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) +}) + +describe('Project file lifecycle against PostgreSQL and private storage', () => { + check( + 'recursive archive and restore preserve individually archived files, bytes, attribution, and billing', + async () => { + const { archiveProjectFileItems, restoreProjectFileFolder } = await import( + '@/lib/projects/files/application/lifecycle' + ) + const f = await fixture() + const parent = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Design' }, + }) + const child = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Diagrams', parentId: parent.folder.id }, + }) + const current = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'shared content'), folderId: child.folder.id }, + }) + const prior = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'old content'), + name: 'retired.md', + folderId: child.folder.id, + }, + }) + await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [prior.file.id] }, + }) + await db + .update(workspaceFiles) + .set({ deletedAt: new Date(0) }) + .where(eq(workspaceFiles.id, prior.file.id)) + const before = await ledger(f.organizationId) + const archived = await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, folderIds: [parent.folder.id] }, + }) + expect(archived.deletedItems).toEqual({ files: 1, folders: 2 }) + await expect( + readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: current.file.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(await ledger(f.organizationId)).toBe(before) + await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Design' }, + }) + const restored = await restoreProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, folderId: parent.folder.id }, + }) + expect(restored.folder.name).toBe('Design (1)') + expect(restored.restoredItems).toEqual({ files: 1, folders: 2 }) + const content = await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: current.file.id, includeSecretProvenance: true }, + }) + expect(content.content.toString()).toBe('shared content') + expect(content.file.uploadedBy).toBe(f.editorId) + expect(content.secretProvenance).toEqual({ status: 'exact', entries: [] }) + await expect( + readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: prior.file.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(await ledger(f.organizationId)).toBe(before) + } + ) + + check( + 'bulk moves and archives reject foreign items, conflicts, and descendant cycles atomically', + async () => { + const { archiveProjectFileItems, moveProjectFileItems } = await import( + '@/lib/projects/files/application/lifecycle' + ) + const f = await fixture() + const other = await fixture() + const destination = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Target' }, + }) + const child = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Child', parentId: destination.folder.id }, + }) + const a = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const b = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), name: 'second.md' }, + }) + const foreign = await createProjectFile.execute({ + principal: other.principal, + input: createInput(other.projectId), + }) + await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), name: 'second.md', folderId: destination.folder.id }, + }) + await expect( + moveProjectFileItems.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileIds: [a.file.id, b.file.id], + targetFolderId: destination.folder.id, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + await expect( + archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [a.file.id, foreign.file.id] }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + moveProjectFileItems.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + folderIds: [destination.folder.id], + targetFolderId: child.folder.id, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + const [unchanged] = (await rows(f.projectId)).filter((file) => file.id === a.file.id) + expect(unchanged).toMatchObject({ folderId: null, deletedAt: null }) + const moved = await moveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [a.file.id], targetFolderPath: '/Target/Child' }, + }) + expect(moved.movedFileIds).toEqual([a.file.id]) + const [stored] = (await rows(f.projectId)).filter((file) => file.id === a.file.id) + expect(stored.folderId).toBe(child.folder.id) + expect(stored.contentUpdatedAt).toEqual(a.file.contentUpdatedAt) + } + ) + + check( + 'file restore re-roots from archived folders and deduplicates while rename preserves byte identity', + async () => { + const { archiveProjectFileItems, renameProjectFile, restoreProjectFile } = await import( + '@/lib/projects/files/application/lifecycle' + ) + const f = await fixture() + const parent = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Archive' }, + }) + const file = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), folderId: parent.folder.id }, + }) + const renamed = await renameProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: file.file.id, name: 'renamed.md' }, + }) + expect(renamed.file.key).toBe(file.file.key) + expect(renamed.file.contentUpdatedAt).toEqual(file.file.contentUpdatedAt) + await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, folderIds: [parent.folder.id] }, + }) + await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), name: 'renamed.md' }, + }) + const restored = await restoreProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: file.file.id }, + }) + expect(restored.file.folderId).toBeNull() + expect(restored.file.name).not.toBe('renamed.md') + expect(restored.file.name.endsWith('.md')).toBe(true) + expect(restored.file.key).toBe(file.file.key) + expect(restored.file.contentUpdatedAt).toEqual(file.file.contentUpdatedAt) + } + ) +}) + +describe('Project outer transaction cleanup', () => { + for (const operation of ['create', 'update'] as const) { + check(`cleans Project ${operation} bytes after a deferred COMMIT rejection`, async () => { + const f = await fixture() + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'original'), + }) + const beforeUsage = await ledger(f.organizationId) + const triggerName = sql.identifier( + `reject_project_commit_${generateId().replaceAll('-', '')}` + ) + await db.execute(sql`CREATE FUNCTION ${triggerName}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.project_id = TG_ARGV[0] THEN + RAISE EXCEPTION 'Deferred file constraint rejected COMMIT' USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END; + $$`) + await db.execute(sql`CREATE CONSTRAINT TRIGGER ${triggerName} + AFTER INSERT OR UPDATE ON ${workspaceFiles} DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION ${triggerName}(${sql.raw(`'${f.projectId}'`)})`) + const transaction = db.transaction.bind(db) + let callbackCompleted = false + const observer = vi.spyOn(db, 'transaction').mockImplementation((callback, config) => + transaction(async (tx) => { + const result = await callback(tx) + if (isRecordLike(result) && isRecordLike(result.result) && 'file' in result.result) + callbackCompleted = true + return result + }, config) + ) + const upload = storage.uploadFile + let stagedKey = '' + const capture = vi.spyOn(storage, 'uploadFile').mockImplementation(async (args) => { + const result = await upload(args) + stagedKey = result.key + return result + }) + try { + const pending = + operation === 'create' + ? createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'rejected'), name: 'new.md' }, + }) + : updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'rejected replacement', + encoding: 'utf-8', + }, + }) + const rejection = await pending.catch((error: unknown) => error) + expect(getPostgresErrorCode(rejection)).toBe('23514') + expect(callbackCompleted).toBe(true) + } finally { + observer.mockRestore() + capture.mockRestore() + await db.execute(sql`DROP TRIGGER ${triggerName} ON ${workspaceFiles}`) + await db.execute(sql`DROP FUNCTION ${triggerName}()`) + } + expect(stagedKey).not.toBe('') + expect(await ledger(f.organizationId)).toBe(beforeUsage) + expect( + await db.select().from(workspaceFiles).where(eq(workspaceFiles.key, stagedKey)) + ).toEqual([]) + expect( + await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, source.file.id)) + ).toEqual([]) + expect( + ( + await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).content.toString() + ).toBe('original') + await expect + .soft(readFile(join(localStorageRoot, stagedKey))) + .rejects.toMatchObject({ code: 'ENOENT' }) + expect( + await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}->>'key' = ${stagedKey}`) + ).toHaveLength(1) + }) + } + + for (const deleteUnavailable of [false, true]) { + check( + `preserves aborted Project content error when cleanup enqueue fails (delete unavailable=${deleteUnavailable})`, + async () => { + const f = await fixture() + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'original'), + }) + const primary = new OrchestrationError('conflict', 'Content transaction rejected') + vi.spyOn(tracking, 'prepareFileStorageMutationInTx').mockRejectedValueOnce(primary) + vi.spyOn(storageCleanup, 'enqueueWorkspaceFileStorageCleanups').mockRejectedValue( + new Error('Cleanup database unavailable') + ) + if (deleteUnavailable) + vi.spyOn(storage, 'deleteFile').mockRejectedValue(new Error('Storage unavailable')) + const upload = storage.uploadFile + let key = '' + vi.spyOn(storage, 'uploadFile').mockImplementation(async (args) => { + const result = await upload(args) + key = result.key + return result + }) + await expect( + updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'replacement', + encoding: 'utf-8', + }, + }) + ).rejects.toBe(primary) + expect(key).not.toBe('') + if (deleteUnavailable) + expect((await readFile(join(localStorageRoot, key))).toString()).toBe('replacement') + else + await expect(readFile(join(localStorageRoot, key))).rejects.toMatchObject({ + code: 'ENOENT', + }) + expect((await readFile(join(localStorageRoot, source.file.key))).toString()).toBe( + 'original' + ) + } + ) + } +}) + +afterAll(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + for (const f of fixtures) { + await db.delete(idempotencyKey).where(sql`${idempotencyKey.key} LIKE ${`%${f.projectId}%`}`) + await db + .delete(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`} OR ${outboxEvent.payload}::jsonb -> 'owner' ->> 'entityId' = ${f.projectId}` + ) + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await db.delete(folder).where(eq(folder.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(subscription).where(eq(subscription.referenceId, f.organizationId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(localStorageRoot, { recursive: true, force: true }) + const report = + process.env.PROJECT_FILE_CONTENT_REPORT_PATH ?? 'test-results/project-file-content.json' + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks }, null, 2)) + await db.$client.end() +}) + +describe('Project streaming source delivery', () => { + for (const historical of [false, true]) { + for (const change of ['access', 'identity', 'cancel'] as const) { + check( + `${historical ? 'historical' : 'current'} download releases storage after ${change}`, + async () => { + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'x'.repeat(1024 * 1024)), + }) + if (historical) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + content: 'new head', + encoding: 'utf-8', + }, + }) + const open = storage.downloadFileStream + let source: Awaited> | undefined + vi.spyOn(storage, 'downloadFileStream').mockImplementationOnce(async (options) => { + source = await open(options) + if (change === 'access') { + await db + .delete(permissions) + .where( + and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId)) + ) + } else if (change === 'identity') { + if (historical) + await db + .delete(workspaceFileVersion) + .where( + and( + eq(workspaceFileVersion.fileId, file.id), + eq(workspaceFileVersion.version, 1) + ) + ) + else + await db + .update(workspaceFiles) + .set({ contentUpdatedAt: new Date(file.contentUpdatedAt.getTime() + 1000) }) + .where(eq(workspaceFiles.id, file.id)) + } + return source + }) + const args = { + principal: f.principal, + input: { projectId: f.projectId, fileId: file.id, version: 1 }, + } + const pending = historical + ? downloadProjectFileVersionContent.execute(args) + : downloadProjectFileContent.execute(args) + if (change === 'cancel') { + const result = await pending + expect(result.contentLength).toBe(1024 * 1024) + const reader = result.stream.getReader() + const chunk = await reader.read() + expect(chunk.done).toBe(false) + expect(chunk.value?.length).toBeLessThan(result.contentLength) + await reader.cancel() + } else { + await expect(pending).rejects.toMatchObject({ + code: change === 'identity' && !historical ? 'conflict' : 'not_found', + }) + } + expect(source?.destroyed).toBe(true) + } + ) + } + } +}) + +describe('Project Copilot content admission', () => { + for (const gate of ['missing', 'deny', 'opaque-before-read', 'opaque-before-delivery'] as const) { + check( + `${gate} gate blocks current reads and unsupported Copilot history reads fail authorization`, + async () => { + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'retained source'), + }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + content: 'current source', + encoding: 'utf-8', + }, + }) + const principal: Principal = { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: f.editorId, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, + invocation: { kind: 'workspace', workspaceId: f.workspaceId }, + } + const buffered = vi.spyOn(storage, 'downloadFile') + const streamed = vi.spyOn(storage, 'downloadFileStream') + for (const operation of [readProjectFileContent, downloadProjectFileContent]) { + let admissions = 0 + const execute = () => + operation.execute({ + principal, + input: { projectId: f.projectId, fileId: file.id }, + }) + await expect( + gate === 'missing' + ? execute() + : observeContentDelivery( + { + admit: async (sources) => { + admissions++ + return sources.map((source) => { + if (gate === 'deny') return false + if (gate === 'opaque-before-delivery' && admissions === 1) return true + return source.view !== 'opaque' + }) + }, + }, + execute + ) + ).rejects.toBeInstanceOf( + gate === 'missing' ? ContentDeliveryUnobservedError : ContentRefusedError + ) + } + for (const operation of [ + readProjectFileVersionContent, + downloadProjectFileVersionContent, + ]) { + await expect( + operation.execute({ + principal, + input: { projectId: f.projectId, fileId: file.id, version: 1 }, + }) + ).rejects.toBeInstanceOf(PrincipalKindAuthorizationError) + } + if (gate === 'opaque-before-delivery') { + expect(buffered).toHaveBeenCalledTimes(1) + expect(streamed).toHaveBeenCalledTimes(1) + } else { + expect(buffered).not.toHaveBeenCalled() + expect(streamed).not.toHaveBeenCalled() + } + } + ) + } +}) diff --git a/apps/sim/lib/projects/files/__integration__/documents.integration.ts b/apps/sim/lib/projects/files/__integration__/documents.integration.ts new file mode 100644 index 00000000000..5bcfc09569d --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/documents.integration.ts @@ -0,0 +1,503 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + member, + organization, + outboxEvent, + permissions, + user, + workspace, + workspaceFiles, + workspaceFileVersion, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import * as storage from '@/lib/uploads/core/storage-service' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) + +import { applyMarkdownToYDoc } from '@/lib/collab-doc/converter' +import { + createProjectFile, + readProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import { rotateProjectFileDocInTx } from '@/lib/projects/files/application/document-lifecycle' +import { + buildProjectFileDocSeed, + getProjectFileDocAccess, + persistProjectFileDoc, +} from '@/lib/projects/files/application/documents' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const uploadRoot = mkdtempSync(join(tmpdir(), 'sim-project-documents-')) +setUploadDirServer(uploadRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const lifecycleTargets: string[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture(permissionType: 'read' | 'write' = 'write') { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@documents.invalid`, + name: 'Document fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Documents', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Document environment', + }) + const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) + if (!binding) throw new Error('Project binding is missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType, + }) + const ids = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(ids) + const owner = createSessionPrincipal({ userId: ownerId }) + const created = await createProjectFile.execute({ + principal: owner, + input: { + projectId: ids.projectId, + name: 'architecture.md', + contentType: 'text/markdown', + content: '# Original architecture\n', + encoding: 'utf-8', + }, + }) + const target = { projectId: ids.projectId, fileId: created.file.id } + const principal: ResourceDelegatedPrincipal = { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId: editorId, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + scope: { + kind: 'entity', + entityType: 'project', + entityId: ids.projectId, + fileId: created.file.id, + }, + invocation: { kind: 'realtime', connectionId: `socket-${generateId()}` }, + } + return { ...ids, owner, target, principal } +} + +function edit(update: Uint8Array, markdown: string, replaceIdentity = false) { + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, update) + applyMarkdownToYDoc(doc, markdown) + if (replaceIdentity) + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, generateId()) + return Y.encodeStateAsUpdate(doc) + } finally { + doc.destroy() + } +} + +function documentId(update: Uint8Array) { + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, update) + return doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + } finally { + doc.destroy() + } +} + +describe('Project collaborative documents against current policy and durable storage', () => { + check('aborted seeds stop storage reads and do not retry', async () => { + const f = await fixture('read') + const controller = new AbortController() + const reason = new Error('Seed request retired') + const read = vi.spyOn(storage, 'downloadFile').mockImplementationOnce(async (options) => { + expect(options.signal).toBeDefined() + controller.abort(reason) + options.signal?.throwIfAborted() + throw new Error('Expected aborted storage read') + }) + try { + await expect( + buildProjectFileDocSeed.execute({ + principal: f.principal, + input: { ...f.target, signal: controller.signal }, + }) + ).rejects.toBe(reason) + expect(read).toHaveBeenCalledTimes(1) + await expect( + buildProjectFileDocSeed.execute({ + principal: f.principal, + input: { ...f.target, signal: controller.signal }, + }) + ).rejects.toBe(reason) + expect(read).toHaveBeenCalledTimes(1) + } finally { + read.mockRestore() + } + }) + + check('readers receive a stable seed but cannot publish durable document edits', async () => { + const f = await fixture('read') + expect( + await getProjectFileDocAccess.execute({ principal: f.principal, input: f.target }) + ).toMatchObject({ canRead: true, canWrite: false }) + const seed = await buildProjectFileDocSeed.execute({ principal: f.principal, input: f.target }) + const again = await buildProjectFileDocSeed.execute({ principal: f.principal, input: f.target }) + expect(typeof documentId(seed.update)).toBe('string') + expect(documentId(again.update)).toBe(documentId(seed.update)) + await expect( + persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Reader edit'), + expectedVersion: seed.version, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + const stored = await readProjectFileContent.execute({ principal: f.owner, input: f.target }) + expect(stored.content.toString()).toBe('# Original architecture\n') + }) + + check( + 'accepted snapshots preserve document identity and attribute versions to the editor', + async () => { + const f = await fixture() + const seed = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + const result = await persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Shared architecture'), + expectedVersion: seed.version, + }, + }) + expect(result.status).toBe('persisted') + const stored = await readProjectFileContent.execute({ principal: f.owner, input: f.target }) + expect(stored.content.toString()).toContain('# Shared architecture') + const again = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + expect(documentId(again.update)).toBe(documentId(seed.update)) + const versions = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, f.target.fileId)) + expect( + versions.some( + (version) => version.source === 'collab' && version.authorUserIds.includes(f.editorId) + ) + ).toBe(true) + const [billing] = await db + .select({ bytes: organization.storageUsedBytes }) + .from(organization) + .where(eq(organization.id, f.organizationId)) + expect(billing?.bytes).toBe(stored.content.length) + } + ) + + check( + 'lifecycle identity rotation is atomic and old snapshots cannot return after restore', + async () => { + const f = await fixture() + lifecycleTargets.push(f.target.fileId) + const initial = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + const original = new Y.Doc() + Y.applyUpdate(original, initial.update) + const originalId = original.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + original.destroy() + const archive = await db.transaction(async (tx) => { + await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, f.target.fileId)) + .for('update') + return rotateProjectFileDocInTx(tx, f.target) + }) + expect(archive).not.toBeNull() + const afterArchive = await getProjectFileDocAccess.execute({ + principal: f.principal, + input: f.target, + }) + expect(afterArchive.docId).not.toBe(originalId) + await expect( + db.transaction(async (tx) => { + await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, f.target.fileId)) + .for('update') + await rotateProjectFileDocInTx(tx, f.target) + throw new Error('rollback-lifecycle') + }) + ).rejects.toThrow('rollback-lifecycle') + expect( + (await getProjectFileDocAccess.execute({ principal: f.principal, input: f.target })).docId + ).toBe(afterArchive.docId) + const restore = await db.transaction(async (tx) => { + await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, f.target.fileId)) + .for('update') + return rotateProjectFileDocInTx(tx, f.target) + }) + if (!archive || !restore) throw new Error('Missing committed lifecycle events') + const rows = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}->>'fileId' = ${f.target.fileId}`) + expect(rows).toHaveLength(2) + const current = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + expect( + await persistProjectFileDoc.execute({ + principal: f.principal, + input: { ...f.target, docState: initial.update, expectedVersion: initial.version }, + }) + ).toMatchObject({ status: 'conflict' }) + const fresh = new Y.Doc() + try { + Y.applyUpdate(fresh, current.update) + expect(fresh.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey)).not.toBe( + afterArchive.docId + ) + expect(fresh.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey)).not.toBe( + originalId + ) + } finally { + fresh.destroy() + } + expect( + ( + await readProjectFileContent.execute({ principal: f.owner, input: f.target }) + ).content.toString() + ).toContain('Original architecture') + } + ) + + check( + 'binary files remain readable as files but cannot become collaborative documents', + async () => { + const f = await fixture() + const binary = Buffer.from('%PDF-1.7\n\x00\xff') + const created = await createProjectFile.execute({ + principal: f.owner, + input: { + projectId: f.projectId, + name: 'architecture.pdf', + contentType: 'application/pdf', + content: binary.toString('base64'), + encoding: 'base64', + }, + }) + const target = { projectId: f.projectId, fileId: created.file.id } + const principal: ResourceDelegatedPrincipal = { + ...f.principal, + scope: { + kind: 'entity', + entityType: 'project', + entityId: f.projectId, + fileId: created.file.id, + }, + } + await expect( + getProjectFileDocAccess.execute({ principal, input: target }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + buildProjectFileDocSeed.execute({ principal, input: target }) + ).rejects.toMatchObject({ code: 'validation' }) + const seed = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + await expect( + persistProjectFileDoc.execute({ + principal, + input: { ...target, docState: seed.update, expectedVersion: Date.now() }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect( + ( + await readProjectFileContent.execute({ principal: f.owner, input: target }) + ).content.equals(binary) + ).toBe(true) + } + ) + + check('current write revocation leaves readers live but rejects a dirty flush', async () => { + const f = await fixture() + const seed = await buildProjectFileDocSeed.execute({ principal: f.principal, input: f.target }) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(eq(permissions.userId, f.editorId)) + expect( + await getProjectFileDocAccess.execute({ principal: f.principal, input: f.target }) + ).toMatchObject({ canRead: true, canWrite: false }) + await expect( + persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Revoked edit'), + expectedVersion: seed.version, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await db.delete(permissions).where(eq(permissions.userId, f.editorId)) + await expect( + buildProjectFileDocSeed.execute({ principal: f.principal, input: f.target }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + check('file-bound realtime grants never read or mutate another Project or file', async () => { + const f = await fixture() + for (const target of [ + { ...f.target, projectId: generateId() }, + { ...f.target, fileId: generateId() }, + ]) { + await expect( + buildProjectFileDocSeed.execute({ principal: f.principal, input: target }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } + }) + + check( + 'a replacement generation and an unsynchronized durable write cannot be overwritten', + async () => { + const f = await fixture() + const seed = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + expect( + await persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Wrong generation', true), + expectedVersion: seed.version, + }, + }) + ).toEqual({ status: 'conflict' }) + await updateProjectFileContent.execute({ + principal: f.owner, + input: { ...f.target, content: '# New durable architecture', encoding: 'utf-8' }, + }) + expect( + await persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Stale client architecture'), + expectedVersion: seed.version, + }, + }) + ).toEqual({ status: 'conflict' }) + const stored = await readProjectFileContent.execute({ principal: f.owner, input: f.target }) + expect(stored.content.toString()).toBe('# New durable architecture') + } + ) +}) + +afterAll(async () => { + const reportPath = + process.env.PROJECT_FILE_DOCUMENTS_REPORT_PATH ?? + resolve('test-results/project-file-documents.json') + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) + for (const fileId of lifecycleTargets) { + await db.delete(outboxEvent).where(sql`${outboxEvent.payload}->>'fileId' = ${fileId}`) + } + for (const f of fixtures) { + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(uploadRoot, { recursive: true, force: true }) +}) diff --git a/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts b/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts index ddef04583d1..2710f581ad4 100644 --- a/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts @@ -20,14 +20,16 @@ import { } from '@sim/db/schema' import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' import { sha256Hex } from '@sim/security/hash' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { createDeferred } from '@sim/testing/helpers/deferred' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { and, eq, inArray, or, sql } from 'drizzle-orm' import { NextRequest } from 'next/server' -import { afterAll, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) @@ -35,12 +37,15 @@ import { resolveProjectStorageBillingContext } from '@/lib/billing/storage/conte import { prepareFileStorageMutationInTx } from '@/lib/billing/storage/tracking' import { processOutboxEventById } from '@/lib/core/outbox/service' import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion' +import { createProjectFileUploadSession } from '@/lib/projects/files/application/uploads' import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox' import { UPLOAD_URL_TTL_MS } from '@/lib/uploads/upload-session/provider' import { PROJECT_FILE_UPLOAD_BINDING_KEY } from '@/lib/uploads/upload-session/types' import { deleteUserAccount } from '@/lib/users/account-deletion' import { PUT as putUploadBytes } from '@/app/api/v2/uploads/[uploadId]/route' +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + const storageRoot = mkdtempSync(join(tmpdir(), 'sim-project-purge-')) setUploadDirServer(storageRoot) const fixtures: { userId: string; projectId: string; workspaceId: string; fileIds: string[] }[] = [] @@ -65,6 +70,10 @@ function check(name: string, run: () => Promise) { }) } +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') +}) + async function seedUpload(f: { userId: string; projectId: string }, fileName: string) { const id = generateId() const uploadToken = generateId() @@ -399,7 +408,17 @@ describe('Private Project teardown and durable object cleanup', () => { async () => { const f = await fixture() const live = await fixture() - const upload = await seedUpload(f, 'pending.bin') + const principal = createSessionPrincipal({ userId: f.userId, sessionId: generateId() }) + const upload = await createProjectFileUploadSession.execute({ + principal, + input: { + projectId: f.projectId, + fileName: 'pending.bin', + contentType: 'application/octet-stream', + fileSize: 4, + localOrigin: 'http://localhost:3000', + }, + }) const derived = `project/${f.projectId}/compiled/derived.pdf` await mkdir(dirname(join(storageRoot, derived)), { recursive: true }) await writeFile(join(storageRoot, derived), 'derived') @@ -472,7 +491,16 @@ describe('Private Project teardown and durable object cleanup', () => { 'a local stream finishing after retirement queues cleanup instead of leaving a late object', async () => { const f = await fixture() - const created = await seedUpload(f, 'stream.bin') + const created = await createProjectFileUploadSession.execute({ + principal: createSessionPrincipal({ userId: f.userId, sessionId: generateId() }), + input: { + projectId: f.projectId, + fileName: 'stream.bin', + contentType: 'application/octet-stream', + fileSize: 4, + localOrigin: 'http://localhost:3000', + }, + }) const entered = createDeferred() const release = createDeferred() const body = new ReadableStream( diff --git a/apps/sim/lib/projects/files/__integration__/retention.integration.ts b/apps/sim/lib/projects/files/__integration__/retention.integration.ts index c37eaabe0b8..70cfcd9278b 100644 --- a/apps/sim/lib/projects/files/__integration__/retention.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/retention.integration.ts @@ -31,14 +31,12 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) -import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { type CleanupJobPayload, runCleanupWithLimits } from '@/lib/billing/cleanup-dispatcher' import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer' import { createCleanupBudgets } from '@/lib/cleanup/limits' import { beginFileArchiveCleanup, cleanupFileVersions } from '@/lib/file-retention' import { FILES_PER_QUERY } from '@/lib/file-retention/versions' -import { authorizeProject } from '@/lib/projects/application/authorization' -import { projectOperations } from '@/lib/projects/application/operations' +import { loadProjectAccess } from '@/lib/projects/application/authorization' import { lockWorkspaceProject } from '@/lib/projects/membership' import { processWorkspaceFileStorageCleanupsNow } from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox' import { deleteWorkspaceFileVersionInTx } from '@/lib/uploads/contexts/workspace/file-version-store' @@ -446,18 +444,7 @@ describe('Project file retention follows the current payer in PostgreSQL', () => const acquired = createDeferred() const release = createDeferred() const authority = db.transaction(async (tx) => { - await authorizeProject( - tx, - createSessionPrincipal({ userId: f.ownerId }), - projectOperations.get, - { projectId: f.projectId }, - 'hold' - ) - await tx - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, f.workspaceId)) - .for('share') + await loadProjectAccess(tx, f.ownerId, { projectId: f.projectId }) const result = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) acquired.resolve(result[0].pid) await release.promise @@ -750,13 +737,7 @@ describe('Project file retention follows the current payer in PostgreSQL', () => const acquired = createDeferred() const restore = createDeferred() const authority = db.transaction(async (tx) => { - await authorizeProject( - tx, - createSessionPrincipal({ userId: f.ownerId }), - projectOperations.get, - { projectId: f.projectId }, - 'hold' - ) + await loadProjectAccess(tx, f.ownerId, { projectId: f.projectId }) await tx .select({ id: workspace.id }) .from(workspace) diff --git a/apps/sim/lib/projects/files/__integration__/uploads.integration.ts b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts new file mode 100644 index 00000000000..540aa167293 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts @@ -0,0 +1,674 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + folder, + member, + organization, + outboxEvent, + permissions, + uploadSession, + user, + workspace, + workspaceFileSecretProvenance, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +await vi.hoisted(async () => { + const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure') + const redisUrl = readTestRedisUrl() + if (redisUrl) process.env.REDIS_URL = redisUrl +}) + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) + +import { closeRedisConnection } from '@/lib/core/config/redis' +import * as application from '@/lib/projects/files/application' +import * as prefixCleanup from '@/lib/projects/files/prefix-cleanup' +import * as manager from '@/lib/uploads/contexts/workspace/file-manager' +import { + UPLOAD_URL_TTL_MS, + writeLocalMultipartPart, + writeLocalPutObject, +} from '@/lib/uploads/upload-session/provider' +import * as sessions from '@/lib/uploads/upload-session/service' +import { simPageSourceEmbedBlock } from '@/lib/workspace-files/page-source-embed' + +const storageRoot = mkdtempSync(join(tmpdir(), 'sim-project-upload-')) +setUploadDirServer(storageRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@upload.invalid`, + name: 'Upload fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Upload', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Upload environment', + }) + const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) + if (!binding) throw new Error('Project fixture missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + const f = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(f) + return { ...f, principal: createSessionPrincipal({ userId: editorId, sessionId: generateId() }) } +} + +function stream(bytes: Buffer) { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }) +} +function input(projectId: string, fileSize = 4) { + return { + projectId, + fileName: 'architecture.bin', + contentType: 'application/octet-stream', + fileSize, + localOrigin: 'http://localhost:3000', + } +} +function control(projectId: string, session: sessions.UploadSessionRecord) { + return { projectId, uploadId: session.id, uploadToken: session.uploadToken } +} +async function put(session: sessions.UploadSessionRecord, bytes: Buffer) { + await writeLocalPutObject({ + uploadId: session.id, + key: session.finalKey, + body: stream(bytes), + expectedSize: bytes.length, + contentType: session.contentType, + metadata: sessions.uploadSessionObjectMetadata(session), + }) +} +async function usage(id: string) { + const [row] = await db + .select({ bytes: organization.storageUsedBytes }) + .from(organization) + .where(eq(organization.id, id)) + return row.bytes +} + +describe('Project outer transaction cleanup for upload sessions', () => { + check('preserves upload authorization failure when its cleanup hook fails', async () => { + const f = await fixture() + const create = sessions.createUploadSession + const preparation = vi + .spyOn(sessions, 'createUploadSession') + .mockImplementation(async (args) => { + const session = await create(args) + await db.delete(permissions).where(eq(permissions.userId, f.editorId)) + return session + }) + const cleanup = vi + .spyOn(prefixCleanup, 'queueRetiredProjectUploadCleanup') + .mockRejectedValue(new Error('Cleanup database unavailable')) + try { + await expect( + application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId), + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } finally { + preparation.mockRestore() + cleanup.mockRestore() + } + }) + check('retains the created upload session after acknowledgement loss', async () => { + const f = await fixture() + const transaction = db.transaction.bind(db) + let sessionId = '' + let originalStatus: unknown + const primary = new Error('Commit acknowledgement lost') + const interception = vi + .spyOn(db, 'transaction') + .mockImplementation(async (callback, config) => { + const result = await transaction(callback, config) + if ( + isRecordLike(result) && + isRecordLike(result.result) && + result.result.purpose === 'project_file' + ) { + sessionId = String(result.result.id) + originalStatus = result.result.status + throw primary + } + return result + }) + try { + await expect( + application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId), + }) + ).rejects.toBe(primary) + } finally { + interception.mockRestore() + } + expect(sessionId).not.toBe('') + const [row] = await db.select().from(uploadSession).where(eq(uploadSession.id, sessionId)) + expect(row.status).toBe(originalStatus) + }) + check('retains committed restored upload source after acknowledgement loss', async () => { + const f = await fixture() + const source = '---\ntitle: Architecture\n---\n# Notes\nShared Project description' + const bytes = Buffer.from( + `${simPageSourceEmbedBlock(source)}Compiled page` + ) + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: { + ...input(f.projectId, bytes.length), + fileName: 'Architecture.html', + contentType: 'text/html', + }, + }) + await put(session, bytes) + const transaction = db.transaction.bind(db) + const primary = new Error('Commit acknowledgement lost') + let key = '' + const interception = vi + .spyOn(db, 'transaction') + .mockImplementation(async (callback, config) => { + const result = await transaction(callback, config) + if ( + !key && + isRecordLike(result) && + isRecordLike(result.result) && + isRecordLike(result.result.file) + ) { + key = String(result.result.file.key) + throw primary + } + return result + }) + try { + await expect( + application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + ).rejects.toBe(primary) + } finally { + interception.mockRestore() + } + expect(key).not.toBe('') + expect(await readFile(join(storageRoot, key), 'utf8')).toBe(source) + expect( + await db.select().from(outboxEvent).where(sql`${outboxEvent.payload}->>'key' = ${key}`) + ).toEqual([]) + }) +}) + +describe('Project upload sessions with real leases, storage, and accounting', () => { + check( + 'PUT completion creates one Project file attributed to the actor and replay never bills twice', + async () => { + const f = await fixture() + const directory = await application.createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Design' }, + }) + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: { ...input(f.projectId), folderId: directory.folder.id }, + }) + expect(session).toMatchObject({ + workspaceId: null, + purpose: 'project_file', + storageContext: 'project', + userId: f.editorId, + }) + await put(session, Buffer.from([0, 255, 17, 1])) + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(result.value.file).toMatchObject({ + owner: { entityType: 'project', entityId: f.projectId }, + uploadedBy: f.editorId, + folderPath: 'Design', + size: 4, + }) + expect(await readFile(join(storageRoot, result.value.file.key))).toEqual( + Buffer.from([0, 255, 17, 1]) + ) + const replay = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(replay.value.file.id).toBe(result.value.file.id) + expect(replay.alreadyCompleted).toBe(true) + expect(await usage(f.organizationId)).toBe(4) + const polled = await application.getProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(polled.file?.id).toBe(result.value.file.id) + await expect( + application.abortProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) + + check( + 'multipart completion assembles real parts and refuses another credential or revoked caller on every control leg', + async () => { + const f = await fixture() + const size = sessions.UPLOAD_SESSION_PART_SIZE + 1 + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId, size), + }) + const target = { + ...control(f.projectId, session), + localOrigin: 'http://localhost:3000', + partNumbers: [1, 2], + } + const wrong = { ...f.principal, sessionId: generateId() } + for (const operation of [ + application.getProjectFileUploadSession, + application.getProjectFileUploadPartUrls, + application.completeProjectFileUploadSession, + application.abortProjectFileUploadSession, + ]) { + await expect(operation.execute({ principal: wrong, input: target })).rejects.toMatchObject({ + code: 'not_found', + }) + } + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + for (const operation of [ + application.getProjectFileUploadPartUrls, + application.completeProjectFileUploadSession, + application.abortProjectFileUploadSession, + ]) { + await expect( + operation.execute({ principal: f.principal, input: target }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } + await db + .update(permissions) + .set({ permissionType: 'admin' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + expect( + ( + await application.getProjectFileUploadPartUrls.execute({ + principal: f.principal, + input: target, + }) + ).parts + ).toHaveLength(2) + await writeLocalMultipartPart({ + uploadId: session.id, + partNumber: 1, + body: stream(Buffer.alloc(sessions.UPLOAD_SESSION_PART_SIZE, 7)), + expectedSize: sessions.UPLOAD_SESSION_PART_SIZE, + }) + await writeLocalMultipartPart({ + uploadId: session.id, + partNumber: 2, + body: stream(Buffer.from([9])), + expectedSize: 1, + }) + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: target, + }) + const content = await readFile(join(storageRoot, result.value.file.key)) + expect(content.length).toBe(size) + expect(content[0]).toBe(7) + expect(content[size - 1]).toBe(9) + expect(await usage(f.organizationId)).toBe(size) + } + ) + + check( + 'completion enforces current quota atomically and failed registration remains retryable without deleting bytes', + async () => { + const f = await fixture() + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '1') + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId), + }) + await put(session, Buffer.from('file')) + await db + .update(organization) + .set({ storageUsedBytes: 1024 ** 3 }) + .where(eq(organization.id, f.organizationId)) + await expect( + application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + expect(await readFile(join(storageRoot, session.finalKey), 'utf8')).toBe('file') + expect( + await db.select().from(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + ).toHaveLength(0) + await db + .update(organization) + .set({ storageUsedBytes: 0 }) + .where(eq(organization.id, f.organizationId)) + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(result.value.file.size).toBe(4) + expect(await usage(f.organizationId)).toBe(4) + } + ) + + check( + 'Copilot retries bind the current invocation and preserve unknown provenance when no trusted source was supplied', + async () => { + const f = await fixture() + const issuedAt = new Date() + const principal: ResourceDelegatedPrincipal = { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: f.editorId, + delegationId: generateId(), + audience: 'sim:project-files', + issuedAt, + expiresAt: new Date(issuedAt.getTime() + 60_000), + invocation: { kind: 'workspace', workspaceId: f.workspaceId }, + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, + } + const session = await application.createProjectFileUploadSession.execute({ + principal, + input: input(f.projectId), + }) + await put(session, Buffer.from('file')) + const retryIssuedAt = new Date() + const fresh = { + ...principal, + delegationId: generateId(), + issuedAt: retryIssuedAt, + expiresAt: new Date(retryIssuedAt.getTime() + 60_000), + } + const result = await application.completeProjectFileUploadSession.execute({ + principal: fresh, + input: control(f.projectId, session), + }) + const [provenance] = await db + .select() + .from(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, result.value.file.id)) + expect(provenance.status).toBe('unknown') + expect(provenance.entries).toEqual([]) + expect(result.value.file.uploadedBy).toBe(f.editorId) + } + ) + + check( + 'an expired completion proof cannot register after another real completion claimed its lease', + async () => { + const f = await fixture() + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId), + }) + await put(session, Buffer.from('file')) + await expect( + sessions.completeUploadSession({ + session, + finalize: async (first) => { + const staged = manager.adoptVerifiedUploadSession(first, { + entityType: 'project', + entityId: f.projectId, + }) + await expect(manager.discardStagedFileContent(staged)).rejects.toThrow('upload session') + await db + .update(uploadSession) + .set({ processingLeaseExpiresAt: new Date(Date.now() - 1000) }) + .where(eq(uploadSession.id, first.id)) + const entered = createDeferred() + const release = createDeferred() + const second = sessions.completeUploadSession({ + session: first, + finalize: async () => { + entered.resolve() + await release.promise + throw new Error('Second lease owns completion') + }, + }) + await entered.promise + try { + await expect( + db.transaction((tx) => sessions.lockUploadSessionRegistrationInTx(tx, first)) + ).rejects.toMatchObject({ code: 'conflict' }) + } finally { + release.resolve() + await expect(second).rejects.toThrow('Second lease owns completion') + } + throw new Error('First completion stopped after registration admission check') + }, + }) + ).rejects.toThrow('First completion stopped after registration admission check') + expect(await readFile(join(storageRoot, session.finalKey), 'utf8')).toBe('file') + expect(await usage(f.organizationId)).toBe(0) + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(result.value.file.size).toBe(4) + } + ) + + check('part URLs signed during a concurrent access revocation are not exposed', async () => { + const f = await fixture() + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId, 8 * 1024 * 1024 + 1), + }) + const entered = createDeferred() + const release = createDeferred() + const createRealUrls = sessions.createUploadPartUrls + const signing = vi.spyOn(sessions, 'createUploadPartUrls').mockImplementation(async (args) => { + const result = await createRealUrls(args) + entered.resolve() + await release.promise + return result + }) + const pending = application.getProjectFileUploadPartUrls.execute({ + principal: f.principal, + input: { + ...control(f.projectId, session), + partNumbers: [1], + localOrigin: 'http://localhost:3000', + }, + }) + await entered.promise + try { + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + release.resolve() + await expect(pending).rejects.toMatchObject({ code: 'forbidden' }) + } finally { + release.resolve() + await pending.catch(() => undefined) + signing.mockRestore() + } + }) + + check( + 'compiled page uploads restore editable source without breaking receipt replay or charging compiled bytes', + async () => { + const f = await fixture() + const source = '---\ntitle: Architecture\n---\n# Notes\nShared Project description' + const bytes = Buffer.from( + `${simPageSourceEmbedBlock(source)}Compiled page` + ) + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: { + ...input(f.projectId, bytes.length), + fileName: 'Architecture.html', + contentType: 'text/html', + }, + }) + await put(session, bytes) + const completionStartedAt = Date.now() + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(result.value.file).toMatchObject({ + name: 'Architecture', + type: 'text/x-sim-page', + size: Buffer.byteLength(source), + }) + expect(await readFile(join(storageRoot, result.value.file.key), 'utf8')).toBe(source) + const [originalCleanup] = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}->>'key' = ${session.finalKey}`) + expect(originalCleanup.availableAt.getTime()).toBeGreaterThanOrEqual( + completionStartedAt + UPLOAD_URL_TTL_MS + ) + expect(await usage(f.organizationId)).toBe(Buffer.byteLength(source)) + const replay = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(replay.value.file.id).toBe(result.value.file.id) + expect(await usage(f.organizationId)).toBe(Buffer.byteLength(source)) + } + ) +}) + +afterAll(async () => { + for (const f of fixtures) { + await db.delete(uploadSession).where(inArray(uploadSession.userId, [f.ownerId, f.editorId])) + await db + .delete(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`}`) + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await db.delete(folder).where(eq(folder.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(storageRoot, { recursive: true, force: true }) + await closeRedisConnection() + const report = process.env.PROJECT_UPLOAD_REPORT_PATH ?? 'test-results/project-upload.json' + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, `${JSON.stringify({ checks }, null, 2)}\n`) + await db.$client.end() +}) + +check( + 'the bound upload credential can poll after a read downgrade but cannot complete', + async () => { + const f = await fixture() + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId), + }) + await put(session, Buffer.from([0, 255, 17, 1])) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(eq(permissions.userId, f.editorId)) + const args = { principal: f.principal, input: control(f.projectId, session) } + const polled = await application.getProjectFileUploadSession.execute(args) + expect(polled.session.id).toBe(session.id) + expect(polled.file).toBeNull() + await expect(application.completeProjectFileUploadSession.execute(args)).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(await usage(f.organizationId)).toBe(0) + } +) diff --git a/apps/sim/lib/projects/files/api/content-presenter.ts b/apps/sim/lib/projects/files/api/content-presenter.ts new file mode 100644 index 00000000000..31ce36ac8d3 --- /dev/null +++ b/apps/sim/lib/projects/files/api/content-presenter.ts @@ -0,0 +1,21 @@ +import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' + +/** Presents authorized bytes without caching the mutable file URL. */ +export function presentProjectFileContent({ + file, + stream, + contentLength, +}: { + file: OwnedFileRecord + stream: ReadableStream + contentLength: number +}) { + return presentFileDelivery({ + body: stream, + filename: file.name, + contentType: file.type, + contentLength, + cacheControl: FILE_CACHE_CONTROL.noStore, + }) +} diff --git a/apps/sim/lib/projects/files/api/index.ts b/apps/sim/lib/projects/files/api/index.ts new file mode 100644 index 00000000000..0ab5b3517d8 --- /dev/null +++ b/apps/sim/lib/projects/files/api/index.ts @@ -0,0 +1,5 @@ +export { toFileVersion as toProjectFileVersion } from '@/lib/workspace-files/api/version-presenters' +export { presentProjectFileContent } from './content-presenter' +export { toV2ProjectFile } from './presenters' +export { presentProjectFileUpload } from './upload-presenter' +export { presentProjectFileVersionContent } from './version-presenters' diff --git a/apps/sim/lib/projects/files/api/presenters.ts b/apps/sim/lib/projects/files/api/presenters.ts new file mode 100644 index 00000000000..ec8af2d8941 --- /dev/null +++ b/apps/sim/lib/projects/files/api/presenters.ts @@ -0,0 +1,42 @@ +import type { ProjectFileFolderRecord } from '@/lib/api/contracts/project-file-folders' +import type { V2ProjectFileFolder } from '@/lib/api/contracts/v2/project-file-folders' +import type { V2ProjectFile } from '@/lib/api/contracts/v2/project-files' +import { buildFolderPath } from '@/lib/folders/paths' +import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' + +/** Public projection of file metadata without collaboration or storage lifecycle internals. */ +export function toV2ProjectFile( + file: OwnedFileRecord<{ entityType: 'project'; entityId: string }> +): V2ProjectFile { + if (file.folderId && file.folderPath === null) { + throw new Error('File references an unresolved folder') + } + return { + id: file.id, + owner: file.owner, + name: file.name, + size: file.size, + type: file.type, + key: file.key, + folderPath: file.folderPath + ? buildFolderPath(parseWorkspaceFileFolderDisplayPath(file.folderPath)) + : '/', + uploadedBy: file.uploadedBy, + uploadedAt: file.uploadedAt.toISOString(), + updatedAt: file.updatedAt.toISOString(), + deletedAt: file.deletedAt?.toISOString() ?? null, + ...workspaceFileRevisionField(file), + } +} + +/** Projects the shared folder record without relying on Date response coercion. */ +export function toV2ProjectFileFolder(folder: ProjectFileFolderRecord): V2ProjectFileFolder { + return { + ...folder, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + deletedAt: folder.deletedAt?.toISOString() ?? null, + } +} diff --git a/apps/sim/lib/projects/files/api/upload-presenter.ts b/apps/sim/lib/projects/files/api/upload-presenter.ts new file mode 100644 index 00000000000..51ddaf46aa9 --- /dev/null +++ b/apps/sim/lib/projects/files/api/upload-presenter.ts @@ -0,0 +1,26 @@ +import type { ProjectFileUploadSession } from '@/lib/api/contracts/project-file-uploads' +import type { V2ProjectFileUpload } from '@/lib/api/contracts/v2/project-file-uploads' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' +import { presentUploadSessionState } from '@/lib/uploads/upload-session/presenter' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' + +/** Selects public fields explicitly; session metadata contains private credential and provenance bindings. */ +export function presentProjectFileUpload( + session: UploadSessionRecord, + file: OwnedFileRecord<{ entityType: 'project'; entityId: string }> | null +): ProjectFileUploadSession { + return { + ...presentUploadSessionState(session), + purpose: 'project_file', + result: file, + } +} + +/** The public control protocol presents canonical file paths after registration. */ +export function toV2ProjectFileUpload( + session: UploadSessionRecord, + file: OwnedFileRecord<{ entityType: 'project'; entityId: string }> | null +): V2ProjectFileUpload { + return { ...presentUploadSessionState(session), file: file ? toV2ProjectFile(file) : null } +} diff --git a/apps/sim/lib/projects/files/api/version-presenters.ts b/apps/sim/lib/projects/files/api/version-presenters.ts new file mode 100644 index 00000000000..788ba360edc --- /dev/null +++ b/apps/sim/lib/projects/files/api/version-presenters.ts @@ -0,0 +1,24 @@ +import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' +import type { WorkspaceFileVersionRecord } from '@/lib/uploads/contexts/workspace/file-version-store' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' +/** Historical source bytes use the selected version's type rather than the current head's type. */ +export function presentProjectFileVersionContent({ + file, + version, + stream, + contentLength, +}: { + file: OwnedFileRecord + version: WorkspaceFileVersionRecord + stream: ReadableStream + contentLength: number +}) { + return presentFileDelivery({ + body: stream, + filename: file.name, + contentType: version.contentType, + contentLength, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }) +} diff --git a/apps/sim/lib/projects/files/application/authorization.ts b/apps/sim/lib/projects/files/application/authorization.ts new file mode 100644 index 00000000000..17e3958f226 --- /dev/null +++ b/apps/sim/lib/projects/files/application/authorization.ts @@ -0,0 +1,294 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + copilotChats, + permissions, + type WorkspaceFileRow, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' +import { requireOrganizationSubjectMembership } from '@/lib/core/application/organization-authorization' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' +import { + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + requireCurrentHumanRole, + requireUserCredentialCapabilities, +} from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { conversationModeSelection } from '@/lib/mothership/chat/intent' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { loadProjectAccess } from '@/lib/projects/application/authorization' +import { resolveCopilotProjectScope } from '@/lib/projects/application/discovery' +import { + PROJECT_FILE_DELEGATION_TTL_MS, + type ProjectFileOperation, + type ProjectFilePrincipal, + projectFileOperations, +} from '@/lib/projects/files/application/operations' +import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' +import { + requireCurrentFileSubject, + requireFileSubject, +} from '@/lib/workspace-files/application/subject' +import { matchesFileOwner, resolveFileOwner } from '@/lib/workspace-files/ownership' + +export interface ProjectFileTarget { + projectId: string + fileId?: string +} + +export interface ProjectFileAuthorizationContext { + projectId: string + organizationId: string | null + ownerUserId: string + owner: { entityType: 'project'; entityId: string } + canWrite: boolean + visibleWorkspaceIds: readonly string[] + file?: WorkspaceFileRow +} + +function requirePrincipal( + principal: Principal, + operation: ProjectFileOperation, + input: ProjectFileTarget +): asserts principal is ProjectFilePrincipal { + if (!Object.values(projectFileOperations).some((registered) => registered === operation)) { + throw new Error('Unregistered Project file operation') + } + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + } + requireOAuthOperationScope(principal, operation) + if (operation.target === 'file' && !input.fileId) { + throw new OrchestrationError('validation', 'File ID is required') + } + if (operation.target !== 'file' && input.fileId !== undefined) { + throw new OrchestrationError('validation', 'This operation targets the Project file collection') + } + if (principal.kind === 'resource_delegated') { + requireResourceDelegation(principal, { + audience: operation.delegationAudience, + services: operation.delegatedServices, + scope: + operation.target === 'collection_observation' + ? { + kind: 'file_collection_observation', + entityType: 'project', + entityId: input.projectId, + } + : { + kind: 'entity', + entityType: 'project', + entityId: input.projectId, + ...(input.fileId ? { fileId: input.fileId } : {}), + }, + maxTtlMs: PROJECT_FILE_DELEGATION_TTL_MS, + }) + } +} + +/** Rechecks canonical conversation ownership and authoring capability while authorization locks are held. */ +async function requireCurrentCopilotProjectInvocation( + tx: DbTransaction, + principal: Extract, + access: Awaited>, + scope: Awaited> +) { + if (scope.organizationId !== access.record.organizationId) { + throw new OrchestrationError('not_found', 'Project not found in this conversation') + } + let workspaceId: string | null + if (principal.invocation.kind === 'chat') { + const [chat] = await tx + .select({ + userId: copilotChats.userId, + workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, + type: copilotChats.type, + mode: conversationModeSelection, + }) + .from(copilotChats) + .where(and(eq(copilotChats.id, principal.invocation.chatId), isNull(copilotChats.deletedAt))) + .for('share') + .limit(1) + if ( + !chat || + chat.userId !== principal.subjectUserId || + Boolean(chat.workspaceId) === Boolean(chat.organizationId) + ) { + throw new OrchestrationError('not_found', 'Chat not found') + } + if (chat.organizationId) { + if ( + chat.organizationId !== scope.organizationId || + chat.organizationId !== access.record.organizationId || + chat.type !== 'mothership' || + (chat.mode !== 'agent' && chat.mode !== 'plan') + ) { + throw new OrchestrationError('not_found', 'Project not found in this conversation') + } + await requireOrganizationSubjectMembership( + principal.subjectUserId, + chat.organizationId, + 'member', + 'copilot.use', + undefined, + { executor: tx } + ) + return + } + workspaceId = chat.workspaceId + } else { + workspaceId = principal.invocation.workspaceId + } + if (!workspaceId || workspaceId !== scope.workspaceId) { + throw new OrchestrationError('not_found', 'Project not found in this conversation') + } + const [origin] = await tx + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .for('share') + .limit(1) + if (!origin || origin.workspaceOrganizationId !== scope.organizationId) + throw new OrchestrationError('not_found', 'Workspace not found in this conversation') + // Hold origin grants without upgrading the target's existing SHARE locks. + await tx + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.userId, principal.subjectUserId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .for('share') + await requireCurrentHumanRole(principal.subjectUserId, origin, 'read', { executor: tx }) + await assertWorkspaceCapability( + principal.subjectUserId, + workspaceId, + 'copilot.use', + origin.workspaceOrganizationId, + tx + ) +} + +/** Shared owner policy keeps compound copies subject to the same live Project edit rule. */ +async function requireProjectFileOwnerRole( + tx: DbTransaction, + principal: ProjectFilePrincipal, + access: Awaited>, + accessMode: 'read' | 'write' +): Promise { + if (access.record.archivedAt) throw new OrchestrationError('conflict', 'Project is archived') + await requireCurrentFileSubject(tx, principal) + const canWrite = + access.orgAdmin || + access.active.some((row) => row.permission === 'admin') || + (access.active.length > 0 && + access.active.every((row) => row.permission === 'write' || row.permission === 'admin')) + if (accessMode === 'write' && !canWrite) { + throw new OrchestrationError( + 'forbidden', + 'Project file editing requires admin access in an environment or write access in every active environment' + ) + } + return { + projectId: access.record.id, + organizationId: access.record.organizationId, + ownerUserId: access.record.ownerId, + owner: { entityType: 'project', entityId: access.record.id }, + canWrite, + visibleWorkspaceIds: access.visible.map((environment) => environment.id), + } +} + +/** Credential and Files capability checks apply to every currently visible environment. */ +async function requireProjectFileOwnerCapabilities( + tx: DbTransaction, + principal: ProjectFilePrincipal, + access: Awaited>, + capability: ProjectFileOperation['capability'] +): Promise { + const userId = requirePrincipalSubjectUserId(principal) + for (const environment of access.visible) { + const context = { + workspaceId: environment.id, + workspaceOrganizationId: access.record.organizationId, + allowPersonalApiKeys: environment.allowPersonalApiKeys, + } + if (principal.kind === 'personal_api_key' || principal.kind === 'oauth_access_token') { + if (!context.allowPersonalApiKeys) throw new PersonalApiKeysDisabledError() + await requireUserCredentialCapabilities(principal, context, tx) + } + // permission-group-enforced: files.use — every accessible active environment applies, independent of Issues. + await assertWorkspaceCapability( + userId, + environment.id, + capability, + access.record.organizationId, + tx + ) + } +} + +/** Prepares trusted invocation outside the transaction; the returned closure rechecks authority inside it. */ +export async function createProjectFileAuthorizer( + principal: Principal, + operation: ProjectFileOperation, + input: ProjectFileTarget +): Promise<(tx: DbTransaction) => Promise> { + requirePrincipal(principal, operation, input) + await requireProjectApiEnabled() + const userId = await requireFileSubject(principal) + const invocationScope = + principal.kind === 'resource_delegated' && principal.serviceId === 'copilot' + ? await resolveCopilotProjectScope(principal) + : undefined + + return async (tx) => { + requirePrincipal(principal, operation, input) + const access = await loadProjectAccess(tx, userId, { projectId: input.projectId }) + const context = await requireProjectFileOwnerRole(tx, principal, access, operation.access) + let file: WorkspaceFileRow | undefined + if (operation.target === 'file') { + const query = tx + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, input.fileId ?? ''), + 'fileScope' in operation && operation.fileScope === 'all' + ? undefined + : isNull(workspaceFiles.deletedAt) + ) + ) + const rows = await (operation.access === 'write' ? query : query.for('share')).limit(1) + file = rows[0] + const owner = file ? resolveFileOwner(file) : null + if (!matchesFileOwner(owner, context.owner)) { + throw new OrchestrationError('not_found', 'File not found') + } + } + if (access.record.organizationId) + await acquirePermissionGroupOrgLock(tx, access.record.organizationId) + if (principal.kind === 'resource_delegated' && principal.serviceId === 'copilot') { + if (!invocationScope) throw new Error('Copilot Project invocation was not prepared') + await requireCurrentCopilotProjectInvocation(tx, principal, access, invocationScope) + } + await requireProjectFileOwnerCapabilities(tx, principal, access, operation.capability) + return { + ...context, + ...(file ? { file } : {}), + } + } +} diff --git a/apps/sim/lib/projects/files/application/authorized-use-case.ts b/apps/sim/lib/projects/files/application/authorized-use-case.ts new file mode 100644 index 00000000000..d23c1f25c9f --- /dev/null +++ b/apps/sim/lib/projects/files/application/authorized-use-case.ts @@ -0,0 +1,146 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { createLogger } from '@sim/logger' +import { describeError, isPostgresCommitRejection } from '@sim/utils/errors' +import { + type AuthorizingUseCase, + recordProjectedUseCaseAuditEntries, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { + createProjectFileAuthorizer, + type ProjectFileAuthorizationContext, + type ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import type { ProjectFileOperation } from '@/lib/projects/files/application/operations' +import { notifyFileListChanged } from '@/lib/realtime/notify' + +const logger = createLogger('ProjectFileUseCase') + +interface ProjectFileUseCaseContext { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + request?: OrchestrationRequestContext +} + +/** Project file operations authorize and mutate under the same canonical Project transaction. */ +export function defineAuthorizedProjectFileUseCase< + const O extends ProjectFileOperation, + I extends ProjectFileTarget, + R, + P = undefined, +>(definition: { + operation: O + isolationLevel?: 'repeatable read' + prepare?(args: ProjectFileUseCaseContext): Promise

+ execute(args: ProjectFileUseCaseContext & { tx: DbTransaction; prepared?: P }): Promise + onCommitFailure?( + args: ProjectFileUseCaseContext & { prepared: P; error: unknown } + ): Promise + projectAudit?( + args: ProjectFileUseCaseContext & { result: NoInfer } + ): WorkspaceUseCaseAuditEntry | WorkspaceUseCaseAuditEntry[] + afterSuccess?(args: ProjectFileUseCaseContext & { result: NoInfer }): void | Promise + invalidatesFileList?: + | boolean + | ((args: ProjectFileUseCaseContext & { result: NoInfer }) => boolean) +}): AuthorizingUseCase { + return { + operation: definition.operation, + delegationAudience: definition.operation.delegationAudience, + async authorize(args) { + const authorize = await createProjectFileAuthorizer( + args.principal, + definition.operation, + args.input + ) + await db.transaction(authorize) + }, + async execute(args) { + let prepared: P | undefined + let preparationContext: ProjectFileAuthorizationContext | undefined + const prepare = definition.prepare + if (prepare) { + const preflight = await createProjectFileAuthorizer( + args.principal, + definition.operation, + args.input + ) + const context = await db.transaction(preflight) + preparationContext = context + prepared = await runWithOutboundOrganization(context.organizationId, () => + prepare({ ...args, context }) + ) + } + let committed: { context: ProjectFileAuthorizationContext; result: R } + let callbackCompleted = false + try { + const authorize = await createProjectFileAuthorizer( + args.principal, + definition.operation, + args.input + ) + committed = await db.transaction( + async (tx) => { + const context = await authorize(tx) + const result = await runWithOutboundOrganization(context.organizationId, () => + definition.execute({ ...args, context, tx, prepared }) + ) + callbackCompleted = true + return { context, result } + }, + definition.isolationLevel ? { isolationLevel: definition.isolationLevel } : undefined + ) + } catch (error) { + if (callbackCompleted && !isPostgresCommitRejection(error)) { + logger.error('Project file commit outcome is uncertain; retaining prepared resources', { + operation: definition.operation.id, + projectId: args.input.projectId, + fileId: args.input.fileId, + error: describeError(error), + }) + } else if (prepared !== undefined && preparationContext) { + try { + await definition.onCommitFailure?.({ + ...args, + context: preparationContext, + prepared, + error, + }) + } catch (cleanupError) { + logger.error('Project file failure cleanup could not complete', { + operation: definition.operation.id, + owner: preparationContext.owner, + fileId: args.input.fileId, + error: describeError(cleanupError), + }) + } + } + throw error + } + const { context, result } = committed + const resultContext = { ...args, context, result } + const invalidates = definition.invalidatesFileList + if (typeof invalidates === 'function' ? invalidates(resultContext) : invalidates) { + await notifyFileListChanged(context.owner) + } + const audit = definition.projectAudit?.(resultContext) + if (audit !== undefined) { + recordProjectedUseCaseAuditEntries( + definition.operation, + null, + args.principal, + args.request, + Array.isArray(audit) ? audit : [audit], + context.organizationId ?? undefined + ) + } + await definition.afterSuccess?.(resultContext) + return result + }, + } +} diff --git a/apps/sim/lib/projects/files/application/collection-access.ts b/apps/sim/lib/projects/files/application/collection-access.ts new file mode 100644 index 00000000000..6910af58b82 --- /dev/null +++ b/apps/sim/lib/projects/files/application/collection-access.ts @@ -0,0 +1,10 @@ +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +/** Collection subscribers use current Project read policy without receiving any file authority. */ +export const getProjectFileListAccess = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.observeCollection, + async execute({ context }) { + return { projectId: context.projectId, canRead: true as const } + }, +}) diff --git a/apps/sim/lib/projects/files/application/content.ts b/apps/sim/lib/projects/files/application/content.ts new file mode 100644 index 00000000000..eb7e8dd722e --- /dev/null +++ b/apps/sim/lib/projects/files/application/content.ts @@ -0,0 +1,389 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { PreparedCollabDocState } from '@/lib/collab-doc/state' +import { isCopilotPrincipal, requireAdmittedContent } from '@/lib/core/application/content-delivery' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import type { DbTransaction } from '@/lib/db/types' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + finishProjectFileWrite, + mapProjectFileResult, + prepareProjectFileAccounting, + recordProjectFileWriteEffects, +} from '@/lib/projects/files/application/write-effects' +import { + ContentVersionConflictError, + commitFileContentInTx, + commitFileCreateInTx, + discardStagedFileContent, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/file-manager' +import { + EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + getBoundWorkspaceFileSecretProvenanceByMetadata, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { downloadFile, downloadFileStream } from '@/lib/uploads/core/storage-service' +import { enqueueFileLiveDocReconciliation } from '@/lib/uploads/server/live-doc-outbox' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { isMarkdownFile } from '@/lib/uploads/utils/file' +import { + hasWorkspaceFileDeliveryObserver, + reportWorkspaceFileDelivery, +} from '@/lib/workspace-files/application/file-delivery-observer' +import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision' +import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { restoreSimPageSourceBuffer } from '@/lib/workspace-files/page-source-embed' + +interface ContentUseCaseArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + tx: DbTransaction + prepared?: P +} + +export interface CreateProjectFileInput extends ProjectFileTarget { + name: string + contentType: string + content: string + encoding: 'utf-8' | 'base64' + folderId?: string | null + folderPath?: string + exactName?: boolean + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface UpdateProjectFileContentInput extends ProjectFileTarget { + fileId: string + /** Private realtime state committed under the same file revision fence; absent from HTTP contracts. */ + collabDocState?: PreparedCollabDocState + content: string + encoding: 'utf-8' | 'base64' + contentType?: string + expectedRevision?: string + expectedUpdatedAt?: Date + provenanceMode?: 'replace_empty' | 'preserve' + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface ReadProjectFileContentInput extends ProjectFileTarget { + fileId: string + maxBytes?: number + signal?: AbortSignal + includeSecretProvenance?: boolean +} + +function requireFile(context: ProjectFileAuthorizationContext) { + if (!context.file) throw new OrchestrationError('not_found', 'File not found') + return context.file +} + +function decodeContent(input: { content: string; encoding: 'utf-8' | 'base64' }) { + const content = Buffer.from(input.content, input.encoding) + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) + throw new OrchestrationError( + 'payload_too_large', + 'File content exceeds the inline upload limit' + ) + return content +} + +function replacementProvenance( + principal: Principal, + provenance?: WorkspaceFileSecretProvenance +): WorkspaceFileSecretProvenance { + return ( + provenance ?? + (principal.kind === 'resource_delegated' + ? { status: 'unknown' } + : EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE) + ) +} + +export const createProjectFile = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.create, + invalidatesFileList: true, + async prepare({ + principal, + input, + context, + }: Omit, 'tx'>) { + const content = decodeContent(input) + const restored = restoreSimPageSourceBuffer(input.name, content) + return stageFileContent({ + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: restored?.name ?? input.name, + contentType: restored ? SIM_PAGE_CONTENT_TYPE : input.contentType, + content: restored?.buffer ?? content, + }) + }, + async execute({ + principal, + input, + context, + tx, + prepared, + }: ContentUseCaseArgs) { + if (!prepared) throw new Error('File content was not staged') + const accounting = await prepareProjectFileAccounting(tx, context) + const file = await commitFileCreateInTx(tx, { + owner: context.owner, + staged: prepared, + userId: requirePrincipalSubjectUserId(principal), + folderId: input.folderId, + folderPath: input.folderPath, + exactName: input.exactName, + secretProvenance: replacementProvenance(principal, input.secretProvenance), + }) + const usage = await accounting.mutation.applyDelta(prepared.size) + const result = await mapProjectFileResult(tx, context, file) + recordProjectFileWriteEffects(result, { + billing: accounting.billing, + usage, + delta: prepared.size, + cleanupIds: [], + }) + return result + }, + onCommitFailure: ({ prepared }) => discardStagedFileContent(prepared), + afterSuccess: ({ result }) => finishProjectFileWrite(result), + projectAudit: ({ input, result }) => ({ + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Uploaded Project file "${result.file.name}"`, + metadata: { + projectId: input.projectId, + fileSize: result.file.size, + fileType: result.file.type, + }, + }), +}) + +export const updateProjectFileContent = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.updateContent, + invalidatesFileList: ({ context, result }) => context.file?.key !== result.file.key, + async prepare({ + principal, + input, + context, + }: Omit, 'tx'>) { + const file = requireFile(context) + return stageFileContent({ + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: file.originalName, + contentType: input.contentType ?? file.contentType, + content: decodeContent(input), + }) + }, + async execute({ + principal, + input, + context, + tx, + prepared, + }: ContentUseCaseArgs) { + if (!prepared) throw new Error('File content was not staged') + const accounting = await prepareProjectFileAccounting(tx, context) + let committed + try { + committed = await commitFileContentInTx(tx, { + owner: context.owner, + fileId: input.fileId, + staged: prepared, + version: resolveWorkspaceFileVersionWrite(principal), + collabDocState: input.collabDocState, + expectedUpdatedAt: input.expectedRevision + ? parseWorkspaceFileRevision(input.expectedRevision, input.fileId) + : input.expectedUpdatedAt, + secretProvenancePolicy: + input.provenanceMode === 'preserve' + ? { mode: 'preserve' } + : { + mode: 'replace', + provenance: replacementProvenance(principal, input.secretProvenance), + }, + }) + } catch (error) { + if (error instanceof ContentVersionConflictError) + throw new OrchestrationError('conflict', error.message) + throw error + } + const liveDocEventId = + !input.collabDocState && + (isMarkdownFile({ + name: committed.previous.originalName, + type: committed.previous.contentType, + }) || + isMarkdownFile({ name: committed.file.originalName, type: committed.file.contentType })) + ? await enqueueFileLiveDocReconciliation(tx, { + owner: context.owner, + fileId: committed.file.id, + version: committed.file.contentUpdatedAt.getTime(), + }) + : undefined + const usage = await accounting.mutation.applyDelta(committed.sizeDiff) + const mapped = await mapProjectFileResult(tx, context, committed.file) + const result = { ...mapped, file: { ...mapped.file, currentVersion: committed.currentVersion } } + recordProjectFileWriteEffects(result, { + billing: accounting.billing, + usage, + delta: committed.sizeDiff, + cleanupIds: committed.storageCleanupEventIds, + liveDocEventId, + }) + return result + }, + onCommitFailure: ({ prepared }) => discardStagedFileContent(prepared), + afterSuccess: ({ result }) => finishProjectFileWrite(result), + projectAudit: ({ input, result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Updated Project file "${result.file.name}"`, + metadata: { projectId: input.projectId, contentSize: result.file.size }, + }), +}) + +interface PreparedFileRead { + key: string + revision: Date + content: Buffer +} + +const readDeliveryProvenance = new WeakMap() + +export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readContent, + async prepare({ + principal, + input, + }: Omit, 'tx'>): Promise { + const snapshot = await captureContentDownload.execute({ principal, input }) + const file = snapshot.file + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: file.id, + provenance: snapshot.secretProvenance, + view: 'opaque', + }) + const content = await downloadFile({ + key: file.key, + context: 'project', + maxBytes: input.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES, + signal: input.signal, + }) + return { key: file.key, revision: file.contentUpdatedAt, content } + }, + async execute({ + principal, + input, + context, + tx, + prepared, + }: ContentUseCaseArgs) { + const file = requireFile(context) + if ( + !prepared || + file.key !== prepared.key || + file.contentUpdatedAt.getTime() !== prepared.revision.getTime() + ) + throw new OrchestrationError('conflict', 'File changed while its content was being read') + const provenance = + input.includeSecretProvenance || + hasWorkspaceFileDeliveryObserver() || + isCopilotPrincipal(principal) + ? ((await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file])).get(file.id) ?? { + status: 'unknown' as const, + }) + : undefined + const result = { + ...(await mapProjectFileResult(tx, context, file)), + content: prepared.content, + ...(input.includeSecretProvenance && provenance ? { secretProvenance: provenance } : {}), + } + if (provenance) readDeliveryProvenance.set(result, provenance) + return result + }, + afterSuccess: async ({ principal, result }) => { + const provenance = readDeliveryProvenance.get(result) + readDeliveryProvenance.delete(result) + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: result.file.id, + provenance: provenance ?? { status: 'unknown' }, + view: 'opaque', + }) + await reportWorkspaceFileDelivery(provenance, result.file.id) + }, +}) + +const captureContentDownload = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readContent, + async execute({ context, tx }: ContentUseCaseArgs) { + const file = requireFile(context) + const provenance = (await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file])).get( + file.id + ) ?? { status: 'unknown' as const } + return { + ...(await mapProjectFileResult(tx, context, file)), + organizationId: context.organizationId, + secretProvenance: provenance, + } + }, +}) + +/** Streams source bytes after rechecking the canonical file and access following storage open. */ +export const downloadProjectFileContent = { + operation: captureContentDownload.operation, + delegationAudience: captureContentDownload.delegationAudience, + authorize: captureContentDownload.authorize, + async execute(args: Parameters[0]) { + const snapshot = await captureContentDownload.execute(args) + await requireAdmittedContent(args.principal, { + kind: 'workspace-file', + id: snapshot.file.id, + provenance: snapshot.secretProvenance, + view: 'opaque', + }) + const stream = nodeReadableToWebStream( + await runWithOutboundOrganization(snapshot.organizationId, () => + downloadFileStream({ key: snapshot.file.key, context: 'project' }) + ) + ) + try { + const current = await captureContentDownload.execute(args) + if ( + current.file.key !== snapshot.file.key || + current.file.contentUpdatedAt.getTime() !== snapshot.file.contentUpdatedAt.getTime() + ) + throw new OrchestrationError('conflict', 'File changed while its content was being read') + await requireAdmittedContent(args.principal, { + kind: 'workspace-file', + id: current.file.id, + provenance: current.secretProvenance, + view: 'opaque', + }) + await reportWorkspaceFileDelivery(current.secretProvenance, current.file.id) + return { file: current.file, stream, contentLength: current.file.size } + } catch (error) { + await stream.cancel(error).catch(() => undefined) + throw error + } + }, +} diff --git a/apps/sim/lib/projects/files/application/document-lifecycle.ts b/apps/sim/lib/projects/files/application/document-lifecycle.ts index 2335d586dcb..52a8427ecf6 100644 --- a/apps/sim/lib/projects/files/application/document-lifecycle.ts +++ b/apps/sim/lib/projects/files/application/document-lifecycle.ts @@ -1,7 +1,83 @@ +import { workspaceFiles } from '@sim/db/schema' +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' -import type { OutboxHandlerRegistry } from '@/lib/core/outbox/service' +import { and, eq } from 'drizzle-orm' +import * as Y from 'yjs' +import { loadCollabDocState, saveCollabDocStateInTx } from '@/lib/collab-doc/state' +import { + enqueueOutboxEvent, + type OutboxHandlerRegistry, + processOutboxEventById, +} from '@/lib/core/outbox/service' +import type { DbTransaction } from '@/lib/db/types' import { PROJECT_FILE_DOCUMENT_RETIRE_EVENT } from '@/lib/projects/files/outbox-events' -import { retireLiveProjectFileDoc } from '@/lib/realtime/notify' +import { retireLiveFileDoc } from '@/lib/realtime/notify' + +interface ProjectDocumentRetirement { + projectId: string + fileId: string + retiredDocId: string + replacementDocId: string +} + +function documentIdentity(state: Uint8Array): string { + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, state) + const id = doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + if (typeof id !== 'string' || !id || id.length > 128) + throw new Error('Project document has no valid cached identity') + return id + } finally { + doc.destroy() + } +} + +/** Read a bounded canonical identity while the caller holds the authorized file row lock. */ +export async function readProjectFileDocIdInTx(tx: DbTransaction, fileId: string) { + const cached = await loadCollabDocState(fileId, undefined, tx) + return cached ? documentIdentity(cached.docState) : null +} + +/** Archive and restore retire the cached history atomically with the file lifecycle mutation. */ +export async function rotateProjectFileDocInTx( + tx: DbTransaction, + target: { projectId: string; fileId: string } +): Promise<{ outboxEventId: string } | null> { + const [file] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, target.fileId), + eq(workspaceFiles.projectId, target.projectId), + eq(workspaceFiles.context, 'project') + ) + ) + .for('update') + .limit(1) + if (!file) throw new Error('Project document lifecycle target does not match its owner') + const cached = await loadCollabDocState(target.fileId, undefined, tx) + if (!cached) return null + const retiredDocId = documentIdentity(cached.docState) + const replacementDocId = generateId() + const replacement = new Y.Doc() + try { + replacement.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, replacementDocId) + await saveCollabDocStateInTx(tx, target.fileId, { + docState: Y.encodeStateAsUpdate(replacement), + sourceHash: '', + expectedState: cached, + }) + } finally { + replacement.destroy() + } + const payload: ProjectDocumentRetirement = { ...target, retiredDocId, replacementDocId } + return { + outboxEventId: await enqueueOutboxEvent(tx, PROJECT_FILE_DOCUMENT_RETIRE_EVENT, payload), + } +} export const projectFileDocumentOutboxHandlers = { [PROJECT_FILE_DOCUMENT_RETIRE_EVENT]: async (payload, context) => { @@ -18,9 +94,9 @@ export const projectFileDocumentOutboxHandlers = { ) throw new Error('Invalid Project document retirement') context.signal.throwIfAborted() - await retireLiveProjectFileDoc( + await retireLiveFileDoc( { - projectId: payload.projectId, + owner: { entityType: 'project', entityId: payload.projectId }, fileId: payload.fileId, retiredDocId: payload.retiredDocId, replacementDocId: payload.replacementDocId, @@ -29,3 +105,8 @@ export const projectFileDocumentOutboxHandlers = { ) }, } satisfies OutboxHandlerRegistry + +/** Attempt committed retirement immediately; the durable worker retries unavailable relays. */ +export function processProjectFileDocRetirementNow(eventId: string) { + return processOutboxEventById(eventId, projectFileDocumentOutboxHandlers) +} diff --git a/apps/sim/lib/projects/files/application/documents.ts b/apps/sim/lib/projects/files/application/documents.ts new file mode 100644 index 00000000000..9c434a31f64 --- /dev/null +++ b/apps/sim/lib/projects/files/application/documents.ts @@ -0,0 +1,253 @@ +import { db } from '@sim/db' +import { workspaceFiles } from '@sim/db/schema' +import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { and, eq, isNull } from 'drizzle-orm' +import * as Y from 'yjs' +import { yDocToFileMarkdown } from '@/lib/collab-doc/converter' +import { type PersistFileDocResult, preparePersistedState } from '@/lib/collab-doc/persist' +import { type FileDocSeed, prepareFileDocSeed } from '@/lib/collab-doc/seed' +import { + assertCollabDocStateSize, + CollabDocStateConflictError, + hashMarkdown, + loadCollabDocState, + type PreparedCollabDocState, + saveCollabDocStateInTx, +} from '@/lib/collab-doc/state' +import type { AuthorizingUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + createProjectFileAuthorizer, + type ProjectFileAuthorizationContext, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { + readProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import { readProjectFileDocIdInTx } from '@/lib/projects/files/application/document-lifecycle' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { isMarkdownFile } from '@/lib/uploads/utils/file' + +interface ProjectDocumentTarget { + projectId: string + fileId: string +} + +interface ProjectDocumentCacheInput extends ProjectDocumentTarget { + expectedVersion: number + state: PreparedCollabDocState +} + +interface ProjectDocumentPersistInput extends ProjectDocumentTarget { + docState: Uint8Array + expectedVersion?: number +} + +const MAX_SEED_BYTES = 5 * 1024 * 1024 +const MAX_ATTEMPTS = 3 + +function requireProjectDocument(context: ProjectFileAuthorizationContext) { + if (!context.file) throw new OrchestrationError('not_found', 'File not found') + if (!isMarkdownFile({ name: context.file.originalName, type: context.file.contentType })) { + throw new OrchestrationError('validation', 'Collaborative documents must be Markdown files') + } +} + +/** Current read membership and the separate write capability for a Project document subscriber. */ +export const getProjectFileDocAccess = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readContent, + ProjectDocumentTarget, + ProjectDocumentTarget & { canRead: true; canWrite: boolean; docId: string | null } +>({ + operation: projectFileOperations.readContent, + async execute({ context, input, tx }) { + requireProjectDocument(context) + return { + ...input, + canRead: true as const, + canWrite: context.canWrite, + docId: await readProjectFileDocIdInTx(tx, input.fileId), + } + }, +}) + +function cacheUseCase( + operation: typeof projectFileOperations.readContent | typeof projectFileOperations.updateContent +) { + return defineAuthorizedProjectFileUseCase< + typeof operation, + ProjectDocumentCacheInput, + { version: number } + >({ + operation, + async execute({ input, tx, context }) { + requireProjectDocument(context) + const [file] = await tx + .select({ version: workspaceFiles.contentUpdatedAt }) + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, input.fileId), + eq(workspaceFiles.projectId, input.projectId), + eq(workspaceFiles.context, 'project'), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('share') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + if (file.version.getTime() !== input.expectedVersion) + throw new OrchestrationError('conflict', 'File changed while preparing its document') + await saveCollabDocStateInTx(tx, input.fileId, input.state) + return { version: file.version.getTime() } + }, + }) +} + +const seedCache = cacheUseCase(projectFileOperations.readContent) +const persistCache = cacheUseCase(projectFileOperations.updateContent) + +function isDocumentConflict(error: unknown) { + return ( + error instanceof CollabDocStateConflictError || + (error instanceof OrchestrationError && error.code === 'conflict') + ) +} + +/** The seed cache is a read projection, fenced against both durable bytes and cached CRDT history. */ +export const buildProjectFileDocSeed: AuthorizingUseCase< + typeof projectFileOperations.readContent, + ProjectDocumentTarget & { signal?: AbortSignal }, + FileDocSeed +> = { + operation: projectFileOperations.readContent, + delegationAudience: projectFileOperations.readContent.delegationAudience, + authorize: async (args) => { + await getProjectFileDocAccess.execute(args) + }, + async execute(args) { + const timeout = AbortSignal.timeout(FILE_DOC_TIMEOUTS.seedRequestMs) + const signal = args.input.signal ? AbortSignal.any([args.input.signal, timeout]) : timeout + signal.throwIfAborted() + await getProjectFileDocAccess.execute(args) + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + signal.throwIfAborted() + try { + const read = await readProjectFileContent.execute({ + ...args, + input: { ...args.input, maxBytes: MAX_SEED_BYTES, signal }, + }) + signal.throwIfAborted() + if (!isMarkdownFile(read.file)) { + throw new OrchestrationError( + 'validation', + 'Collaborative documents must be Markdown files' + ) + } + const cached = await loadCollabDocState(args.input.fileId) + signal.throwIfAborted() + const update = prepareFileDocSeed(args.input.fileId, read.content, cached) + signal.throwIfAborted() + const accepted = await seedCache.execute({ + ...args, + input: { + ...args.input, + expectedVersion: read.file.contentUpdatedAt.getTime(), + state: { + docState: update, + sourceHash: hashMarkdown(read.content), + expectedState: cached + ? { sourceHash: cached.sourceHash, stateHash: cached.stateHash } + : null, + }, + }, + }) + signal.throwIfAborted() + return { update, version: accepted.version } + } catch (error) { + if (!isDocumentConflict(error)) throw error + } + } + throw new OrchestrationError('conflict', 'File changed while preparing its document') + }, +} + +/** Commit a snapshot through the same content, provenance, version and billing transaction as edits. */ +export const persistProjectFileDoc: AuthorizingUseCase< + typeof projectFileOperations.updateContent, + ProjectDocumentPersistInput, + PersistFileDocResult +> = { + operation: projectFileOperations.updateContent, + delegationAudience: projectFileOperations.updateContent.delegationAudience, + async authorize(args) { + const authorize = await createProjectFileAuthorizer( + args.principal, + projectFileOperations.updateContent, + args.input + ) + requireProjectDocument(await db.transaction(authorize)) + }, + async execute(args) { + await this.authorize(args) + if (args.input.expectedVersion === undefined) return { status: 'deferred' } + assertCollabDocStateSize(args.input.docState) + const doc = new Y.Doc() + let markdown: Buffer + try { + Y.applyUpdate(doc, args.input.docState) + markdown = Buffer.from(yDocToFileMarkdown(doc), 'utf-8') + } finally { + doc.destroy() + } + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + const read = await readProjectFileContent.execute(args) + const version = read.file.contentUpdatedAt.getTime() + if (!isMarkdownFile(read.file)) { + throw new OrchestrationError( + 'validation', + 'Collaborative documents must be Markdown files' + ) + } + const cached = await loadCollabDocState(args.input.fileId) + const sameContent = read.content.equals(markdown) + const prepared = preparePersistedState( + args.input.docState, + cached, + markdown, + version !== args.input.expectedVersion && !sameContent + ) + if (!prepared) return { status: 'conflict' } + if (sameContent) { + const accepted = await persistCache.execute({ + ...args, + input: { ...args.input, expectedVersion: version, state: prepared }, + }) + return { status: 'persisted', version: accepted.version } + } + if ( + version !== args.input.expectedVersion && + cached?.sourceHash !== hashMarkdown(read.content) + ) + return { status: 'conflict' } + const updated = await updateProjectFileContent.execute({ + ...args, + input: { + ...args.input, + content: markdown.toString('utf-8'), + encoding: 'utf-8', + expectedUpdatedAt: read.file.contentUpdatedAt, + provenanceMode: 'preserve', + collabDocState: prepared, + }, + }) + return { status: 'persisted', version: updated.file.contentUpdatedAt.getTime() } + } catch (error) { + if (!isDocumentConflict(error)) throw error + } + } + return { status: 'conflict' } + }, +} diff --git a/apps/sim/lib/projects/files/application/folders.ts b/apps/sim/lib/projects/files/application/folders.ts new file mode 100644 index 00000000000..91bda9df0cb --- /dev/null +++ b/apps/sim/lib/projects/files/application/folders.ts @@ -0,0 +1,109 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { DbTransaction } from '@/lib/db/types' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + createFileFolder, + listFileFolders, + updateFileFolder, +} from '@/lib/uploads/contexts/workspace' + +interface FolderUseCaseArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + tx: DbTransaction +} + +interface ListProjectFileFoldersInput extends ProjectFileTarget { + scope?: 'active' | 'archived' | 'all' +} + +interface CreateProjectFileFolderInput extends ProjectFileTarget { + name: string + parentId?: string | null +} + +interface UpdateProjectFileFolderInput extends ProjectFileTarget { + folderId: string + name?: string + parentId?: string | null + sortOrder?: number +} + +export const listProjectFileFolders = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.listFolders, + async execute({ input, context, tx }: FolderUseCaseArgs) { + return { + folders: await listFileFolders(context.owner, { scope: input.scope }, tx), + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } + }, +}) + +export const createProjectFileFolder = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.createFolder, + invalidatesFileList: true, + async execute({ + principal, + input, + context, + tx, + }: FolderUseCaseArgs) { + return { + folder: await createFileFolder( + { + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: input.name, + parentId: input.parentId, + }, + tx + ), + } + }, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created Project file folder "${result.folder.name}"`, + metadata: { projectId: input.projectId }, + } + }, +}) + +export const updateProjectFileFolder = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.updateFolder, + invalidatesFileList: true, + async execute({ input, context, tx }: FolderUseCaseArgs) { + return { + folder: await updateFileFolder( + { + owner: context.owner, + folderId: input.folderId, + name: input.name, + parentId: input.parentId, + sortOrder: input.sortOrder, + }, + tx + ), + } + }, + projectAudit({ input, result }) { + return { + action: input.parentId === undefined ? AuditAction.FOLDER_UPDATED : AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Updated Project file folder "${result.folder.name}"`, + metadata: { projectId: input.projectId }, + } + }, +}) diff --git a/apps/sim/lib/projects/files/application/index.ts b/apps/sim/lib/projects/files/application/index.ts new file mode 100644 index 00000000000..65435ae4f2f --- /dev/null +++ b/apps/sim/lib/projects/files/application/index.ts @@ -0,0 +1,35 @@ +export { getProjectFileListAccess } from './collection-access' +export { + createProjectFile, + downloadProjectFileContent, + updateProjectFileContent, +} from './content' +export { createProjectFileFolder, listProjectFileFolders, updateProjectFileFolder } from './folders' +export { + archiveProjectFileItems, + moveProjectFileItems, + renameProjectFile, + restoreProjectFile, + restoreProjectFileFolder, +} from './lifecycle' +export { projectFileOperations } from './operations' +export { + getProjectFileMetadata, + listProjectFileItems, + listProjectFiles, + resolveProjectFileReference, +} from './read' +export { + abortProjectFileUploadSession, + completeProjectFileUploadSession, + createProjectFileUploadSession, + getProjectFileUploadPartUrls, + getProjectFileUploadSession, +} from './uploads' +export { + deleteProjectFileVersion, + downloadProjectFileVersionContent, + listProjectFileVersions, + readProjectFileVersion, + revertProjectFileVersion, +} from './versions' diff --git a/apps/sim/lib/projects/files/application/lifecycle.ts b/apps/sim/lib/projects/files/application/lifecycle.ts new file mode 100644 index 00000000000..21ecc724423 --- /dev/null +++ b/apps/sim/lib/projects/files/application/lifecycle.ts @@ -0,0 +1,202 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceFileRow } from '@sim/db/schema' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { + processProjectFileDocRetirementNow, + rotateProjectFileDocInTx, +} from '@/lib/projects/files/application/document-lifecycle' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + archiveFileItems, + buildWorkspaceFileFolderPathMap, + listFileFolders, + mapFileRecord, + moveFileItems, + renameFileInTx, + restoreFileFolder, + restoreFileInTx, +} from '@/lib/uploads/contexts/workspace' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +interface LifecycleArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + tx: DbTransaction +} + +interface SelectionInput extends ProjectFileTarget { + fileIds?: string[] + folderIds?: string[] +} +interface MoveInput extends SelectionInput { + targetFolderId?: string | null + targetFolderPath?: string +} + +const retirementEffects = new WeakMap() + +async function retireDocuments( + tx: DbTransaction, + projectId: string, + fileIds: string[], + result: object +) { + const eventIds: string[] = [] + for (const fileId of [...new Set(fileIds)].sort()) { + const retired = await rotateProjectFileDocInTx(tx, { projectId, fileId }) + if (retired) eventIds.push(retired.outboxEventId) + } + retirementEffects.set(result, eventIds) +} + +async function finishRetirements(result: object) { + const eventIds = retirementEffects.get(result) ?? [] + retirementEffects.delete(result) + await Promise.all(eventIds.map(processProjectFileDocRetirementNow)) +} + +function boundedSelection(input: SelectionInput) { + const fileIds = [...new Set(input.fileIds ?? [])] + const folderIds = [...new Set(input.folderIds ?? [])] + if (fileIds.length + folderIds.length === 0) + throw new OrchestrationError('validation', 'At least one file or folder must be selected') + if ( + fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS || + folderIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS + ) + throw new OrchestrationError( + 'validation', + `Bulk file operations accept at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} file and folder IDs` + ) + return { fileIds, folderIds } +} + +async function projectFileRecord( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + file: WorkspaceFileRow +) { + const folders = file.folderId ? await listFileFolders(context.owner, { scope: 'all' }, tx) : [] + return mapFileRecord(file, context.owner, buildWorkspaceFileFolderPathMap(folders)) +} + +export const renameProjectFile = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.rename, + invalidatesFileList: ({ context, result }) => context.file?.originalName !== result.file.name, + async execute({ + input, + context, + tx, + }: LifecycleArgs) { + const file = await renameFileInTx(tx, context.owner, input.fileId, input.name) + return { file: await projectFileRecord(tx, context, file) } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Renamed Project file "${result.file.name}"`, + metadata: { projectId: context.projectId }, + }), +}) + +export const archiveProjectFileItems = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.archiveItems, + invalidatesFileList: ({ result }) => result.deletedItems.files + result.deletedItems.folders > 0, + async execute({ input, context, tx }: LifecycleArgs) { + const archived = await archiveFileItems( + { owner: context.owner, ...boundedSelection(input) }, + tx + ) + const result = { + deletedItems: { files: archived.files, folders: archived.folders }, + affectedIds: { fileIds: archived.fileIds, folderIds: archived.folderIds }, + } + await retireDocuments(tx, context.projectId, archived.fileIds, result) + return result + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + description: 'Archived Project file items', + metadata: { projectId: context.projectId, ...result.affectedIds }, + }), + afterSuccess: ({ result }) => finishRetirements(result), +}) + +export const moveProjectFileItems = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.moveItems, + invalidatesFileList: ({ result }) => result.movedFiles + result.movedFolders > 0, + async execute({ input, context, tx }: LifecycleArgs) { + return moveFileItems( + { + owner: context.owner, + ...boundedSelection(input), + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }, + tx + ) + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_MOVED, + resourceType: AuditResourceType.FILE, + description: 'Moved Project file items', + metadata: { + projectId: context.projectId, + fileIds: result.movedFileIds, + folderIds: result.movedFolderIds, + }, + }), +}) + +export const restoreProjectFile = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.restore, + invalidatesFileList: ({ context }) => Boolean(context.file?.deletedAt), + async execute({ input, context, tx }: LifecycleArgs) { + const restored = await restoreFileInTx(tx, context.owner, input.fileId) + const result = { restored: true as const, file: await projectFileRecord(tx, context, restored) } + if (context.file?.deletedAt) + await retireDocuments(tx, context.projectId, [input.fileId], result) + return result + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_RESTORED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Restored Project file "${result.file.name}"`, + metadata: { projectId: context.projectId }, + }), + afterSuccess: ({ result }) => finishRetirements(result), +}) + +export const restoreProjectFileFolder = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.restoreFolder, + invalidatesFileList: ({ result }) => + result.restoredItems.files + result.restoredItems.folders > 0, + async execute({ input, context, tx }: LifecycleArgs) { + const restored = await restoreFileFolder(context.owner, input.folderId, tx) + const result = { folder: restored.folder, restoredItems: restored.restoredItems } + await retireDocuments(tx, context.projectId, restored.restoredFileIds, result) + return result + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Restored Project file folder "${result.folder.name}"`, + metadata: { projectId: context.projectId, restoredItems: result.restoredItems }, + }), + afterSuccess: ({ result }) => finishRetirements(result), +}) diff --git a/apps/sim/lib/projects/files/application/operations.ts b/apps/sim/lib/projects/files/application/operations.ts new file mode 100644 index 00000000000..f3087085fac --- /dev/null +++ b/apps/sim/lib/projects/files/application/operations.ts @@ -0,0 +1,341 @@ +import type { Principal, ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { + type ApplicationOperation, + assertOperationCapability, + assertOperationOAuthPolicy, +} from '@/lib/core/application/operation' + +export type ProjectFilePrincipal = Extract< + Principal, + { kind: 'session' | 'personal_api_key' | 'oauth_access_token' | 'resource_delegated' } +> + +interface ProjectFilePolicy extends ApplicationOperation { + readonly access: 'read' | 'write' + readonly target: 'project' | 'file' | 'collection_observation' + readonly fileScope?: 'active' | 'all' + readonly principalKinds: readonly ProjectFilePrincipal['kind'][] + readonly delegatedServices: readonly ResourceDelegatedPrincipal['serviceId'][] + readonly delegationAudience: 'sim:project-files' | 'sim:file-list-observation' +} + +export const PROJECT_FILE_DELEGATION_TTL_MS = 60_000 + +function defineProjectFileOperation(operation: O): O { + assertOperationCapability(operation) + assertOperationOAuthPolicy(operation) + const observesCollection = operation.target === 'collection_observation' + if ( + observesCollection && + (operation.access !== 'read' || + operation.delegationAudience !== 'sim:file-list-observation' || + operation.principalKinds.length !== 1 || + operation.principalKinds[0] !== 'resource_delegated' || + operation.delegatedServices.length !== 1 || + operation.delegatedServices[0] !== 'realtime') + ) { + throw new Error('Collection observation requires an exclusive read-only realtime grant') + } + if ( + operation.target !== 'file' && + !observesCollection && + operation.delegatedServices.includes('realtime') + ) { + throw new Error('Realtime Project authority must target an existing file') + } + Object.freeze(operation.principalKinds) + Object.freeze(operation.delegatedServices) + return Object.freeze(operation) +} + +const PRINCIPALS = [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'resource_delegated', +] as const +const POLICY = { + principalKinds: PRINCIPALS, + delegationAudience: 'sim:project-files', + delegatedServices: ['copilot'], +} as const + +const HISTORY_POLICY = { + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + delegationAudience: 'sim:project-files', + delegatedServices: [], +} as const + +export const projectFileOperations = { + observeCollection: defineProjectFileOperation({ + id: 'project_files.observe_collection', + capability: 'files.use', + access: 'read', + target: 'collection_observation', + principalKinds: ['resource_delegated'], + delegatedServices: ['realtime'], + delegationAudience: 'sim:file-list-observation', + }), + extractArchive: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.extract_archive', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + downloadItems: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.download_items', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + exportSnapshot: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.export_snapshot', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + searchContent: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.search_content', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + readShare: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.share.read', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + updateShare: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.share.update', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + listVersions: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.list', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + readVersion: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.read', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + readVersionContent: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.read_content', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + revertVersion: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.revert', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + deleteVersion: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.delete', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + readInline: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.read_inline', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + uploadCreate: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.create', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + uploadRead: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.read', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + uploadParts: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.parts', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + uploadComplete: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.complete', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + uploadCancel: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.cancel', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + listFolders: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_folders.list', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + createFolder: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_folders.create', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + updateFolder: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_folders.update', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + list: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.list', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + resolveReference: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.resolve_reference', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + readMetadata: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.read_metadata', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + readArtifact: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.read_artifact', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + readContent: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.read_content', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + delegatedServices: ['copilot', 'realtime'], + }), + create: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.create', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + updateContent: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.update_content', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + delegatedServices: ['copilot', 'realtime'], + }), + rename: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.rename', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + archiveItems: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.archive_items', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + moveItems: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.move_items', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + restore: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.restore', + capability: 'files.use', + access: 'write', + target: 'file', + fileScope: 'all', + oauthScope: 'api:write', + }), + restoreFolder: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_folders.restore', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), +} as const + +export type ProjectFileOperation = + (typeof projectFileOperations)[keyof typeof projectFileOperations] diff --git a/apps/sim/lib/projects/files/application/read.ts b/apps/sim/lib/projects/files/application/read.ts new file mode 100644 index 00000000000..7702d991975 --- /dev/null +++ b/apps/sim/lib/projects/files/application/read.ts @@ -0,0 +1,160 @@ +import type { CursorKey } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { resolveFolderPathFilter } from '@/lib/folders/queries' +import { resolveFolderScope } from '@/lib/folders/subtree' +import type { ProjectFileTarget } from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + buildWorkspaceFileFolderPathMap, + listFileFolders, + loadActiveFileFolderPathIndex, + mapFileRecord, + type OwnedFileRecord, + queryFileRecords, + resolveFileReference, + workspaceFileVfsPath, +} from '@/lib/uploads/contexts/workspace' +import { resolveArchivedFileFolderScope } from '@/lib/uploads/contexts/workspace/file-folder-manager' +import { type FileBrowserQuery, queryFileBrowserItems } from '@/lib/workspace-files/browser-query' +import { fileOwnerVfsPath } from '@/lib/workspace-files/owner-paths' + +interface ListProjectFilesInput extends ProjectFileTarget { + scope?: 'active' | 'archived' + folderId?: string | null + folderPath?: string + recursive?: boolean + search?: string + sortBy: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder: 'asc' | 'desc' + limit: number + after?: CursorKey[] +} + +interface ListProjectFilesResult { + files: OwnedFileRecord<{ entityType: 'project'; entityId: string }>[] + nextKeys: CursorKey[] | null + capabilities: { canRead: true; canWrite: boolean } +} + +export const listProjectFiles = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.list, + ListProjectFilesInput, + ListProjectFilesResult +>({ + operation: projectFileOperations.list, + async execute({ input, context, tx }) { + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000) { + throw new OrchestrationError('validation', 'File page limit must be between 1 and 1000') + } + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Specify either folderPath or folderId, not both') + } + let folderId: string | null | string[] | undefined = input.folderId + if (input.folderPath !== undefined && input.scope === 'archived') { + folderId = await resolveArchivedFileFolderScope( + context.owner, + input.folderPath, + input.recursive, + MAX_FOLDERS_PER_WORKSPACE, + tx + ) + } else if (input.folderPath !== undefined) { + const index = await loadActiveFileFolderPathIndex(context.owner, tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const filter = resolveFolderPathFilter(index, input.folderPath) + if (filter.kind === 'noMatch') { + return { + files: [], + nextKeys: null, + capabilities: { canRead: true, canWrite: context.canWrite }, + } + } + folderId = resolveFolderScope(index, filter, input.recursive) + } + const result = await queryFileRecords(context.owner, { ...input, folderId }, tx) + return { + ...result, + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } + }, +}) + +export const getProjectFileMetadata = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readMetadata, + async execute({ context, tx }) { + const file = context.file + if (!file) throw new OrchestrationError('not_found', 'File not found') + const folders = file.folderId ? await listFileFolders(context.owner, { scope: 'all' }, tx) : [] + return { + file: mapFileRecord(file, context.owner, buildWorkspaceFileFolderPathMap(folders)), + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } + }, +}) + +/** Resolves metadata under collection authority before an adapter requests an exact file capability. */ +export const resolveProjectFileReference = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.resolveReference, + ProjectFileTarget & { fileReference: string }, + { + file: OwnedFileRecord<{ entityType: 'project'; entityId: string }> + vfsPath: string + capabilities: { canRead: true; canWrite: boolean } + } +>({ + operation: projectFileOperations.resolveReference, + async execute({ input, context, tx }) { + const file = await resolveFileReference(context.owner, input.fileReference, tx) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { + file, + vfsPath: fileOwnerVfsPath(context.owner, workspaceFileVfsPath(file)), + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } + }, +}) + +/** The browser pages folders and files together without changing the file-only API listing. */ +export const listProjectFileItems = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.list, + ProjectFileTarget & FileBrowserQuery, + Awaited> & ListProjectFilesResult +>({ + operation: projectFileOperations.list, + isolationLevel: 'repeatable read', + async execute({ input, context, tx }) { + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000) + throw new OrchestrationError('validation', 'File page limit must be between 1 and 1000') + const page = await queryFileBrowserItems(context.owner, input, tx) + const fileIds = page.items.filter((item) => item.kind === 'file').map((item) => item.id) + const files = fileIds.length + ? ( + await queryFileRecords( + context.owner, + { + scope: input.scope, + fileIds, + sortBy: 'name', + sortOrder: 'asc', + limit: fileIds.length, + }, + tx + ) + ).files + : [] + const filesById = new Map(files.map((file) => [file.id, file])) + const orderedFiles = fileIds.map((id) => { + const file = filesById.get(id) + if (!file) throw new Error('Browser file is missing from its snapshot') + return file + }) + return { + ...page, + files: orderedFiles, + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } + }, +}) diff --git a/apps/sim/lib/projects/files/application/uploads.ts b/apps/sim/lib/projects/files/application/uploads.ts new file mode 100644 index 00000000000..b3d41dab88a --- /dev/null +++ b/apps/sim/lib/projects/files/application/uploads.ts @@ -0,0 +1,385 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import { resolveProjectStorageBillingContext } from '@/lib/billing/storage/context' +import { checkStorageQuotaForBillingContext } from '@/lib/billing/storage/limits' +import type { AuthorizingUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { + OrchestrationError, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { + createProjectFileAuthorizer, + type ProjectFileAuthorizationContext, + type ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { + type ProjectFileOperation, + projectFileOperations, +} from '@/lib/projects/files/application/operations' +import { + finishProjectFileWrite, + mapProjectFileResult, + prepareProjectFileAccounting, + recordProjectFileWriteEffects, +} from '@/lib/projects/files/application/write-effects' +import { + projectUploadCleanupAvailableAt, + queueRetiredProjectUploadCleanup, +} from '@/lib/projects/files/prefix-cleanup' +import { resolveFileFolderTarget } from '@/lib/uploads/contexts/workspace/file-folder-manager' +import { + adoptVerifiedUploadSession, + commitFileCreateInTx, + discardStagedFileContent, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { requestOrigin } from '@/lib/uploads/upload-session/application' +import { assertProjectFileUploadBinding } from '@/lib/uploads/upload-session/project-file-binding' +import { readProjectFileUploadProvenance } from '@/lib/uploads/upload-session/project-file-provenance' +import { + abortUploadSession, + type CreatedUploadSession, + completeUploadSession, + createUploadPartUrls, + createUploadSession, + getOwnedUploadSession, + lockUploadSessionRegistrationInTx, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' +import type { WorkspaceFileUploadSource } from '@/lib/uploads/upload-session/workspace-file-provenance' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { + MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES, + restoreSimPageSourceBuffer, +} from '@/lib/workspace-files/page-source-embed' + +const logger = createLogger('ProjectFileUploads') + +export interface CreateProjectFileUploadInput extends ProjectFileTarget { + fileName: string + contentType: string + fileSize: number + folderId?: string | null + folderPath?: string + exactName?: boolean + localOrigin?: string + /** Host-owned classification; public upload contracts never accept this field. */ + secretProvenance?: WorkspaceFileUploadSource +} + +export interface ProjectFileUploadControlInput extends ProjectFileTarget { + uploadId: string + uploadToken: string + partNumbers?: number[] + localOrigin?: string + /** Trusted evidence for a pending streamed upload; never request-body claims. */ + secretProvenance?: WorkspaceFileSecretProvenance +} + +interface UploadArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + request?: OrchestrationRequestContext + tx: DbTransaction + prepared?: P +} + +async function loadRegisteredFile( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + fileId: string +) { + const [row] = await tx + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, fileId), + eq(workspaceFiles.projectId, context.projectId), + eq(workspaceFiles.context, 'project'), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('share') + .limit(1) + return row ? mapProjectFileResult(tx, context, row) : null +} + +export const createProjectFileUploadSession = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.uploadCreate, + async prepare({ + principal, + input, + context, + request, + }: Omit, 'tx'>) { + const folder = await resolveFileFolderTarget(context.owner, input) + const billing = await resolveProjectStorageBillingContext({ + projectId: context.projectId, + ownerId: context.ownerUserId, + organizationId: context.organizationId, + }) + const quota = await checkStorageQuotaForBillingContext(billing, input.fileSize) + if (!quota.allowed) + throw new OrchestrationError('payload_too_large', quota.error ?? 'Storage limit exceeded') + return createUploadSession({ + purpose: 'project_file', + projectId: context.projectId, + userId: requirePrincipalSubjectUserId(principal), + principal, + fileName: input.fileName, + contentType: input.contentType, + fileSize: input.fileSize, + localOrigin: input.localOrigin ?? (request ? requestOrigin(request) : undefined), + secretProvenance: input.secretProvenance, + metadata: { folderId: folder?.id ?? null, exactName: input.exactName === true }, + }) + }, + async execute({ + principal, + context, + tx, + prepared, + }: UploadArgs) { + if (!prepared) throw new Error('Upload session was not prepared') + assertProjectFileUploadBinding(prepared, principal, context.projectId) + await resolveFileFolderTarget(context.owner, { folderId: uploadFolderId(prepared) }, tx) + return prepared + }, + async onCommitFailure({ prepared }) { + try { + await abortUploadSession(prepared) + } catch (error) { + logger.warn('Upload cancellation will be retried by expiry cleanup', { + uploadId: prepared.id, + error, + }) + } finally { + await queueRetiredProjectUploadCleanup(prepared) + } + }, +}) + +function uploadFolderId(session: UploadSessionRecord) { + const id = session.metadata.folderId + if (id !== null && typeof id !== 'string') + throw new OrchestrationError('conflict', 'Upload folder binding is invalid') + return id +} + +async function loadControl( + principal: Principal, + operation: ProjectFileOperation, + input: ProjectFileUploadControlInput +) { + const authorize = await createProjectFileAuthorizer(principal, operation, input) + return db.transaction(async (tx) => { + const context = await authorize(tx) + const session = await getOwnedUploadSession({ + uploadId: input.uploadId, + uploadToken: input.uploadToken, + purpose: 'project_file', + principal, + executor: tx, + }) + assertProjectFileUploadBinding(session, principal, context.projectId) + const file = session.completedFileId + ? await loadRegisteredFile(tx, context, session.completedFileId) + : null + return { session, context, file } + }) +} + +function controlUseCase( + operation: O, + execute: (args: { + principal: Principal + input: ProjectFileUploadControlInput + loaded: Awaited> + request?: OrchestrationRequestContext + }) => Promise +): AuthorizingUseCase { + return { + operation, + delegationAudience: operation.delegationAudience, + async authorize({ principal, input }) { + await loadControl(principal, operation, input) + }, + async execute({ principal, input, request }) { + const loaded = await loadControl(principal, operation, input) + return runWithOutboundOrganization(loaded.context.organizationId, () => + execute({ principal, input, loaded, request }) + ) + }, + } +} + +export const getProjectFileUploadSession = controlUseCase( + projectFileOperations.uploadRead, + async ({ loaded }) => ({ session: loaded.session, file: loaded.file?.file ?? null }) +) + +export const getProjectFileUploadPartUrls = controlUseCase( + projectFileOperations.uploadParts, + async ({ principal, input, loaded, request }) => { + const localOrigin = input.localOrigin ?? (request ? requestOrigin(request) : undefined) + if (!localOrigin) + throw new OrchestrationError('validation', 'Upload part URLs require an origin') + const parts = await createUploadPartUrls({ + session: loaded.session, + partNumbers: input.partNumbers ?? [], + localOrigin, + }) + const current = await loadControl(principal, projectFileOperations.uploadParts, input) + if ( + current.session.status !== 'uploading' || + current.session.completedFileId || + current.session.expiresAt.getTime() <= Date.now() + ) + throw new OrchestrationError('conflict', 'Upload session no longer accepts bytes') + return { parts } + } +) + +export const abortProjectFileUploadSession = controlUseCase( + projectFileOperations.uploadCancel, + async ({ loaded }) => abortUploadSession(loaded.session) +) + +interface RegisterUploadInput extends ProjectFileTarget { + session: UploadSessionRecord +} +interface PreparedUploadContent { + staged: StagedFileContent + restored: boolean +} +const registerProjectUpload = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.uploadComplete, + invalidatesFileList: true, + async prepare({ + principal, + input, + context, + }: Omit, 'tx'>): Promise { + assertProjectFileUploadBinding(input.session, principal, context.projectId) + const adopted = adoptVerifiedUploadSession(input.session, context.owner) + if ( + adopted.name.toLowerCase().endsWith('.html') && + adopted.size > 0 && + adopted.size <= MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES + ) { + const bytes = await downloadFile({ key: adopted.key, context: 'project' }) + if (bytes.length !== adopted.size) + throw new OrchestrationError('conflict', 'Upload bytes changed before registration') + const restored = restoreSimPageSourceBuffer(adopted.name, bytes) + if (restored) + return { + staged: await stageFileContent({ + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: restored.name, + contentType: SIM_PAGE_CONTENT_TYPE, + content: restored.buffer, + }), + restored: true, + } + } + return { staged: adopted, restored: false } + }, + async execute({ + principal, + input, + context, + tx, + prepared, + }: UploadArgs) { + if (!prepared) throw new Error('Upload bytes were not prepared') + assertProjectFileUploadBinding(input.session, principal, context.projectId) + const { billing, mutation: accounting } = await prepareProjectFileAccounting(tx, context) + const registration = await lockUploadSessionRegistrationInTx(tx, input.session) + const file = await commitFileCreateInTx(tx, { + owner: context.owner, + staged: prepared.staged, + userId: requirePrincipalSubjectUserId(principal), + folderId: uploadFolderId(input.session), + exactName: input.session.metadata.exactName === true, + secretProvenance: readProjectFileUploadProvenance(input.session.metadata, context.projectId), + }) + const usage = await accounting.applyDelta(prepared.staged.size) + await registration.registerFile(file.id) + const cleanupIds = prepared.restored + ? await enqueueWorkspaceFileStorageCleanups(tx, [input.session.finalKey], 'project', { + availableAt: projectUploadCleanupAvailableAt(), + }) + : [] + const result = await mapProjectFileResult(tx, context, file) + recordProjectFileWriteEffects(result, { + billing, + usage, + delta: prepared.staged.size, + cleanupIds, + cleanupReason: 'restored editable page source', + }) + return result + }, + async onCommitFailure({ prepared }) { + if (prepared.restored) await discardStagedFileContent(prepared.staged) + }, + async afterSuccess({ result }) { + await finishProjectFileWrite(result) + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Uploaded Project file "${result.file.name}"`, + metadata: { + projectId: input.projectId, + fileSize: result.file.size, + fileType: result.file.type, + }, + }), +}) + +export const completeProjectFileUploadSession = controlUseCase( + projectFileOperations.uploadComplete, + async ({ principal, input, loaded, request }) => { + if (loaded.session.status === 'completed' && !loaded.session.completedFileId) + throw new OrchestrationError('conflict', 'Completed upload has no registered file') + try { + return await completeUploadSession({ + session: loaded.session, + secretProvenance: input.secretProvenance, + async loadCompleted() { + const current = await loadControl(principal, projectFileOperations.uploadComplete, input) + if (!current.file) throw new OrchestrationError('not_found', 'Uploaded file not found') + return current.file + }, + async finalize(session) { + const value = await registerProjectUpload.execute({ + principal, + input: { projectId: loaded.context.projectId, session }, + request, + }) + return { value, completedFileId: value.file.id } + }, + }) + } catch (error) { + await queueRetiredProjectUploadCleanup(loaded.session) + throw error + } + } +) diff --git a/apps/sim/lib/projects/files/application/versions.ts b/apps/sim/lib/projects/files/application/versions.ts new file mode 100644 index 00000000000..3d6516f27fb --- /dev/null +++ b/apps/sim/lib/projects/files/application/versions.ts @@ -0,0 +1,506 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' +import { requireAdmittedContent } from '@/lib/core/application/content-delivery' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import type { DbTransaction } from '@/lib/db/types' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { + type ProjectFileOperation, + projectFileOperations, +} from '@/lib/projects/files/application/operations' +import { + finishProjectFileWrite, + mapProjectFileResult, + prepareProjectFileAccounting, + recordProjectFileWriteEffects, +} from '@/lib/projects/files/application/write-effects' +import { + ContentVersionConflictError, + commitFileContentInTx, + discardStagedFileContent, + mapFileRecord, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/file-manager' +import { + snapshotWorkspaceFileSecretProvenanceInTx, + type WorkspaceFileSecretProvenanceSnapshot, + workspaceFileSecretProvenanceFromSnapshot, +} from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { + enqueueWorkspaceFileStorageCleanups, + processWorkspaceFileStorageCleanupsNow, +} from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox' +import { + deleteWorkspaceFileVersionInTx, + getCurrentWorkspaceFileVersion, + getWorkspaceFileVersion, + getWorkspaceFileVersionProvenance, + queryWorkspaceFileVersions, + type WorkspaceFileVersionRecord, +} from '@/lib/uploads/contexts/workspace/file-version-store' +import { downloadFile, downloadFileStream } from '@/lib/uploads/core/storage-service' +import { enqueueFileLiveDocReconciliation } from '@/lib/uploads/server/live-doc-outbox' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { isMarkdownFile } from '@/lib/uploads/utils/file' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write' +import { projectFileVersionAuthors } from '@/lib/workspace-files/application/version-authors' +import { + assertFileVersionRevision, + readFileVersionObject, +} from '@/lib/workspace-files/application/version-content' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +interface VersionTarget extends ProjectFileTarget { + fileId: string + version: number +} +interface RevertInput extends VersionTarget { + expectedCurrentVersion?: number + expectedRevision?: string +} +interface VersionArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + tx: DbTransaction + prepared?: P +} +interface VersionSnapshot { + organizationId: string | null + file: WorkspaceFileRow + current: WorkspaceFileVersionRecord + version: WorkspaceFileVersionRecord + provenance: WorkspaceFileSecretProvenanceSnapshot +} +interface PreparedVersionRead { + snapshot: VersionSnapshot + content: Buffer +} +interface PreparedRevert { + snapshot: VersionSnapshot + staged?: StagedFileContent +} + +function requireFile(context: ProjectFileAuthorizationContext) { + if (!context.file) throw new OrchestrationError('not_found', 'File not found') + return context.file +} + +async function projectVersionAuthor(tx: DbTransaction, version: WorkspaceFileVersionRecord) { + const [result] = await projectFileVersionAuthors([version], tx) + return result +} + +async function lockFile(tx: DbTransaction, context: ProjectFileAuthorizationContext) { + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + eq(workspaceFiles.id, requireFile(context).id), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('update') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { ...context, file } +} + +function versionNumber(version: number) { + if (!Number.isSafeInteger(version) || version < 1) + throw new OrchestrationError('validation', 'Invalid file version') +} + +async function loadVersion( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + version: number +) { + versionNumber(version) + const file = mapFileRecord(requireFile(context), context.owner, new Map()) + const target = await getWorkspaceFileVersion(file, version, tx) + if (!target) throw new OrchestrationError('not_found', `Version ${version} not found`) + return target +} + +async function captureVersion( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + input: RevertInput +): Promise { + const file = requireFile(context) + const mapped = mapFileRecord(file, context.owner, new Map()) + const current = await getCurrentWorkspaceFileVersion(mapped, tx) + if ( + input.expectedCurrentVersion !== undefined && + current.version !== input.expectedCurrentVersion + ) + throw new OrchestrationError('conflict', 'The current file version changed') + assertFileVersionRevision( + file, + input.expectedRevision, + 'The file changed since the revision you read' + ) + const version = await loadVersion(tx, context, input.version) + const provenance = version.isCurrent + ? await snapshotWorkspaceFileSecretProvenanceInTx( + tx, + file.id, + file.contentUpdatedAt, + file.secretProvenanceVersion + ) + : await getWorkspaceFileVersionProvenance(file.id, version.version, version.key, tx) + if (!provenance) throw new OrchestrationError('not_found', `Version ${version.version} not found`) + return { + organizationId: context.organizationId, + file, + current, + version, + provenance: provenance.status === null ? { status: 'unknown', entries: [] } : provenance, + } +} + +function snapshotUseCase(operation: O) { + return defineAuthorizedProjectFileUseCase({ + operation, + execute: ({ tx, context, input }) => captureVersion(tx, context, input), + }) +} + +const snapshotRead = snapshotUseCase(projectFileOperations.readVersionContent) +const snapshotRevert = snapshotUseCase(projectFileOperations.revertVersion) + +async function readVersionBytes( + version: WorkspaceFileVersionRecord, + maxBytes = MAX_BUFFERED_TRANSFER_BYTES +) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_BUFFERED_TRANSFER_BYTES) + throw new OrchestrationError('validation', 'Invalid file byte limit') + return readFileVersionObject(version.version, () => + downloadFile({ key: version.key, context: 'project', maxBytes }) + ) +} + +function requireVersionMatch( + current: VersionSnapshot, + expected: VersionSnapshot, + requireHead: boolean +) { + if ( + current.version.key !== expected.version.key || + current.version.updatedAt.getTime() !== expected.version.updatedAt.getTime() || + (requireHead && + (current.file.key !== expected.file.key || + current.file.contentUpdatedAt.getTime() !== expected.file.contentUpdatedAt.getTime())) + ) + throw new OrchestrationError( + 'conflict', + 'The file or selected version changed during the operation' + ) +} + +export const listProjectFileVersions = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.listVersions, + async execute({ + input, + context, + tx, + }: VersionArgs< + ProjectFileTarget & { + fileId: string + sortOrder: ListSortOrder + limit: number + after?: CursorKey[] + } + >) { + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000) + throw new OrchestrationError('validation', 'Invalid version page size') + const page = await queryWorkspaceFileVersions( + mapFileRecord(requireFile(context), context.owner, new Map()), + input, + tx + ) + return { + ...page, + ...workspaceFileRevisionField(requireFile(context)), + versions: await projectFileVersionAuthors(page.versions, tx), + } + }, +}) + +export const readProjectFileVersion = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readVersion, + async execute({ input, context, tx }: VersionArgs) { + return { + version: await projectVersionAuthor(tx, await loadVersion(tx, context, input.version)), + } + }, +}) + +const readVersionContent = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readVersionContent, + async prepare({ + principal, + input, + }: Omit, 'tx'>): Promise { + const snapshot = await snapshotRead.execute({ principal, input }) + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: snapshot.file.id, + provenance: workspaceFileSecretProvenanceFromSnapshot(snapshot.provenance), + view: 'opaque', + }) + return { snapshot, content: await readVersionBytes(snapshot.version, input.maxBytes) } + }, + async execute({ + input, + context, + tx, + prepared, + }: VersionArgs) { + if (!prepared) throw new Error('Version read was not prepared') + const current = await captureVersion(tx, context, input) + requireVersionMatch(current, prepared.snapshot, false) + return { + ...(await mapProjectFileResult(tx, context, current.file)), + version: await projectVersionAuthor(tx, current.version), + content: prepared.content, + secretProvenance: workspaceFileSecretProvenanceFromSnapshot(current.provenance), + } + }, + afterSuccess: async ({ principal, result }) => { + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: result.file.id, + provenance: result.secretProvenance, + view: 'opaque', + }) + await reportWorkspaceFileDelivery(result.secretProvenance, result.file.id) + }, +}) + +/** HEAD validates retention and the selected version under the same operation without storage I/O. */ +export const readProjectFileVersionContent = { + ...readVersionContent, + async authorize(args: Parameters[0]) { + await snapshotRead.execute(args) + }, +} + +/** Streams retained source bytes while preserving version retention and delivery authorization. */ +export const downloadProjectFileVersionContent = { + operation: snapshotRead.operation, + delegationAudience: snapshotRead.delegationAudience, + authorize: readProjectFileVersionContent.authorize, + async execute(args: Parameters[0]) { + const snapshot = await snapshotRead.execute(args) + await requireAdmittedContent(args.principal, { + kind: 'workspace-file', + id: snapshot.file.id, + provenance: workspaceFileSecretProvenanceFromSnapshot(snapshot.provenance), + view: 'opaque', + }) + const stream = nodeReadableToWebStream( + await runWithOutboundOrganization(snapshot.organizationId, () => + readFileVersionObject(snapshot.version.version, () => + downloadFileStream({ key: snapshot.version.key, context: 'project' }) + ) + ) + ) + try { + const current = await snapshotRead.execute(args) + requireVersionMatch(current, snapshot, false) + await requireAdmittedContent(args.principal, { + kind: 'workspace-file', + id: current.file.id, + provenance: workspaceFileSecretProvenanceFromSnapshot(current.provenance), + view: 'opaque', + }) + await reportWorkspaceFileDelivery( + workspaceFileSecretProvenanceFromSnapshot(current.provenance), + current.file.id + ) + return { + file: mapFileRecord( + current.file, + { entityType: 'project', entityId: args.input.projectId }, + new Map() + ), + version: current.version, + stream, + contentLength: current.version.size, + } + } catch (error) { + await stream.cancel(error).catch(() => undefined) + throw error + } + }, +} + +export const revertProjectFileVersion = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.revertVersion, + invalidatesFileList: ({ result }) => result.reverted, + async prepare({ + principal, + input, + context, + }: Omit, 'tx'>): Promise { + const snapshot = await snapshotRevert.execute({ principal, input }) + if (snapshot.version.isCurrent) return { snapshot } + const content = await readVersionBytes(snapshot.version) + const staged = await stageFileContent({ + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: snapshot.file.originalName, + contentType: snapshot.version.contentType, + content, + }) + return { snapshot, staged } + }, + async execute({ + principal, + input, + context, + tx, + prepared, + }: VersionArgs) { + if (!prepared) throw new Error('Revert was not prepared') + const accounting = prepared.staged ? await prepareProjectFileAccounting(tx, context) : undefined + const canonical = await lockFile(tx, context) + const current = await captureVersion(tx, canonical, input) + requireVersionMatch(current, prepared.snapshot, true) + if (!prepared.staged || !accounting) { + return { + ...(await mapProjectFileResult(tx, canonical, current.file)), + version: await projectVersionAuthor(tx, current.version), + reverted: false, + revertedFrom: current.current.version, + } + } + let committed + try { + committed = await commitFileContentInTx(tx, { + owner: canonical.owner, + fileId: input.fileId, + staged: prepared.staged, + expectedUpdatedAt: prepared.snapshot.file.contentUpdatedAt, + version: resolveWorkspaceFileVersionWrite(principal, { + source: 'revert', + restoredFromVersion: current.version.version, + }), + secretProvenancePolicy: { mode: 'reinstate', snapshot: current.provenance }, + }) + } catch (error) { + if (error instanceof ContentVersionConflictError) + throw new OrchestrationError('conflict', error.message) + throw error + } + const liveDocEventId = + isMarkdownFile({ name: current.file.originalName, type: current.file.contentType }) || + isMarkdownFile({ name: committed.file.originalName, type: committed.file.contentType }) + ? await enqueueFileLiveDocReconciliation(tx, { + owner: canonical.owner, + fileId: input.fileId, + version: committed.file.contentUpdatedAt.getTime(), + }) + : undefined + const usage = await accounting.mutation.applyDelta(committed.sizeDiff) + const result = { + ...(await mapProjectFileResult(tx, canonical, committed.file)), + version: await projectVersionAuthor( + tx, + await getCurrentWorkspaceFileVersion( + mapFileRecord(committed.file, canonical.owner, new Map()), + tx + ) + ), + reverted: true, + revertedFrom: current.current.version, + } + recordProjectFileWriteEffects(result, { + billing: accounting.billing, + usage, + delta: committed.sizeDiff, + cleanupIds: committed.storageCleanupEventIds, + liveDocEventId, + }) + return result + }, + async onCommitFailure({ prepared }) { + if (prepared.staged) await discardStagedFileContent(prepared.staged) + }, + async afterSuccess({ result }) { + if (result.reverted) await finishProjectFileWrite(result) + }, + projectAudit: ({ input, result }) => + result.reverted + ? { + action: AuditAction.FILE_REVERTED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Reverted Project file "${result.file.name}"`, + metadata: { + projectId: input.projectId, + previousVersion: result.revertedFrom, + restoredVersion: input.version, + newVersion: result.version.version, + }, + } + : [], +}) + +const deletionEffects = new WeakMap() + +export const deleteProjectFileVersion = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.deleteVersion, + async execute({ input, context, tx }: VersionArgs) { + const canonical = await lockFile(tx, context) + const version = await loadVersion(tx, canonical, input.version) + if (version.isCurrent) + throw new OrchestrationError('conflict', 'The current version cannot be deleted') + const deletion = await deleteWorkspaceFileVersionInTx(tx, input.fileId, input.version) + if (deletion.status === 'not_found') + throw new OrchestrationError('not_found', 'Version not found') + if (deletion.status === 'newest') + throw new OrchestrationError('conflict', 'The newest version cannot be deleted') + const events = await enqueueWorkspaceFileStorageCleanups(tx, [deletion.key], 'project') + const result = { + ...(await mapProjectFileResult(tx, canonical, canonical.file)), + version: version.version, + } + deletionEffects.set(result, events) + return result + }, + async afterSuccess({ result, input }) { + const events = deletionEffects.get(result) ?? [] + deletionEffects.delete(result) + await processWorkspaceFileStorageCleanupsNow(events, { + projectId: input.projectId, + fileId: input.fileId, + reason: 'deleted version', + }) + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.FILE_VERSION_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Deleted version ${result.version} of Project file "${result.file.name}"`, + metadata: { projectId: input.projectId, version: result.version }, + }), +}) diff --git a/apps/sim/lib/projects/files/application/write-effects.ts b/apps/sim/lib/projects/files/application/write-effects.ts new file mode 100644 index 00000000000..f06b063a224 --- /dev/null +++ b/apps/sim/lib/projects/files/application/write-effects.ts @@ -0,0 +1,64 @@ +import type { WorkspaceFileRow } from '@sim/db/schema' +import { prepareFileAccountingInTx } from '@/lib/billing/storage/accounting' +import type { ProjectStorageBillingContext } from '@/lib/billing/storage/context' +import { maybeNotifyStorageLimitForBillingContext } from '@/lib/billing/storage/tracking' +import type { DbTransaction } from '@/lib/db/types' +import type { ProjectFileAuthorizationContext } from '@/lib/projects/files/application/authorization' +import { + buildWorkspaceFileFolderPathMap, + listFileFolders, +} from '@/lib/uploads/contexts/workspace/file-folder-manager' +import { mapFileRecord } from '@/lib/uploads/contexts/workspace/file-manager' +import { finishFileContentEffects } from '@/lib/uploads/server/content-effects' + +interface CommittedWriteEffects { + billing: ProjectStorageBillingContext + usage: number + delta: number + cleanupIds: string[] + liveDocEventId?: string + cleanupReason?: string +} + +const committedWriteEffects = new WeakMap() + +/** Attaches private postcommit work to an authoritative result without adding wire fields. */ +export function recordProjectFileWriteEffects(result: object, effects: CommittedWriteEffects) { + committedWriteEffects.set(result, effects) +} + +/** Applies notifications and durable cleanup only after the metadata transaction commits. */ +export async function finishProjectFileWrite(result: object) { + const effects = committedWriteEffects.get(result) + if (!effects) throw new Error('Committed file effects are unavailable') + committedWriteEffects.delete(result) + await maybeNotifyStorageLimitForBillingContext(effects.billing, effects.usage, effects.delta < 0) + await finishFileContentEffects(effects, { + projectId: effects.billing.projectId, + reason: effects.cleanupReason ?? 'released version', + }) +} + +/** Projects owner-aware metadata using the caller's authorized transaction. */ +export async function mapProjectFileResult( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + file: WorkspaceFileRow +) { + const folders = file.folderId ? await listFileFolders(context.owner, { scope: 'all' }, tx) : [] + return { + file: { + ...mapFileRecord(file, context.owner, buildWorkspaceFileFolderPathMap(folders)), + contentUpdatedAt: file.contentUpdatedAt, + }, + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } +} + +/** Locks the canonical Project payer before content, directory, and history mutation locks. */ +export async function prepareProjectFileAccounting( + tx: DbTransaction, + context: ProjectFileAuthorizationContext +) { + return prepareFileAccountingInTx(tx, { entityType: 'project', entityId: context.projectId }) +} diff --git a/apps/sim/lib/projects/files/prefix-cleanup.ts b/apps/sim/lib/projects/files/prefix-cleanup.ts index 13786cb2eae..65b62c6b8d2 100644 --- a/apps/sim/lib/projects/files/prefix-cleanup.ts +++ b/apps/sim/lib/projects/files/prefix-cleanup.ts @@ -44,7 +44,7 @@ interface CleanupPayload { } /** Expired signatures stop new transfers, but a transfer already in progress may finish later. */ -function projectUploadCleanupAvailableAt(now = new Date()): Date { +export function projectUploadCleanupAvailableAt(now = new Date()): Date { return new Date(now.getTime() + UPLOAD_URL_TTL_MS + CLOCK_SKEW_MS) } diff --git a/apps/sim/lib/realtime/notify.test.ts b/apps/sim/lib/realtime/notify.test.ts index fe2a2855744..4b23540a708 100644 --- a/apps/sim/lib/realtime/notify.test.ts +++ b/apps/sim/lib/realtime/notify.test.ts @@ -1,10 +1,19 @@ import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock' import { resetUrlsMock, urlsMockFns } from '@sim/testing/mocks/urls.mock' import { afterAll, describe, expect, it, vi } from 'vitest' -import { applyEditToLiveFileDoc, invalidateLiveFileDoc } from '@/lib/realtime/notify' +import { + applyEditToLiveFileDoc, + invalidateLiveFileDoc, + retireLiveFileDoc, +} from '@/lib/realtime/notify' urlsMockFns.mockGetSocketServerUrl.mockReturnValue('http://realtime') setEnv({ INTERNAL_API_SECRET: 'secret' }) +const target = { + fileId: 'file-1', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, +} as const + afterAll(() => { resetUrlsMock() resetEnvMock() @@ -13,7 +22,7 @@ afterAll(() => { describe('applyEditToLiveFileDoc', () => { it('throws when the realtime call fails so the outbox can retry', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket pod down'))) - await expect(applyEditToLiveFileDoc('file-1', '# hello', { version: 42 })).rejects.toThrow( + await expect(applyEditToLiveFileDoc(target, '# hello', { version: 42 })).rejects.toThrow( 'socket pod down' ) }) @@ -25,7 +34,7 @@ describe('applyEditToLiveFileDoc', () => { vi.fn().mockResolvedValue(new Response(new ReadableStream({ cancel }), { status: 503 })) ) - await expect(applyEditToLiveFileDoc('file-1', '# hello', { version: 42 })).rejects.toThrow( + await expect(applyEditToLiveFileDoc(target, '# hello', { version: 42 })).rejects.toThrow( 'status 503' ) expect(cancel).toHaveBeenCalledOnce() @@ -40,7 +49,7 @@ describe('invalidateLiveFileDoc', () => { vi.fn().mockResolvedValue(new Response(new ReadableStream({ cancel }), { status })) ) - const result = invalidateLiveFileDoc('file-1', 42) + const result = invalidateLiveFileDoc(target, 42) if (status === 200) { await expect(result).resolves.toBeUndefined() } else { @@ -56,7 +65,7 @@ describe('invalidateLiveFileDoc', () => { vi.fn().mockResolvedValue(new Response(new ReadableStream({ cancel }), { status: 503 })) ) - await expect(invalidateLiveFileDoc('file-1', 42)).rejects.toThrow('status 503') + await expect(invalidateLiveFileDoc(target, 42)).rejects.toThrow('status 503') expect(cancel).toHaveBeenCalledOnce() }) @@ -64,18 +73,55 @@ describe('invalidateLiveFileDoc', () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) - await invalidateLiveFileDoc('file-1', 42) + await invalidateLiveFileDoc(target, 42) expect(fetchMock).toHaveBeenCalledWith( 'http://realtime/api/file-doc/invalidate', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'x-api-key': 'secret' }), - body: JSON.stringify({ fileId: 'file-1', version: 42 }), + body: JSON.stringify({ fileId: target.fileId, version: 42 }), }) ) fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }) - await expect(invalidateLiveFileDoc('file-1', 42)).rejects.toThrow('status 503') + await expect(invalidateLiveFileDoc(target, 42)).rejects.toThrow('status 503') }) }) + +describe('app and relay rollout compatibility', () => { + it.each(['workspace', 'project'] as const)( + 'encodes %s targets for every durable delivery', + async (entityType) => { + const fetchMock = vi + .fn() + .mockImplementation(async () => Response.json({ applied: true, status: 'applied' })) + vi.stubGlobal('fetch', fetchMock) + const owner = { entityType, entityId: 'owner-1' } + const target = { fileId: 'file-1', owner } + await applyEditToLiveFileDoc(target, '# saved', { version: 42 }) + await invalidateLiveFileDoc(target, 42) + await retireLiveFileDoc({ ...target, retiredDocId: 'old', replacementDocId: 'new' }) + const bodies = fetchMock.mock.calls.map(([, options]) => JSON.parse(options.body)) + expect(bodies).toEqual([ + { + fileId: 'file-1', + markdown: '# saved', + version: 42, + ...(entityType === 'project' ? { owner, projectId: 'owner-1' } : {}), + }, + { + fileId: 'file-1', + version: 42, + ...(entityType === 'project' ? { owner, projectId: 'owner-1' } : {}), + }, + { + fileId: 'file-1', + retiredDocId: 'old', + replacementDocId: 'new', + ...(entityType === 'project' ? { owner, projectId: 'owner-1' } : {}), + }, + ]) + } + ) +}) diff --git a/apps/sim/lib/realtime/notify.ts b/apps/sim/lib/realtime/notify.ts index b06ff437082..2e47d103f9e 100644 --- a/apps/sim/lib/realtime/notify.ts +++ b/apps/sim/lib/realtime/notify.ts @@ -1,9 +1,15 @@ import { createLogger } from '@sim/logger' import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { type FileDocTarget, fileDocOwnerWireFields } from '@sim/realtime-protocol/file-doc-target' import { getErrorMessage } from '@sim/utils/errors' import type { FolderResourceType } from '@/lib/api/contracts/folders' import { env } from '@/lib/core/config/env' import { getSocketServerUrl } from '@/lib/core/utils/urls' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' const logger = createLogger('RealtimeNotify') @@ -18,31 +24,34 @@ const NOTIFY_TIMEOUT_MS = 2000 const APPLY_EDIT_TIMEOUT_MS = FILE_DOC_TIMEOUTS.applyEditMs /** - * POST one workspace list-changed signal (`/api/workspace--changed`) to the realtime server, - * which fans it out to every socket in that workspace's live-list room so their browser refetches. + * POST one owner-scoped list-changed signal to the realtime server, which fans it out to + * every authorized socket in that owner's live-list room so its browser refetches. * Lossy — a dropped notification only degrades to stale-until-refetch. Never throws. Callers * `await` it (rather than fire-and-forget) so the fetch is guaranteed to dispatch before a Node * route handler returns — a floating promise can be dropped after the response is sent. It is a * normally-sub-millisecond local call, hard-bounded to {@link NOTIFY_TIMEOUT_MS}, so it adds that * latency only when the socket pod is unreachable. */ -async function postWorkspaceListChanged(endpoint: string, workspaceId: string): Promise { +async function postListChanged( + endpoint: string, + target: { workspaceId: string } | { projectId: string } +): Promise { try { const response = await fetch(`${getSocketServerUrl()}/api/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, - body: JSON.stringify({ workspaceId }), + body: JSON.stringify(target), signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), }) if (!response.ok) { logger.warn(`${endpoint} notify failed`, { - workspaceId, + ...target, status: response.status, }) } } catch (error) { logger.warn(`${endpoint} notify error`, { - workspaceId, + ...target, error: getErrorMessage(error), }) } @@ -50,20 +59,34 @@ async function postWorkspaceListChanged(endpoint: string, workspaceId: string): /** * Best-effort fan-out that a workspace's file tree changed, so every viewer of that workspace's - * files refetches. See {@link postWorkspaceListChanged} for the shared lossy/never-throws contract. + * files refetches. See {@link postListChanged} for the shared lossy/never-throws contract. */ export function notifyWorkspaceFilesChanged(workspaceId: string): Promise { - return postWorkspaceListChanged('workspace-files-changed', workspaceId) + return postListChanged('workspace-files-changed', { workspaceId }) +} + +const FILE_LIST_NOTIFIERS: FileOwnerAdapters<(id: string) => Promise> = { + workspace: notifyWorkspaceFilesChanged, + project: (projectId) => postListChanged('project-files-changed', { projectId }), +} + +/** Lossy owner-scoped invalidation, called after the canonical file mutation commits. */ +export async function notifyFileListChanged(owner: EditableFileOwner): Promise { + try { + await requireFileOwnerAdapter(FILE_LIST_NOTIFIERS, owner)(owner.entityId) + } catch (error) { + logger.warn('File collection notify failed', { owner, error: getErrorMessage(error) }) + } } /** * Best-effort fan-out that a workspace's table list changed (a table was created, renamed, moved, * deleted, or restored), so every viewer of that workspace's tables refetches. Fires from the * shared table service, so it covers every surface (HTTP routes AND copilot). See - * {@link postWorkspaceListChanged} for the shared lossy/never-throws contract. + * {@link postListChanged} for the shared lossy/never-throws contract. */ export function notifyWorkspaceTablesChanged(workspaceId: string): Promise { - return postWorkspaceListChanged('workspace-tables-changed', workspaceId) + return postListChanged('workspace-tables-changed', { workspaceId }) } /** @@ -73,10 +96,10 @@ export function notifyWorkspaceTablesChanged(workspaceId: string): Promise * per-workflow editor notifications ({@link notifyWorkflowUpdated}): those only reach sockets with * that workflow's canvas open, while this reaches everyone in the workspace. Fires from the * workflow application use cases, so it covers every surface (UI, CLI, copilot, API). See - * {@link postWorkspaceListChanged} for the shared lossy/never-throws contract. + * {@link postListChanged} for the shared lossy/never-throws contract. */ export function notifyWorkspaceWorkflowsChanged(workspaceId: string): Promise { - return postWorkspaceListChanged('workspace-workflows-changed', workspaceId) + return postListChanged('workspace-workflows-changed', { workspaceId }) } /** Best-effort fan-out that invalidates open editors for one durably changed workflow. */ @@ -187,17 +210,21 @@ interface LiveFileDocMergeResponse { * failures to callers that own a retry policy, such as the transactional outbox. */ export async function applyEditToLiveFileDoc( - fileId: string, + target: FileDocTarget, markdown: string, order: LiveFileDocMergeOrder = {}, - signal?: AbortSignal, - owner?: { entityType: 'project'; entityId: string } + signal?: AbortSignal ): Promise { const timeoutSignal = AbortSignal.timeout(APPLY_EDIT_TIMEOUT_MS) const response = await fetch(`${getSocketServerUrl()}/api/file-doc/apply-edit`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, - body: JSON.stringify({ fileId, markdown, version: order.version, ...(owner ? { owner } : {}) }), + body: JSON.stringify({ + fileId: target.fileId, + markdown, + version: order.version, + ...(target.owner.entityType === 'project' ? fileDocOwnerWireFields(target.owner) : {}), + }), signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }) if (!response.ok) { @@ -226,16 +253,19 @@ export async function applyEditToLiveFileDoc( * editor. Unlike list notifications this is durability-sensitive and throws so the outbox retries. */ export async function invalidateLiveFileDoc( - fileId: string, + target: FileDocTarget, version: number, - signal?: AbortSignal, - owner?: { entityType: 'project'; entityId: string } + signal?: AbortSignal ): Promise { const timeoutSignal = AbortSignal.timeout(APPLY_EDIT_TIMEOUT_MS) const response = await fetch(`${getSocketServerUrl()}/api/file-doc/invalidate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, - body: JSON.stringify({ fileId, version, ...(owner ? { owner } : {}) }), + body: JSON.stringify({ + fileId: target.fileId, + version, + ...(target.owner.entityType === 'project' ? fileDocOwnerWireFields(target.owner) : {}), + }), signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }) await response.body?.cancel().catch(() => {}) @@ -244,9 +274,9 @@ export async function invalidateLiveFileDoc( } } -/** Retire only a named Project document history, so delayed delivery cannot erase a restored file. */ -export async function retireLiveProjectFileDoc( - target: { projectId: string; fileId: string; retiredDocId: string; replacementDocId: string }, +/** Retire only a named document history, so delayed delivery cannot erase a restored file. */ +export async function retireLiveFileDoc( + target: FileDocTarget & { retiredDocId: string; replacementDocId: string }, signal?: AbortSignal ): Promise { const timeout = AbortSignal.timeout(APPLY_EDIT_TIMEOUT_MS) @@ -254,10 +284,14 @@ export async function retireLiveProjectFileDoc( const response = await fetch(`${getSocketServerUrl()}/api/file-doc/retire`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, - body: JSON.stringify(target), + body: JSON.stringify({ + fileId: target.fileId, + retiredDocId: target.retiredDocId, + replacementDocId: target.replacementDocId, + ...(target.owner.entityType === 'project' ? fileDocOwnerWireFields(target.owner) : {}), + }), signal: signal ? AbortSignal.any([signal, timeout]) : timeout, }) await response.body?.cancel().catch(() => {}) - if (!response.ok) - throw new Error(`Project document retirement failed with status ${response.status}`) + if (!response.ok) throw new Error(`Document retirement failed with status ${response.status}`) } diff --git a/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts index 691af34002b..3c7cb2b96f6 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts @@ -680,7 +680,7 @@ export async function createWorkspaceFileFolder( } /** Creates one folder under the caller's owner transaction and the shared hierarchy lock. */ -async function createFileFolder( +export async function createFileFolder( params: CreateFileFolderParams, tx: DbTransaction ): Promise> { @@ -924,7 +924,7 @@ export async function updateWorkspaceFileFolder( } /** Renames or moves one folder while preserving its canonical owner and subtree identity. */ -async function updateFileFolder( +export async function updateFileFolder( params: UpdateFileFolderParams, tx: DbTransaction ): Promise> { @@ -1032,7 +1032,7 @@ export async function moveWorkspaceFileItems(params: { ) } -async function moveFileItems( +export async function moveFileItems( params: { owner: EditableFileOwner fileIds?: string[] @@ -1269,7 +1269,7 @@ export async function restoreWorkspaceFileFolder( } /** Restores the folder's archive batch, keeping independently archived descendants unchanged. */ -async function restoreFileFolder( +export async function restoreFileFolder( owner: O, folderId: string, tx: DbTransaction @@ -1435,7 +1435,7 @@ export async function bulkArchiveWorkspaceFileItems(params: { ) } -async function archiveFileItems( +export async function archiveFileItems( params: { owner: EditableFileOwner fileIds?: string[] @@ -1533,7 +1533,7 @@ async function archiveFileItems( } /** Loads a complete canonical owner tree, refusing a partial index when a bound is supplied. */ -async function loadActiveFileFolderPathIndex( +export async function loadActiveFileFolderPathIndex( owner: EditableFileOwner, client: DbOrTx = db, options?: { maxRows?: number } @@ -1549,6 +1549,56 @@ async function loadActiveFileFolderPathIndex( return buildFolderPathIndex(rows) } +/** Archived files can belong to active folders or multiple historical folders with the same path. */ +export async function resolveArchivedFileFolderScope( + owner: EditableFileOwner, + path: string, + recursive: boolean | undefined, + maxRows: number, + client: DbOrTx +): Promise { + const segments = parseFolderPath(path) + if (segments.length === 0) return recursive ? undefined : null + let parents: (string | null)[] = [null] + for (const segment of segments) { + const rows = await client + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + fileFolderOwnerCondition(owner), + eq(folderTable.name, segment), + parents[0] === null + ? isNull(folderTable.parentId) + : inArray( + folderTable.parentId, + parents.filter((id): id is string => id !== null) + ) + ) + ) + .limit(maxRows + 1) + if (rows.length > maxRows) throw new FolderCollectionLimitExceededError('path index', maxRows) + parents = rows.map((row) => row.id) + if (parents.length === 0) return [] + } + const matchingIds = parents.filter((id): id is string => id !== null) + if (!recursive) return matchingIds + const found = new Set(matchingIds) + let frontier = matchingIds + while (frontier.length > 0) { + const rows = await client + .select({ id: folderTable.id }) + .from(folderTable) + .where(and(fileFolderOwnerCondition(owner), inArray(folderTable.parentId, frontier))) + .limit(maxRows - found.size + 1) + const children = rows.map((row) => row.id).filter((id) => !found.has(id)) + for (const id of children) found.add(id) + if (found.size > maxRows) throw new FolderCollectionLimitExceededError('cascade', maxRows) + frontier = children + } + return [...found] +} + export interface WorkspaceFileFolderPathMutation { folder: typeof folderTable.$inferSelect & { workspaceId: string; userId: string } path: string diff --git a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts index 43764393a6e..0ef7e5e5422 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts @@ -56,7 +56,8 @@ import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { DbOrTx, DbTransaction } from '@/lib/db/types' -import { parseFolderPath } from '@/lib/folders/paths' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { buildFolderPath, parseFolderPath } from '@/lib/folders/paths' import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' import type { FolderIdScope } from '@/lib/folders/scope' import { normalizeVfsSegment } from '@/lib/mothership/vfs/normalize-segment' @@ -65,7 +66,21 @@ import { lockWorkspaceProject } from '@/lib/projects/membership' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServePathPrefix } from '@/lib/uploads' import type { WorkspaceFileFolderRecord } from '@/lib/uploads/contexts/workspace/file-folder-manager' -import { enqueueWorkspaceFileLiveDocReconciliation } from '@/lib/uploads/contexts/workspace/file-live-doc-outbox' +import { + assertFileFolderTarget, + assertWorkspaceFileFolderTarget, + buildWorkspaceFileFolderPathMap, + fileNameExistsInWorkspaceFolder, + findWorkspaceFileFolderIdByPath, + getWorkspaceFileFolderPath, + listFileFolders, + listWorkspaceFileFolders, + loadActiveFileFolderPathIndex, + normalizeWorkspaceFileItemName, + resolveFileFolderTarget, + resolveWorkspaceFileFolderTarget, + workspaceFileNameFolderCondition, +} from '@/lib/uploads/contexts/workspace/file-folder-manager' import { applyWorkspaceFileSecretProvenancePolicyInTx, EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, @@ -98,7 +113,12 @@ import { uploadFile, } from '@/lib/uploads/core/storage-service' import { finishFileContentEffects } from '@/lib/uploads/server/content-effects' +import { enqueueFileLiveDocReconciliation } from '@/lib/uploads/server/live-doc-outbox' import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { + getVerifiedUploadSessionObject, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' import { isMarkdownFile } from '@/lib/uploads/utils/file' import type { ServableFile } from '@/lib/uploads/utils/file.server' import { @@ -133,20 +153,6 @@ import { import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { isUuid } from '@/executor/constants' import type { UserFile } from '@/executor/types' -import { - assertFileFolderTarget, - assertWorkspaceFileFolderTarget, - buildWorkspaceFileFolderPathMap, - fileNameExistsInWorkspaceFolder, - findWorkspaceFileFolderIdByPath, - getWorkspaceFileFolderPath, - listFileFolders, - listWorkspaceFileFolders, - normalizeWorkspaceFileItemName, - resolveFileFolderTarget, - resolveWorkspaceFileFolderTarget, - workspaceFileNameFolderCondition, -} from './file-folder-manager' const logger = createLogger('WorkspaceFileStorage') @@ -207,7 +213,7 @@ export interface VersionedWorkspaceFileRecord extends WorkspaceFileRecord { } /** Shared file metadata never substitutes an environment ID for its owning Project. */ -interface OwnedFileRecord +export interface OwnedFileRecord extends Omit { owner: O folderId: string | null @@ -481,7 +487,7 @@ export async function allocateUniqueWorkspaceFileName( return withCopySuffix(baseName, generateShortId(8)) } -interface StagedFileContent { +export interface StagedFileContent { readonly owner: Readonly readonly key: string readonly name: string @@ -491,8 +497,9 @@ interface StagedFileContent { } const stagedFileContents = new WeakSet() +const adoptedUploadContents = new WeakSet() -interface PlannedFileIdentity { +export interface PlannedFileIdentity { readonly id: string } @@ -505,8 +512,36 @@ function planFileIdentity(owner: EditableFileOwner): PlannedFileIdentity { return identity } +/** Adopts verified session bytes without making generic staging cleanup their owner. */ +export function adoptVerifiedUploadSession( + session: UploadSessionRecord, + owner: EditableFileOwner +): StagedFileContent { + const object = getVerifiedUploadSessionObject(session) + if ( + owner.entityType !== 'project' || + object.purpose !== 'project_file' || + object.projectId !== owner.entityId || + object.workspaceId !== null || + object.storageContext !== 'project' || + !object.finalKey.startsWith(`project/${owner.entityId}/`) + ) + throw new Error('Verified upload does not belong to this file owner') + const staged = Object.freeze({ + owner: Object.freeze({ ...owner }), + key: object.finalKey, + name: normalizeWorkspaceFileItemName(object.fileName, 'File'), + size: object.fileSize, + contentType: object.contentType, + contentHash: null, + }) + stagedFileContents.add(staged) + adoptedUploadContents.add(staged) + return staged +} + /** Writes private bytes before the owner-authorized metadata transaction begins. */ -async function stageFileContent(args: { +export async function stageFileContent(args: { owner: EditableFileOwner userId: string name: string @@ -572,8 +607,10 @@ function assertStagedFileOwner(owner: EditableFileOwner, staged: StagedFileConte } /** Discards definitely uncommitted bytes without replacing the operation's original failure. */ -async function discardStagedFileContent(staged: StagedFileContent): Promise { +export async function discardStagedFileContent(staged: StagedFileContent): Promise { assertStagedFileOwner(staged.owner, staged) + if (adoptedUploadContents.has(staged)) + throw new Error('Staged bytes are owned by their upload session') let events: string[] try { events = await enqueueWorkspaceFileStorageCleanups(db, [staged.key], staged.owner.entityType) @@ -630,7 +667,7 @@ async function finalizeStagedFileContent( } /** Commits new canonical metadata and provenance under the owner's directory lock. */ -async function commitFileCreateInTx( +export async function commitFileCreateInTx( tx: DbTransaction, args: { owner: EditableFileOwner @@ -1454,7 +1491,7 @@ function mapWorkspaceFileRecord( } /** Projects the same persisted file metadata for either supported editable owner. */ -function mapFileRecord( +export function mapFileRecord( file: WorkspaceFileListRow, owner: O, folderPaths: Map @@ -1823,7 +1860,7 @@ export async function queryWorkspaceFiles( } /** Authorized callers share one owner-filtered keyset query under their transaction. */ -async function queryFileRecords( +export async function queryFileRecords( owner: O, options: QueryWorkspaceFilesOptions, client: DbOrTx = db @@ -2006,6 +2043,78 @@ export function findWorkspaceFileRecord( return files.find((file) => normalizeVfsSegment(file.name) === segmentKey) ?? null } +/** Resolves an ID or scoped VFS path without widening to another owner or scanning file contents. */ +export async function resolveFileReference( + owner: O, + reference: string, + client: DbOrTx = db +): Promise | null> { + let localReference = reference.trim().replace(/^\/+/, '') + const namespace = owner.entityType === 'project' ? 'projects' : 'workspaces' + if (localReference.startsWith(`${namespace}/`)) { + const prefix = `${namespace}/${owner.entityId}/` + if (!localReference.startsWith(prefix)) { + throw new OrchestrationError('validation', 'File reference does not match its owner') + } + localReference = localReference.slice(prefix.length) + } + const isFileId = localReference.startsWith('wf_') || isUuid(localReference) + if (!isFileId && !localReference.startsWith('files/')) { + throw new OrchestrationError( + 'validation', + 'File reference must be an ID or an owner-scoped files path' + ) + } + let segments: string[] + try { + segments = decodeVfsPathSegments( + isFileId ? localReference : localReference.slice('files/'.length) + ) + } catch { + throw new OrchestrationError('validation', 'Invalid file reference path') + } + if (segments.length === 0) throw new OrchestrationError('validation', 'File reference is empty') + + const folders = await loadActiveFileFolderPathIndex(owner, client, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + let selector: SQL + if (isFileId) { + selector = eq(workspaceFiles.id, localReference) + } else { + const folderPath = buildFolderPath(segments.slice(0, -1)) + let folderId: string | null = null + if (folderPath !== '/') { + const matchingFolders = [...folders.pathById].filter( + ([, path]) => + parseFolderPath(path).map(normalizeVfsSegment).join('/') === + segments.slice(0, -1).map(normalizeVfsSegment).join('/') + ) + if (matchingFolders.length > 1) { + throw new OrchestrationError('conflict', 'File folder reference is ambiguous') + } + const match = matchingFolders[0] + if (!match) return null + folderId = match[0] + } + const name = segments.at(-1) ?? '' + selector = + and( + workspaceFileNameFolderCondition(folderId), + sql`regexp_replace(normalize(${workspaceFiles.originalName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') ~ ${displaySegmentPattern(name)}` + ) ?? sql`false` + } + const rows = await client + .select() + .from(workspaceFiles) + .where(and(fileOwnerCondition(owner), isNull(workspaceFiles.deletedAt), selector)) + .limit(2) + if (rows.length > 1) throw new OrchestrationError('conflict', 'File reference is ambiguous') + const row = rows[0] + if (!row) return null + return mapFileRecord(row, owner, buildWorkspaceFileFolderPathMap([...folders.rowById.values()])) +} + async function getWorkspaceFileByExactReference( workspaceId: string, segments: string[] @@ -2400,7 +2509,7 @@ export class ContentVersionConflictError extends Error { } } -interface CommitFileContentOptions { +export interface CommitFileContentOptions { commitOwner?: (tx: DbOrTx) => Promise owner: EditableFileOwner fileId: string @@ -2412,7 +2521,7 @@ interface CommitFileContentOptions { } /** Commits bytes, history and provenance after the caller has prepared the owner's accounting. */ -async function commitFileContentInTx(tx: DbTransaction, options: CommitFileContentOptions) { +export async function commitFileContentInTx(tx: DbTransaction, options: CommitFileContentOptions) { const { owner, fileId, staged } = options if (staged.contentHash === null) throw new Error('Content replacement requires staged bytes with a verified content hash') @@ -2619,7 +2728,7 @@ export async function updateWorkspaceFileContent( options.syncLiveDoc !== false && (isMarkdownFile({ type: currentFile.contentType, name: currentFile.originalName }) || isMarkdownFile({ type: updatedFile.contentType, name: updatedFile.originalName })) - ? await enqueueWorkspaceFileLiveDocReconciliation(tx, { + ? await enqueueFileLiveDocReconciliation(tx, { workspaceId, fileId, version: updatedFile.contentUpdatedAt.getTime(), @@ -2740,7 +2849,7 @@ export async function renameWorkspaceFile( } /** Renames under the owner's tree and file locks without changing the content identity. */ -async function renameFileInTx( +export async function renameFileInTx( tx: DbTransaction, owner: EditableFileOwner, fileId: string, @@ -2937,7 +3046,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): } /** Restores a retained head, re-rooting and deduplicating within its canonical owner. */ -async function restoreFileInTx( +export async function restoreFileInTx( tx: DbTransaction, owner: EditableFileOwner, fileId: string diff --git a/apps/sim/lib/uploads/contexts/workspace/file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/file-storage-accounting.test.ts index 7c98cfc5e5b..cbab555e33e 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/file-storage-accounting.test.ts @@ -58,9 +58,9 @@ vi.mock('@/lib/realtime/notify', () => realtimeNotifyMock) vi.mock('@/lib/projects/membership', () => projectMembershipMock) -vi.mock('@/lib/uploads/contexts/workspace/file-live-doc-outbox', () => ({ - enqueueWorkspaceFileLiveDocReconciliation: mockEnqueueWorkspaceFileLiveDocReconciliation, - processWorkspaceFileLiveDocReconciliationNow: mockProcessWorkspaceFileLiveDocReconciliationNow, +vi.mock('@/lib/uploads/server/live-doc-outbox', () => ({ + enqueueFileLiveDocReconciliation: mockEnqueueWorkspaceFileLiveDocReconciliation, + processFileLiveDocReconciliationNow: mockProcessWorkspaceFileLiveDocReconciliationNow, })) vi.mock('@/lib/billing/storage', () => billingStorageMock) diff --git a/apps/sim/lib/uploads/server/content-effects.test.ts b/apps/sim/lib/uploads/server/content-effects.test.ts index f64a8a8d49a..e51dd8b6efc 100644 --- a/apps/sim/lib/uploads/server/content-effects.test.ts +++ b/apps/sim/lib/uploads/server/content-effects.test.ts @@ -7,8 +7,8 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox', () => ({ processWorkspaceFileStorageCleanupsNow: mocks.cleanup, })) -vi.mock('@/lib/uploads/contexts/workspace/file-live-doc-outbox', () => ({ - processWorkspaceFileLiveDocReconciliationNow: mocks.reconcile, +vi.mock('@/lib/uploads/server/live-doc-outbox', () => ({ + processFileLiveDocReconciliationNow: mocks.reconcile, })) import { finishFileContentEffects } from '@/lib/uploads/server/content-effects' diff --git a/apps/sim/lib/uploads/server/content-effects.ts b/apps/sim/lib/uploads/server/content-effects.ts index 3d590af4d4d..776eb20d6ad 100644 --- a/apps/sim/lib/uploads/server/content-effects.ts +++ b/apps/sim/lib/uploads/server/content-effects.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { describeError } from '@sim/utils/errors' -import { processWorkspaceFileLiveDocReconciliationNow } from '@/lib/uploads/contexts/workspace/file-live-doc-outbox' import { processWorkspaceFileStorageCleanupsNow } from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox' +import { processFileLiveDocReconciliationNow } from '@/lib/uploads/server/live-doc-outbox' const logger = createLogger('FileContentEffects') @@ -14,7 +14,7 @@ export async function finishFileContentEffects( if (!effects.liveDocEventId) return const context = { ...logContext, eventId: effects.liveDocEventId } try { - const result = await processWorkspaceFileLiveDocReconciliationNow(effects.liveDocEventId) + const result = await processFileLiveDocReconciliationNow(effects.liveDocEventId) if (result === 'dead_letter' || result === 'not_found') { logger.error('Committed file live-document reconciliation requires intervention', { ...context, diff --git a/apps/sim/lib/uploads/server/delivery.ts b/apps/sim/lib/uploads/server/delivery.ts new file mode 100644 index 00000000000..7f38bad0231 --- /dev/null +++ b/apps/sim/lib/uploads/server/delivery.ts @@ -0,0 +1,142 @@ +import { ensureFileNameExtension } from '@/lib/uploads/utils/file' + +const SAFE_INLINE_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/x-icon', + 'application/pdf', + 'text/plain', + 'text/csv', + 'application/json', +]) + +const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml']) + +function getSecureFileHeaders(filename: string, originalContentType: string) { + const extension = filename.split('.').pop()?.toLowerCase() || '' + + if (FORCE_ATTACHMENT_EXTENSIONS.has(extension)) { + return { + contentType: 'application/octet-stream', + disposition: 'attachment', + } + } + + const mediaType = originalContentType.split(';', 1)[0].trim().toLowerCase() + const safeContentType = mediaType === 'text/html' ? 'text/plain' : originalContentType + const disposition = SAFE_INLINE_TYPES.has(mediaType === 'text/html' ? 'text/plain' : mediaType) + ? 'inline' + : 'attachment' + + return { + contentType: safeContentType, + disposition, + } +} + +/** + * Percent-encode a filename as an RFC 8187 `ext-value`. + * + * `encodeURIComponent` alone is not enough: it leaves `'`, `(`, `)` and `*` raw, and + * none of those are `attr-char`. The apostrophe is the specific hazard — it is the + * delimiter in `UTF-8''name`, so a filename like `it's.pdf` would emit a third `'` + * and desync the parser. + */ +function encodeExtValue(filename: string): string { + return encodeURIComponent(filename).replace( + /['()*]/g, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}` + ) +} + +/** + * Build the `filename` parameters for a Content-Disposition header. + * + * The name is attacker-controlled (it is the user's `originalName`), so it can never + * be interpolated raw: a `"` closes the quoted-string early and everything after it + * is parsed as further parameters. An injected `filename*` is the payload that + * matters, because RFC 6266 tells clients to prefer `filename*` over `filename` — + * so the attacker's value wins and the download lands under a name the product UI + * never showed. Both parameters are therefore always emitted from sanitized input: + * the quoted form keeps only printable ASCII minus `"` and `\`, and the `filename*` + * form is fully percent-encoded. + * + * `;` is neutralized too, even though a quoted string may legally contain one: the + * quoted parameter exists as the fallback for clients that do not implement + * `filename*`, and those are the same clients liable to split parameters on a bare + * `;` without honouring the quoting. The exact name still survives in `filename*`. + */ +export function encodeFilenameForHeader(storageKey: string): string { + const filename = storageKey.split('/').pop() || storageKey + const asciiSafe = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\;]/g, '_') + // Unchanged input proves the name is printable ASCII with no `"` or `\`, so the + // quoted form alone is both safe and sufficient — `filename*` buys nothing here. + if (asciiSafe === filename) { + return `filename="${filename}"` + } + return `filename="${asciiSafe}"; filename*=UTF-8''${encodeExtValue(filename)}` +} + +/** Explicit delivery policies preserve each surface's existing browser cache behavior. */ +export const FILE_CACHE_CONTROL = { + noStore: 'private, no-store', + revalidate: 'private, no-cache, must-revalidate', + private: 'private, no-cache', + immutable: 'private, max-age=31536000, immutable', + publicAsset: 'public, max-age=31536000', +} as const + +/** Constructs identical security and filename headers for buffered and streamed representations. */ +function fileDeliveryHeaders(input: { + filename: string + contentType: string + cacheControl: string + contentLength?: number + attachment?: boolean +}): Headers { + const filename = ensureFileNameExtension(input.filename, input.contentType) + const secure = getSecureFileHeaders(filename, input.contentType) + const headers = new Headers({ + 'Content-Type': secure.contentType, + 'Content-Disposition': `${input.attachment ? 'attachment' : secure.disposition}; ${encodeFilenameForHeader(filename)}`, + 'Cache-Control': input.cacheControl, + 'X-Content-Type-Options': 'nosniff', + }) + if (input.contentLength !== undefined) headers.set('Content-Length', String(input.contentLength)) + if (secure.contentType.split(';', 1)[0].trim().toLowerCase() === 'image/svg+xml') + headers.set( + 'Content-Security-Policy', + "default-src 'none'; style-src 'unsafe-inline'; sandbox;" + ) + return headers +} + +/** Binary presenters share header assembly without coupling storage mechanics to a route framework. */ +export function presentFileDelivery(input: { + body: Buffer | ReadableStream + filename: string + contentType: string + contentLength: number + cacheControl: string + attachment?: boolean +}) { + const headers = fileDeliveryHeaders(input) + return { + body: Buffer.isBuffer(input.body) + ? new Uint8Array( + input.body.buffer as ArrayBuffer, + input.body.byteOffset, + input.body.byteLength + ) + : input.body, + contentType: headers.get('Content-Type') ?? 'application/octet-stream', + contentLength: input.contentLength, + headers, + } +} diff --git a/apps/sim/lib/uploads/contexts/workspace/file-live-doc-outbox.test.ts b/apps/sim/lib/uploads/server/live-doc-outbox.test.ts similarity index 87% rename from apps/sim/lib/uploads/contexts/workspace/file-live-doc-outbox.test.ts rename to apps/sim/lib/uploads/server/live-doc-outbox.test.ts index 8da41ddf99c..9441aeae4b2 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-live-doc-outbox.test.ts +++ b/apps/sim/lib/uploads/server/live-doc-outbox.test.ts @@ -2,14 +2,14 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { realtimeNotifyMock, realtimeNotifyMockFns } from '@sim/testing/mocks/realtime-notify.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT } from '@/lib/uploads/contexts/workspace/file-outbox-events' vi.mock('@/lib/realtime/notify', () => realtimeNotifyMock) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) import type { OutboxEventContext } from '@/lib/core/outbox/service' -import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/file-live-doc-outbox' -import { WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT } from '@/lib/uploads/contexts/workspace/file-outbox-events' +import { fileLiveDocOutboxHandlers } from '@/lib/uploads/server/live-doc-outbox' const mockDownloadFile = storageServiceMockFns.mockDownloadFile @@ -35,7 +35,7 @@ function context(): OutboxEventContext { } function handler() { - const registered = workspaceFileLiveDocOutboxHandlers[WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT] + const registered = fileLiveDocOutboxHandlers[WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT] if (!registered) throw new Error('Workspace file live-document handler is not registered') return registered } @@ -64,7 +64,11 @@ describe('workspace file live-document outbox', () => { expect.objectContaining({ key: 'workspace/workspace-1/file.md', context: 'workspace' }) ) expect(mockApplyEditToLiveFileDoc).toHaveBeenCalledWith( - 'file-1', + { + fileId: 'file-1', + version: VERSION.getTime(), + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }, '# Durable content', { version: VERSION.getTime() }, expect.any(AbortSignal) @@ -125,7 +129,11 @@ describe('workspace file live-document outbox', () => { expect(mockDownloadFile).not.toHaveBeenCalled() expect(mockApplyEditToLiveFileDoc).not.toHaveBeenCalled() expect(mockInvalidateLiveFileDoc).toHaveBeenCalledWith( - 'file-1', + { + fileId: 'file-1', + version: VERSION.getTime(), + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }, VERSION.getTime(), expect.any(AbortSignal) ) @@ -147,7 +155,11 @@ describe('workspace file live-document outbox', () => { expect(mockDownloadFile).not.toHaveBeenCalled() expect(mockApplyEditToLiveFileDoc).not.toHaveBeenCalled() expect(mockInvalidateLiveFileDoc).toHaveBeenCalledWith( - 'file-1', + { + fileId: 'file-1', + version: VERSION.getTime(), + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }, VERSION.getTime(), expect.any(AbortSignal) ) @@ -170,7 +182,11 @@ describe('workspace file live-document outbox', () => { expect(mockDownloadFile).not.toHaveBeenCalled() expect(mockApplyEditToLiveFileDoc).not.toHaveBeenCalled() expect(mockInvalidateLiveFileDoc).toHaveBeenCalledWith( - 'file-1', + { + fileId: 'file-1', + version: VERSION.getTime(), + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }, latestVersion, expect.any(AbortSignal) ) diff --git a/apps/sim/lib/uploads/contexts/workspace/file-live-doc-outbox.ts b/apps/sim/lib/uploads/server/live-doc-outbox.ts similarity index 60% rename from apps/sim/lib/uploads/contexts/workspace/file-live-doc-outbox.ts rename to apps/sim/lib/uploads/server/live-doc-outbox.ts index c46dc2e3151..7f3da877298 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-live-doc-outbox.ts +++ b/apps/sim/lib/uploads/server/live-doc-outbox.ts @@ -1,5 +1,6 @@ import { db } from '@sim/db' import { workspaceFiles } from '@sim/db/schema' +import { parseFileDocTarget } from '@sim/realtime-protocol/file-doc-target' import { isRecordLike } from '@sim/utils/object' import { PASTE_LIMITS } from '@sim/utils/paste' import { and, eq, isNull } from 'drizzle-orm' @@ -14,16 +15,12 @@ import { applyEditToLiveFileDoc, invalidateLiveFileDoc } from '@/lib/realtime/no import { WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT } from '@/lib/uploads/contexts/workspace/file-outbox-events' import { downloadFile } from '@/lib/uploads/core/storage-service' import { isMarkdownFile } from '@/lib/uploads/utils/file' -import { - type FileOwnerAdapters, - requireFileOwnerAdapter, -} from '@/lib/workspace-files/owner-adapters' import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' -type WorkspaceFileLiveDocPayload = { fileId: string; version: number } & ( +type FileLiveDocPayload = { fileId: string; version: number } & ( | { workspaceId: string; owner?: never } - | { owner: { entityType: 'project'; entityId: string }; workspaceId?: never } + | { owner: EditableFileOwner; workspaceId?: never } ) interface ParsedLiveDocPayload { @@ -32,51 +29,17 @@ interface ParsedLiveDocPayload { owner: EditableFileOwner } -interface LiveDocDelivery { - invalidate(target: ParsedLiveDocPayload, version: number, signal: AbortSignal): Promise - merge( - target: ParsedLiveDocPayload, - markdown: string, - signal: AbortSignal - ): ReturnType -} - -const LIVE_DOC_DELIVERY: FileOwnerAdapters = { - workspace: { - invalidate: (target, version, signal) => invalidateLiveFileDoc(target.fileId, version, signal), - merge: (target, markdown, signal) => - applyEditToLiveFileDoc(target.fileId, markdown, { version: target.version }, signal), - }, - project: { - invalidate: (target, version, signal) => - invalidateLiveFileDoc(target.fileId, version, signal, { - entityType: 'project', - entityId: target.owner.entityId, - }), - merge: (target, markdown, signal) => - applyEditToLiveFileDoc(target.fileId, markdown, { version: target.version }, signal, { - entityType: 'project', - entityId: target.owner.entityId, - }), - }, -} - function parsePayload(payload: unknown): ParsedLiveDocPayload { if (!isRecordLike(payload)) { throw new Error('Workspace file live-document outbox payload must be an object') } - const candidate = payload as Partial + const candidate = payload as Partial let owner: EditableFileOwner if (candidate.owner !== undefined) { - if ( - candidate.workspaceId !== undefined || - !isRecordLike(candidate.owner) || - candidate.owner.entityType !== 'project' || - typeof candidate.owner.entityId !== 'string' || - !candidate.owner.entityId - ) - throw new Error('Invalid Project live-document owner') - owner = { entityType: 'project', entityId: candidate.owner.entityId } + const target = parseFileDocTarget(candidate) + if (candidate.workspaceId !== undefined || !target?.owner) + throw new Error('Invalid live-document owner') + owner = target.owner } else { if (typeof candidate.workspaceId !== 'string' || !candidate.workspaceId) throw new Error('Workspace file live-document outbox payload is missing workspaceId') @@ -95,7 +58,7 @@ function parsePayload(payload: unknown): ParsedLiveDocPayload { return { fileId: candidate.fileId, version: candidate.version, owner } } -const reconcileWorkspaceFileLiveDoc: OutboxHandler = async (rawPayload, context) => { +const reconcileFileLiveDoc: OutboxHandler = async (rawPayload, context) => { const payload = parsePayload(rawPayload) context.signal.throwIfAborted() const [file] = await db @@ -117,7 +80,6 @@ const reconcileWorkspaceFileLiveDoc: OutboxHandler = async (rawPayload, .limit(1) if (!file) return - const delivery = requireFileOwnerAdapter(LIVE_DOC_DELIVERY, payload.owner) const currentVersion = file.contentUpdatedAt.getTime() if (currentVersion < payload.version) { throw new Error('Workspace file live-document reconciliation is ahead of durable content') @@ -128,7 +90,7 @@ const reconcileWorkspaceFileLiveDoc: OutboxHandler = async (rawPayload, file.sizeBytes > PASTE_LIMITS.RICH_MARKDOWN_BYTES ) { /** Later binary writes do not enqueue reconciliation, so retire the latest unsupported version. */ - await delivery.invalidate(payload, currentVersion, context.signal) + await invalidateLiveFileDoc(payload, currentVersion, context.signal) return } if (currentVersion > payload.version) return @@ -140,25 +102,30 @@ const reconcileWorkspaceFileLiveDoc: OutboxHandler = async (rawPayload, signal: context.signal, }) context.signal.throwIfAborted() - const result = await delivery.merge(payload, content.toString('utf-8'), context.signal) + const result = await applyEditToLiveFileDoc( + payload, + content.toString('utf-8'), + { version: payload.version }, + context.signal + ) if (result.status === 'merge-unavailable') { return deferOutboxHandler('Live document merge slot is temporarily unavailable') } } -export const workspaceFileLiveDocOutboxHandlers = { - [WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT]: reconcileWorkspaceFileLiveDoc, +export const fileLiveDocOutboxHandlers = { + [WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT]: reconcileFileLiveDoc, } satisfies OutboxHandlerRegistry /** Enqueues live-document reconciliation in the same transaction as the durable file version. */ -export function enqueueWorkspaceFileLiveDocReconciliation( +export function enqueueFileLiveDocReconciliation( executor: Pick, - payload: WorkspaceFileLiveDocPayload + payload: FileLiveDocPayload ): Promise { return enqueueOutboxEvent(executor, WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT, payload) } /** Attempts a newly committed reconciliation immediately; the outbox worker owns retries. */ -export function processWorkspaceFileLiveDocReconciliationNow(eventId: string) { - return processOutboxEventById(eventId, workspaceFileLiveDocOutboxHandlers) +export function processFileLiveDocReconciliationNow(eventId: string) { + return processOutboxEventById(eventId, fileLiveDocOutboxHandlers) } diff --git a/apps/sim/lib/uploads/upload-session/project-file-binding.ts b/apps/sim/lib/uploads/upload-session/project-file-binding.ts new file mode 100644 index 00000000000..747015a1912 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/project-file-binding.ts @@ -0,0 +1,86 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { isRecordLike } from '@sim/utils/object' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PROJECT_FILE_DELEGATION_TTL_MS } from '@/lib/projects/files/application/operations' +import { PROJECT_FILE_UPLOAD_BINDING_KEY } from '@/lib/uploads/upload-session/types' + +function credential(principal: Principal, projectId: string) { + switch (principal.kind) { + case 'session': + return { kind: principal.kind, userId: principal.userId, sessionId: principal.sessionId } + case 'personal_api_key': + return { kind: principal.kind, userId: principal.userId, keyId: principal.keyId } + case 'oauth_access_token': + return { kind: principal.kind, userId: principal.userId, clientId: principal.clientId } + case 'resource_delegated': { + requireResourceDelegation(principal, { + audience: 'sim:project-files', + services: ['copilot'], + scope: { kind: 'entity', entityType: 'project', entityId: projectId }, + maxTtlMs: PROJECT_FILE_DELEGATION_TTL_MS, + }) + if (principal.serviceId !== 'copilot') break + return { + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + audience: principal.audience, + invocation: { ...principal.invocation }, + } + } + } + throw new OrchestrationError('forbidden', 'This principal cannot control Project file uploads') +} + +/** A receipt binds the actual credential and invocation; regenerated delegation IDs may retry. */ +export function createProjectFileUploadBinding(principal: Principal, projectId: string) { + return { + version: 1 as const, + entityType: 'project' as const, + entityId: projectId, + userId: requirePrincipalSubjectUserId(principal), + principal: credential(principal, projectId), + } +} + +interface ProjectUploadSession { + purpose: string + workspaceId: string | null + userId: string + metadata: Record +} + +/** Project receipts have no legacy uploader-only authorization fallback. */ +export function assertProjectFileUploadBinding( + session: ProjectUploadSession, + principal: Principal, + projectId?: string +): string { + const binding = session.metadata[PROJECT_FILE_UPLOAD_BINDING_KEY] + if ( + session.purpose !== 'project_file' || + session.workspaceId !== null || + !isRecordLike(binding) || + binding.version !== 1 || + binding.entityType !== 'project' || + typeof binding.entityId !== 'string' || + !binding.entityId || + binding.userId !== session.userId || + (projectId !== undefined && projectId !== binding.entityId) || + !isRecordLike(binding.principal) + ) + throw new OrchestrationError('not_found', 'Upload session not found') + const expected = createProjectFileUploadBinding(principal, binding.entityId) + const actual = binding.principal + const matches = + expected.userId === binding.userId && + Object.entries(expected.principal).every(([key, value]) => { + if (key !== 'invocation') return actual[key] === value + const invocation = actual.invocation + if (!isRecordLike(invocation) || !isRecordLike(value)) return false + return Object.entries(value).every(([field, entry]) => invocation[field] === entry) + }) + if (!matches) throw new OrchestrationError('not_found', 'Upload session not found') + return binding.entityId +} diff --git a/apps/sim/lib/uploads/upload-session/project-file-provenance.ts b/apps/sim/lib/uploads/upload-session/project-file-provenance.ts new file mode 100644 index 00000000000..8900265cf47 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/project-file-provenance.ts @@ -0,0 +1,40 @@ +import { isRecordLike } from '@sim/utils/object' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { + parseWorkspaceFileSecretProvenance, + type WorkspaceFileUploadSource, +} from '@/lib/uploads/upload-session/workspace-file-provenance' + +export const PROJECT_FILE_UPLOAD_PROVENANCE_KEY = 'projectFileSecretProvenance' + +/** Classification is bound to the shared owner and sealed once when completion claims its lease. */ +export function bindProjectFileUploadProvenance( + projectId: string, + source: WorkspaceFileUploadSource +) { + return { + version: 1, + projectId, + provenance: + source === 'pending' + ? { status: 'unknown' as const } + : parseWorkspaceFileSecretProvenance(source), + ...(source === 'pending' ? { pending: true } : {}), + } +} + +/** Malformed or missing Project classification remains unknown, never an implicit clean upload. */ +export function readProjectFileUploadProvenance( + metadata: Record, + projectId: string +): WorkspaceFileSecretProvenance { + const binding = metadata[PROJECT_FILE_UPLOAD_PROVENANCE_KEY] + if ( + !isRecordLike(binding) || + binding.version !== 1 || + binding.projectId !== projectId || + Object.hasOwn(binding, 'pending') + ) + return { status: 'unknown' } + return parseWorkspaceFileSecretProvenance(binding.provenance) +} diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index 8128a9b1586..cdc758c58a7 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -11,12 +11,14 @@ import { sha256Hex } from '@sim/security/hash' import { generateSecureToken } from '@sim/security/tokens' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { and, asc, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm' import { checkStorageQuotaForBillingContext, resolveStorageBillingContext, } from '@/lib/billing/storage' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { generateUniqueExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/file-manager' import { assertOrganizationAttachmentControlBinding } from '@/lib/uploads/contexts/organization-assistant/binding' @@ -35,6 +37,14 @@ import { type StorageContext, } from '@/lib/uploads/shared/types' import { maybeCleanupLocalUploadArtifacts } from '@/lib/uploads/upload-session/cleanup' +import { + assertProjectFileUploadBinding, + createProjectFileUploadBinding, +} from '@/lib/uploads/upload-session/project-file-binding' +import { + bindProjectFileUploadProvenance, + PROJECT_FILE_UPLOAD_PROVENANCE_KEY, +} from '@/lib/uploads/upload-session/project-file-provenance' import { abortProviderUpload, type CompletedUploadPart, @@ -48,11 +58,12 @@ import { type UploadPartUrl, uploadStorageProvider, } from '@/lib/uploads/upload-session/provider' -import type { - UploadSessionPurpose, - UploadSessionStatus, - UploadStorageProvider, - UploadTransferMethod, +import { + PROJECT_FILE_UPLOAD_BINDING_KEY, + type UploadSessionPurpose, + type UploadSessionStatus, + type UploadStorageProvider, + type UploadTransferMethod, } from '@/lib/uploads/upload-session/types' import { bindWorkspaceFileUploadProvenance, @@ -79,7 +90,6 @@ export const UPLOAD_SESSION_ASSET_MAX_BYTES = 5 * 1024 * 1024 const PROCESSING_LEASE_MS = 5 * 60 * 1000 const CLEANUP_BATCH_SIZE = 100 const TERMINAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 -const cleanupDb = dbFor('cleanup') export type { UploadSessionPurpose, UploadSessionStatus, UploadTransferMethod } @@ -201,6 +211,13 @@ interface CreateUploadSessionBaseParams { export type CreateUploadSessionParams = CreateUploadSessionBaseParams & ( + | { + purpose: 'project_file' + projectId: string + workspaceId?: never + principal: Principal + secretProvenance?: WorkspaceFileUploadSource + } | { purpose: 'workspace_file' workspaceId: string @@ -241,6 +258,101 @@ export type CreateUploadSessionParams = CreateUploadSessionBaseParams & type UploadSessionRow = typeof uploadSession.$inferSelect +function verifiedCompletionObject(session: UploadSessionRecord, leaseId: string) { + const binding = session.metadata[PROJECT_FILE_UPLOAD_BINDING_KEY] + return Object.freeze({ + leaseId, + metadata: JSON.stringify(session.metadata), + object: Object.freeze({ + id: session.id, + purpose: session.purpose, + workspaceId: session.workspaceId, + userId: session.userId, + finalKey: session.finalKey, + storageContext: session.storageContext, + storageProvider: session.storageProvider, + providerUploadId: session.providerUploadId, + providerObjectVersion: session.providerObjectVersion, + fileName: session.fileName, + contentType: session.contentType, + fileSize: session.fileSize, + }), + projectId: + isRecordLike(binding) && typeof binding.entityId === 'string' ? binding.entityId : null, + }) +} + +const verifiedUploadObjects = new WeakMap< + UploadSessionRecord, + ReturnType +>() + +/** Only the live finalizer receives proof of provider-verified bytes; serialization drops it. */ +export function getVerifiedUploadSessionObject(session: UploadSessionRecord) { + const proof = verifiedUploadObjects.get(session) + if (!proof) + throw new UploadSessionError('conflict', 'Upload object has no active verification proof') + return { ...proof.object, projectId: proof.projectId } +} + +/** Locks the exact completion lease before its metadata, billing, and receipt commit together. */ +export async function lockUploadSessionRegistrationInTx( + tx: DbTransaction, + session: UploadSessionRecord +) { + const proof = verifiedUploadObjects.get(session) + if (!proof) + throw new UploadSessionError('conflict', 'Upload object has no active verification proof') + const [row] = await tx + .select() + .from(uploadSession) + .where(eq(uploadSession.id, proof.object.id)) + .for('update') + if ( + !row || + row.status !== 'finalizing' || + row.completedFileId !== null || + row.processingLeaseId !== proof.leaseId || + !row.processingLeaseExpiresAt || + row.processingLeaseExpiresAt.getTime() <= Date.now() || + JSON.stringify(row.metadata) !== proof.metadata || + !Object.entries(proof.object).every( + ([key, value]) => row[key as keyof UploadSessionRow] === value + ) + ) + throw new UploadSessionError('conflict', 'Upload registration lease was lost') + let registered = false + return { + async registerFile(fileId: string) { + if (registered) throw new Error('Upload session registration was already recorded') + const rows = await tx + .update(uploadSession) + .set({ completedFileId: fileId, updatedAt: new Date() }) + .where( + and( + eq(uploadSession.id, row.id), + eq(uploadSession.status, 'finalizing'), + eq(uploadSession.processingLeaseId, proof.leaseId), + isNull(uploadSession.completedFileId) + ) + ) + .returning({ id: uploadSession.id }) + if (rows.length !== 1) + throw new UploadSessionError('conflict', 'Upload registration lease was lost') + registered = true + }, + } +} + +function projectCompletionProvenance( + session: UploadSessionRecord, + source?: WorkspaceFileSecretProvenance +) { + const binding = session.metadata[PROJECT_FILE_UPLOAD_BINDING_KEY] + if (!isRecordLike(binding) || typeof binding.entityId !== 'string') throw uploadNotFound() + return bindProjectFileUploadProvenance(binding.entityId, source ?? { status: 'unknown' }) +} + export async function createUploadSession( params: CreateUploadSessionParams ): Promise { @@ -249,6 +361,19 @@ export async function createUploadSession( const uploadToken = generateSecureToken(32) const workspaceId = params.purpose === 'profile_picture' ? null : (params.workspaceId ?? null) const metadata = { ...(params.metadata ?? {}) } + if (params.purpose === 'project_file') { + const binding = createProjectFileUploadBinding(params.principal, params.projectId) + if (binding.userId !== params.userId) + throw new UploadSessionError('forbidden', 'Project uploads require the actual uploading user') + metadata[PROJECT_FILE_UPLOAD_BINDING_KEY] = binding + metadata[PROJECT_FILE_UPLOAD_PROVENANCE_KEY] = bindProjectFileUploadProvenance( + params.projectId, + params.secretProvenance ?? + (params.principal.kind === 'resource_delegated' + ? { status: 'unknown' } + : { status: 'exact', entries: [] }) + ) + } if (params.purpose === 'organization_logo') { if (params.principal.kind !== 'session' || params.principal.userId !== params.userId) { throw new UploadSessionError('forbidden', 'Organization logos require the uploading session') @@ -429,8 +554,9 @@ export async function getOwnedUploadSession(params: { workflowId?: string executionId?: string principal?: Principal + executor?: Pick }): Promise { - const [row] = await db + const [row] = await (params.executor ?? db) .select() .from(uploadSession) .where(eq(uploadSession.id, params.uploadId)) @@ -501,6 +627,11 @@ export function createUploadSessionAuthBinding( options: { executorDelegationAudience?: string; copilotDelegationAudience?: string } = {} ): UploadSessionAuthBinding { switch (principal.kind) { + case 'resource_delegated': + throw new UploadSessionError( + 'forbidden', + 'Resource delegation cannot create workspace uploads' + ) case 'slack_app': case 'slack_installation': throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads') @@ -604,8 +735,10 @@ export function assertUploadSessionAuthBinding( session: UploadSessionRecord, principal: Principal ): void { - if (session.purpose === 'project_file') - throw new UploadSessionError('forbidden', 'Project upload control is unavailable') + if (session.purpose === 'project_file') { + assertProjectFileUploadBinding(session, principal) + return + } if (session.purpose === 'organization_logo') { assertOrganizationLogoControlBinding(session, principal) return @@ -764,12 +897,14 @@ export async function completeUploadSession(params: { recoveringFinalization ? ['finalizing'] : ['uploading', 'completing'], recoveringFinalization ? 'finalizing' : 'completing', db, - params.session.purpose === 'workspace_file' && params.session.workspaceId - ? bindWorkspaceFileUploadProvenance( - params.session.workspaceId, - params.secretProvenance ?? { status: 'unknown' } - ) - : undefined + params.session.purpose === 'project_file' + ? projectCompletionProvenance(params.session, params.secretProvenance) + : params.session.purpose === 'workspace_file' && params.session.workspaceId + ? bindWorkspaceFileUploadProvenance( + params.session.workspaceId, + params.secretProvenance ?? { status: 'unknown' } + ) + : undefined )), uploadToken: params.session.uploadToken, } @@ -848,7 +983,13 @@ export async function completeUploadSession(params: { requireRow(finalizingRow, 'Upload completion lease was lost'), claimed.uploadToken ) - const finalized = await params.finalize(finalizing) + verifiedUploadObjects.set(finalizing, verifiedCompletionObject(finalizing, leaseId)) + let finalized: Awaited> + try { + finalized = await params.finalize(finalizing) + } finally { + verifiedUploadObjects.delete(finalizing) + } const completed = await markUploadSessionCompleted( claimed, leaseId, @@ -973,6 +1114,7 @@ export async function cleanupExpiredUploadSessions(): Promise<{ failed: number purged: number }> { + const cleanupDb = dbFor('cleanup') const now = new Date() const candidates = await cleanupDb .select() @@ -1203,9 +1345,15 @@ async function claimSession( statuses: UploadSessionStatus[], nextStatus: UploadSessionStatus = 'completing', database: typeof db = db, - fileProvenance?: ReturnType + fileProvenance?: + | ReturnType + | ReturnType ): Promise { const now = new Date() + const provenanceKey = + fileProvenance && 'projectId' in fileProvenance + ? PROJECT_FILE_UPLOAD_PROVENANCE_KEY + : WORKSPACE_FILE_UPLOAD_PROVENANCE_KEY const [row] = await database .update(uploadSession) .set({ @@ -1217,8 +1365,8 @@ async function claimSession( /** Seal once with the completion lease; recovery must retain the first claim's evidence. */ ...(fileProvenance ? { - metadata: sql`CASE WHEN ${uploadSession.metadata}->${WORKSPACE_FILE_UPLOAD_PROVENANCE_KEY}->>'pending' = 'true' - THEN jsonb_set(${uploadSession.metadata}, ARRAY[${WORKSPACE_FILE_UPLOAD_PROVENANCE_KEY}]::text[], ${JSON.stringify(fileProvenance)}::jsonb) + metadata: sql`CASE WHEN ${uploadSession.metadata}->${provenanceKey}->>'pending' = 'true' + THEN jsonb_set(${uploadSession.metadata}, ARRAY[${provenanceKey}]::text[], ${JSON.stringify(fileProvenance)}::jsonb) ELSE ${uploadSession.metadata} END`, } : {}), @@ -1361,7 +1509,7 @@ function validateFile(params: CreateUploadSessionParams): void { if (!params.contentType.trim()) { throw new UploadSessionError('validation', 'contentType must not be empty') } - const minimum = params.purpose === 'workspace_file' ? 0 : 1 + const minimum = params.purpose === 'workspace_file' || params.purpose === 'project_file' ? 0 : 1 if (!Number.isSafeInteger(params.fileSize) || params.fileSize < minimum) { const range = minimum === 0 ? 'a non-negative integer' : 'a positive integer' throw new UploadSessionError('validation', `fileSize must be ${range}`) @@ -1395,12 +1543,15 @@ function validateFile(params: CreateUploadSessionParams): void { } if ( params.purpose !== 'profile_picture' && + params.purpose !== 'project_file' && params.purpose !== 'organization_logo' && !organizationAttachment && !params.workspaceId?.trim() ) { throw new UploadSessionError('validation', 'workspaceId must not be empty') } + if (params.purpose === 'project_file' && !params.projectId.trim()) + throw new UploadSessionError('validation', 'projectId must not be empty') if (params.purpose === 'knowledge_document' && !params.knowledgeBaseId.trim()) { throw new UploadSessionError('validation', 'knowledgeBaseId must not be empty') } @@ -1452,6 +1603,11 @@ function resolveUploadStorage( id: string ): { storageContext: StorageContext; finalKey: string } { switch (params.purpose) { + case 'project_file': + return { + storageContext: 'project', + finalKey: `project/${params.projectId}/${buildStorageKeySegment(`${id}-`, params.fileName)}`, + } case 'workspace_file': return { storageContext: 'workspace', diff --git a/apps/sim/lib/uploads/utils/file.ts b/apps/sim/lib/uploads/utils/file.ts index 00e8ad82e8c..43b15b67df9 100644 --- a/apps/sim/lib/uploads/utils/file.ts +++ b/apps/sim/lib/uploads/utils/file.ts @@ -289,7 +289,7 @@ export function buildArchiveExtractGuidance(name: string): string { return `"${name}" is a .zip archive — its contents can't be read directly. Mount it into the chat sandbox with run_code (inputs.files: [{"path": "uploads/${name}", "sandboxPath": "/tmp/${name}"}]) and unzip it there; persist anything worth keeping with \`files upload @\`.` } -const EXTENSION_TO_MIME: Record = { +export const EXTENSION_TO_MIME: Readonly> = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', diff --git a/apps/sim/lib/users/queries.ts b/apps/sim/lib/users/queries.ts index 969e9345b0f..73e5f7c3190 100644 --- a/apps/sim/lib/users/queries.ts +++ b/apps/sim/lib/users/queries.ts @@ -3,6 +3,7 @@ import { settings, user } from '@sim/db/schema' import { eq, inArray } from 'drizzle-orm' import type { UserSettingsApi } from '@/lib/api/contracts/user' import { normalizeStringArray } from '@/lib/core/utils/arrays' +import type { DbOrTx } from '@/lib/db/types' const MAX_USER_EMAIL_BATCH = 1000 @@ -116,14 +117,15 @@ export async function getUserEmailsByIds(userIds: readonly string[]): Promise> { const uniqueIds = Array.from(new Set(userIds)) if (uniqueIds.length === 0) return new Map() if (uniqueIds.length > MAX_USER_EMAIL_BATCH) { throw new Error(`Cannot resolve more than ${MAX_USER_EMAIL_BATCH} user emails at once`) } - const rows = await db + const rows = await executor .select({ id: user.id, email: user.email }) .from(user) .where(inArray(user.id, uniqueIds)) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index db2e8a70380..3a1fc488114 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -254,6 +254,7 @@ async function executeCopilotRun(params: { } }): Promise { if ( + params.principal.kind === 'resource_delegated' || params.principal.kind === 'organization_delegated' || params.principal.kind === 'slack_app' || params.principal.kind === 'slack_installation' || diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts index f4c93a1ad9a..8943f1051c3 100644 --- a/apps/sim/lib/workspace-files/api/index.ts +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -6,3 +6,4 @@ export { internalWorkspaceFileServeAuth, v2FileErrorPolicies, } from '@/lib/workspace-files/api/route-policies' +export { toFileVersion } from '@/lib/workspace-files/api/version-presenters' diff --git a/apps/sim/lib/workspace-files/api/version-presenters.ts b/apps/sim/lib/workspace-files/api/version-presenters.ts new file mode 100644 index 00000000000..316d97371e6 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/version-presenters.ts @@ -0,0 +1,19 @@ +import type { V2FileVersion } from '@/lib/api/contracts/v2/file-versions' +import type { AuthoredFileVersion } from '@/lib/workspace-files/application/version-authors' + +/** Serializes the version and author fields already projected by an authorized operation. */ +export function toFileVersion(version: AuthoredFileVersion): V2FileVersion { + return { + fileId: version.fileId, + version: version.version, + isCurrent: version.isCurrent, + size: version.size, + contentType: version.contentType, + source: version.source, + authors: version.authors, + restoredFromVersion: version.restoredFromVersion, + createdAt: version.createdAt.toISOString(), + updatedAt: version.updatedAt.toISOString(), + supersededAt: version.supersededAt?.toISOString() ?? null, + } +} diff --git a/apps/sim/lib/workspace-files/application/file-context.ts b/apps/sim/lib/workspace-files/application/file-context.ts index e9be9300b34..a9e46b5a492 100644 --- a/apps/sim/lib/workspace-files/application/file-context.ts +++ b/apps/sim/lib/workspace-files/application/file-context.ts @@ -8,6 +8,7 @@ import { type WorkspaceFileLifecycleContext, } from '@/lib/uploads/contexts/workspace/file-manager' import { ownedFileKind } from '@/lib/workspace-files/owned-files' +import { matchesFileOwner } from '@/lib/workspace-files/ownership' export interface WorkspaceFileContextInput { fileId: string @@ -56,20 +57,34 @@ export async function assertOwnedFileAccess( await kind.assertOwnerAccess(context.fileId, context.workspaceOrganizationId, executor) } -export async function resolveActiveWorkspaceFileContext( - input: WorkspaceFileContextInput -): Promise { - const canonical = await loadActiveWorkspaceFileContext(input.fileId, { - includeDeleted: input.includeDeleted, - includeChatUploads: input.includeChatUploads, - ...(input.ownedFilePrincipal ? { includeOwnedFiles: true } : {}), - }) +function requireWorkspaceFileContext( + canonical: C | null, + assertedWorkspaceId: string | undefined +): C { if ( !canonical || - (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + (assertedWorkspaceId !== undefined && + !matchesFileOwner( + { entityType: 'workspace', entityId: canonical.workspaceId }, + { entityType: 'workspace', entityId: assertedWorkspaceId } + )) ) { throw new OrchestrationError('not_found', 'File not found') } + return canonical +} + +export async function resolveActiveWorkspaceFileContext( + input: WorkspaceFileContextInput +): Promise { + const canonical = requireWorkspaceFileContext( + await loadActiveWorkspaceFileContext(input.fileId, { + includeDeleted: input.includeDeleted, + includeChatUploads: input.includeChatUploads, + includeOwnedFiles: Boolean(input.ownedFilePrincipal), + }), + input.assertedWorkspaceId + ) if (input.ownedFilePrincipal) await assertOwnedFileAccess(input.ownedFilePrincipal, canonical) return canonical } @@ -77,12 +92,8 @@ export async function resolveActiveWorkspaceFileContext( export async function resolveWorkspaceFileLifecycleContext( input: WorkspaceFileContextInput ): Promise { - const canonical = await loadWorkspaceFileLifecycleContext(input.fileId) - if ( - !canonical || - (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) - ) { - throw new OrchestrationError('not_found', 'File not found') - } - return canonical + return requireWorkspaceFileContext( + await loadWorkspaceFileLifecycleContext(input.fileId), + input.assertedWorkspaceId + ) } diff --git a/apps/sim/lib/workspace-files/application/file-delivery-observer.ts b/apps/sim/lib/workspace-files/application/file-delivery-observer.ts new file mode 100644 index 00000000000..89172cb32c8 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/file-delivery-observer.ts @@ -0,0 +1,30 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/file-secret-provenance' + +/** Single-file deliveries name the canonical file for attribution; aggregates have no single file ID. */ +type WorkspaceFileDeliveryObserver = ( + provenance: WorkspaceFileSecretProvenance | undefined, + fileId?: string +) => Promise + +const observer = new AsyncLocalStorage() +/** Internal transport evidence observes authorized bytes without changing public API admission or output. */ +export function observeWorkspaceFileDelivery( + observe: WorkspaceFileDeliveryObserver, + execute: () => T +): T { + return observer.run(observe, execute) +} +export function hasWorkspaceFileDeliveryObserver(): boolean { + return observer.getStore() !== undefined +} +/** + * Runs before the canonical use case returns bytes to its transport. An observer that refuses the + * file throws, so its bytes are never read. + */ +export async function reportWorkspaceFileDelivery( + provenance: WorkspaceFileSecretProvenance | undefined, + fileId?: string +): Promise { + await observer.getStore()?.(provenance, fileId) +} diff --git a/apps/sim/lib/workspace-files/application/file-version-write.ts b/apps/sim/lib/workspace-files/application/file-version-write.ts index a44bb4ce4b1..801afbea100 100644 --- a/apps/sim/lib/workspace-files/application/file-version-write.ts +++ b/apps/sim/lib/workspace-files/application/file-version-write.ts @@ -10,6 +10,7 @@ function versionSourceForPrincipal(principal: Principal): WorkspaceFileVersionSo case 'oauth_access_token': case 'workspace_api_key': return 'api' + case 'resource_delegated': case 'delegated': if (principal.serviceId === 'copilot') return 'copilot' if (principal.serviceId === 'executor') return 'workflow' diff --git a/apps/sim/lib/workspace-files/application/file-versions.ts b/apps/sim/lib/workspace-files/application/file-versions.ts index c90fb29f36c..247ff84cafa 100644 --- a/apps/sim/lib/workspace-files/application/file-versions.ts +++ b/apps/sim/lib/workspace-files/application/file-versions.ts @@ -27,7 +27,6 @@ import { queryWorkspaceFileVersions, type WorkspaceFileVersionRecord, } from '@/lib/uploads/contexts/workspace/file-version-store' -import { hasObjectNotFoundCause } from '@/lib/uploads/core/errors' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { formatFileSize } from '@/lib/uploads/utils/file' import { @@ -40,7 +39,7 @@ import { streamWorkspaceFileRecord, } from '@/lib/workspace-files/application/download-workspace-file' import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/file-context' -import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write' import { fileOperations } from '@/lib/workspace-files/application/operations' import { prepareOwnedFileContentWrite } from '@/lib/workspace-files/application/owned-file-content' @@ -49,6 +48,14 @@ import { type ReadWorkspaceFileTextInput, type ReadWorkspaceFileTextResult, } from '@/lib/workspace-files/application/read-workspace-file-text' +import { + type AuthoredFileVersion, + projectFileVersionAuthors, +} from '@/lib/workspace-files/application/version-authors' +import { + assertFileVersionRevision, + readFileVersionObject, +} from '@/lib/workspace-files/application/version-content' const logger = createLogger('WorkspaceFileVersions') @@ -68,7 +75,8 @@ export interface ListWorkspaceFileVersionsInput extends FileVersionTarget { } export interface ListWorkspaceFileVersionsResult { - versions: WorkspaceFileVersionRecord[] + revision?: string + versions: AuthoredFileVersion[] nextKeys: CursorKey[] | null } @@ -83,7 +91,7 @@ export interface ReadWorkspaceFileVersionTextResult extends ReadWorkspaceFileTex version: WorkspaceFileVersionRecord } -export interface DownloadWorkspaceFileVersionResult extends DownloadWorkspaceFileStreamResult { +interface DownloadWorkspaceFileVersionResult extends DownloadWorkspaceFileStreamResult { version: WorkspaceFileVersionRecord } @@ -140,21 +148,6 @@ async function readVersionSecretProvenance( return snapshot ? workspaceFileSecretProvenanceFromSnapshot(snapshot) : { status: 'unknown' } } -/** - * Runs a read of a version's stored object, answering 404 when the object is gone — retention or a - * delete can remove a superseded version between loading its row and reading its bytes. - */ -async function readVersionObject(version: number, read: () => Promise): Promise { - try { - return await read() - } catch (error) { - if (hasObjectNotFoundCause(error)) { - throw new OrchestrationError('not_found', `Version ${version} not found`) - } - throw error - } -} - /** * The file as it was at `version`: current name and location, that version's bytes. Rename and move * are metadata writes that never create versions, so a version always reads under today's name. @@ -190,7 +183,11 @@ export const listWorkspaceFileVersions = defineAuthorizedWorkspaceFileUseCase({ limit: input.limit, after: input.after, }) - return { versions, nextKeys } + return { + versions: await projectFileVersionAuthors(versions), + nextKeys, + ...workspaceFileRevisionField(file), + } }, }) @@ -223,7 +220,7 @@ export const readWorkspaceFileVersionText = defineAuthorizedWorkspaceFileUseCase const version = await loadVersion(file, input.version) const fileAtVersion = recordAtVersion(file, version) const secretProvenance = await readVersionSecretProvenance(principal, file, version) - const result = await readVersionObject(version.version, () => + const result = await readFileVersionObject(version.version, () => extractWorkspaceFileRecordText( fileAtVersion, input, @@ -236,7 +233,7 @@ export const readWorkspaceFileVersionText = defineAuthorizedWorkspaceFileUseCase }, }) -export const downloadWorkspaceFileVersion = defineAuthorizedWorkspaceFileUseCase({ +const downloadVersion = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.downloadVersion, resolveContext: ({ principal, input }: { principal: Principal; input: FileVersionRef }) => resolveActiveWorkspaceFileContext({ ...input, ownedFilePrincipal: principal }), @@ -250,7 +247,7 @@ export const downloadWorkspaceFileVersion = defineAuthorizedWorkspaceFileUseCase 'complete', await readVersionSecretProvenance(principal, file, version) ) - const result = await readVersionObject(version.version, () => + const result = await readFileVersionObject(version.version, () => streamWorkspaceFileRecord(recordAtVersion(file, version), principal) ) return { ...result, file, version } @@ -270,6 +267,19 @@ export const downloadWorkspaceFileVersion = defineAuthorizedWorkspaceFileUseCase }), }) +/** Historical HEAD checks the retained version without opening its storage object. */ +export const downloadWorkspaceFileVersion = { + ...downloadVersion, + async authorize(args: Parameters[0]) { + await downloadVersion.authorize(args) + const context = await resolveActiveWorkspaceFileContext({ + ...args.input, + ownedFilePrincipal: args.principal, + }) + await loadVersion(await loadActiveFile(context), args.input.version) + }, +} + async function executeRevertWorkspaceFileVersion({ input, context, @@ -290,22 +300,11 @@ async function executeRevertWorkspaceFileVersion({ `The current version is ${current.version}, not ${input.expectedCurrentVersion}` ) } - /* - * Checked before the no-op branch below: a caller that named content which has since changed - * must hear about it, not be told there was nothing to do. - */ - const expectedContentAt = input.expectedRevision - ? parseWorkspaceFileRevision(input.expectedRevision, context.fileId) - : undefined - if ( - expectedContentAt && - (file.contentUpdatedAt ?? file.updatedAt).getTime() !== expectedContentAt.getTime() - ) { - throw new OrchestrationError( - 'conflict', - 'The file changed since the revision you read; re-read it before reverting' - ) - } + const expectedContentAt = assertFileVersionRevision( + file, + input.expectedRevision, + 'The file changed since the revision you read; re-read it before reverting' + ) const target = await loadVersion(file, input.version) if (target.isCurrent) { return { file, version: target, reverted: false, revertedFrom: current.version } @@ -318,7 +317,7 @@ async function executeRevertWorkspaceFileVersion({ } const [content, provenance] = await Promise.all([ - readVersionObject(target.version, () => + readFileVersionObject(target.version, () => fetchWorkspaceFileBuffer(recordAtVersion(file, target), { maxBytes: MAX_BUFFERED_TRANSFER_BYTES, }) diff --git a/apps/sim/lib/workspace-files/application/subject.ts b/apps/sim/lib/workspace-files/application/subject.ts new file mode 100644 index 00000000000..a11bc5906e7 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/subject.ts @@ -0,0 +1,36 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { user } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { getActivelyBannedUserIds, isAccountBlocked } from '@/lib/auth/ban' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' + +type FileSubjectPrincipal = Extract + +/** Resolves the acting human and applies admission-time account and deployment restrictions. */ +export async function requireFileSubject(principal: FileSubjectPrincipal): Promise { + // actorless-unsupported: Project files and cross-owner copies require a human; their operation registries reject workspace keys and executors. + const userId = requirePrincipalSubjectUserId(principal) + if ((await getActivelyBannedUserIds([userId])).length) { + throw new OrchestrationError('forbidden', 'User account is suspended') + } + return userId +} + +/** Holds the acting account stable through a file transaction without substituting an owner. */ +export async function requireCurrentFileSubject( + tx: DbTransaction, + principal: FileSubjectPrincipal +): Promise { + // actorless-unsupported: Project files and cross-owner copies require a human; their operation registries reject workspace keys and executors. + const userId = requirePrincipalSubjectUserId(principal) + const [actor] = await tx + .select({ banned: user.banned, banExpires: user.banExpires, suspendedAt: user.suspendedAt }) + .from(user) + .where(eq(user.id, userId)) + .for('share') + .limit(1) + if (!actor || isAccountBlocked(actor)) { + throw new OrchestrationError('forbidden', 'User account is suspended') + } +} diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts index efaa3b96510..96f1cb91619 100644 --- a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts @@ -43,7 +43,11 @@ describe('updateWorkspaceFileDimensionsOperation', () => { }) expect(result).toEqual({ success: false }) - expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { + includeDeleted: undefined, + includeChatUploads: undefined, + includeOwnedFiles: false, + }) expect(mocks.updateDimensions).toHaveBeenCalledWith('workspace-1', 'file-1', { key: 'workspace/workspace-1/current-key', width: 800, diff --git a/apps/sim/lib/workspace-files/application/version-authors.ts b/apps/sim/lib/workspace-files/application/version-authors.ts new file mode 100644 index 00000000000..c09da10592b --- /dev/null +++ b/apps/sim/lib/workspace-files/application/version-authors.ts @@ -0,0 +1,24 @@ +import { chunkArray } from '@sim/utils/helpers' +import type { DbOrTx } from '@/lib/db/types' +import type { WorkspaceFileVersionRecord } from '@/lib/uploads/contexts/workspace/file-version-store' +import { findUserEmailsByIds } from '@/lib/users/queries' + +export type AuthoredFileVersion = WorkspaceFileVersionRecord & { + authors: { id: string; email: string | null }[] +} + +/** Resolves attribution only after the caller has authorized the selected file history. */ +export async function projectFileVersionAuthors( + versions: WorkspaceFileVersionRecord[], + executor?: DbOrTx +): Promise { + const ids = [...new Set(versions.flatMap((version) => version.authorUserIds))] + const emails = new Map() + for (const batch of chunkArray(ids, 1000)) { + for (const [id, email] of await findUserEmailsByIds(batch, executor)) emails.set(id, email) + } + return versions.map((version) => ({ + ...version, + authors: version.authorUserIds.map((id) => ({ id, email: emails.get(id) ?? null })), + })) +} diff --git a/apps/sim/lib/workspace-files/application/version-content.ts b/apps/sim/lib/workspace-files/application/version-content.ts new file mode 100644 index 00000000000..d74d49bc972 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/version-content.ts @@ -0,0 +1,33 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/file-manager' +import { hasObjectNotFoundCause } from '@/lib/uploads/core/errors' +import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision' + +/** Checks exact content before even a no-op revert, independently of coalesced history numbers. */ +export function assertFileVersionRevision( + file: Pick, + expectedRevision: string | undefined, + conflictMessage: string +): Date | undefined { + if (!expectedRevision) return undefined + const expected = parseWorkspaceFileRevision(expectedRevision, file.id) + if ((file.contentUpdatedAt ?? file.updatedAt).getTime() !== expected.getTime()) { + throw new OrchestrationError('conflict', conflictMessage) + } + return expected +} + +/** Maps an object removed after its version was loaded to the same missing-version response. */ +export async function readFileVersionObject( + version: number, + read: () => Promise +): Promise { + try { + return await read() + } catch (error) { + if (hasObjectNotFoundCause(error)) { + throw new OrchestrationError('not_found', `Version ${version} not found`) + } + throw error + } +} diff --git a/apps/sim/lib/workspace-files/browser-query.ts b/apps/sim/lib/workspace-files/browser-query.ts new file mode 100644 index 00000000000..d196f86d6ba --- /dev/null +++ b/apps/sim/lib/workspace-files/browser-query.ts @@ -0,0 +1,254 @@ +import { folder, workspaceFiles } from '@sim/db/schema' +import { escapeLikePattern } from '@sim/utils/string' +import { and, isNull, type SQL, sql } from 'drizzle-orm' +import { + type CursorKey, + encodeKeyset, + INVALID_CURSOR_MESSAGE, + type KeysetKey, + keysetAfter, + keysetColumns, + type ListSortOrder, + listOrderBy, + numberKey, + textKey, +} from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' +import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' +import { + EXTENSION_TO_MIME, + MIME_TYPE_MAPPING, + resolveEffectiveMimeType, +} from '@/lib/uploads/utils/file' +import { SUPPORTED_DOCUMENT_EXTENSIONS } from '@/lib/uploads/utils/validation' +import { + FILE_BROWSER_MIME_LABELS, + FILE_BROWSER_SIZE_BOUNDARIES, + type FileBrowserCreator, + type FileBrowserFilters, + type FileBrowserItem, + type FileBrowserSort, +} from '@/lib/workspace-files/browser' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +export interface FileBrowserQuery extends FileBrowserFilters { + scope: 'active' | 'archived' + folderId?: string | null + search?: string + sortBy: FileBrowserSort + sortOrder: ListSortOrder + limit: number + after?: CursorKey[] +} + +type BrowserRow = { + id: string + kind: 'file' | 'folder' + name: string + parent_id: string | null + size: string | number + type: string + created_ms: string | number + updated_ms: string | number + creator_id: string | null + creator_name: string | null + creator_image: string | null + creator_deleted: boolean +} + +const EFFECTIVE_EXTENSION_MIMES = Object.fromEntries( + Object.keys(EXTENSION_TO_MIME).map((extension) => [ + extension, + resolveEffectiveMimeType(null, `file.${extension}`), + ]) +) +const AUDIO_MIMES = Object.keys(MIME_TYPE_MAPPING).filter( + (mime) => MIME_TYPE_MAPPING[mime] === 'audio' +) +const VIDEO_MIMES = Object.keys(MIME_TYPE_MAPPING).filter( + (mime) => MIME_TYPE_MAPPING[mime] === 'video' +) + +const BROWSER_SORTS: Record> = { + name: textKey(sql`name collate "C"`, (row) => row.name), + size: numberKey(sql`size`, (row) => Number(row.size)), + type: textKey(sql`type collate "C"`, (row) => row.type), + created: numberKey(sql`created_ms`, (row) => Number(row.created_ms)), + owner: textKey( + sql`coalesce(creator_name, '') collate "C"`, + (row) => row.creator_name ?? '' + ), + updated: numberKey(sql`updated_ms`, (row) => Number(row.updated_ms)), +} + +function creatorFromRow(row: BrowserRow): FileBrowserCreator | null { + return row.creator_id + ? { + id: row.creator_id, + name: row.creator_name ?? 'Deleted user', + image: row.creator_image, + deleted: row.creator_deleted, + } + : null +} + +/** Returns one mixed file/folder page; canonical scope and every filter precede its cursor and LIMIT. */ +export async function queryFileBrowserItems( + owner: EditableFileOwner, + input: FileBrowserQuery, + tx: DbOrTx +) { + const activeFiles = + input.scope === 'archived' + ? sql`${workspaceFiles.deletedAt} is not null` + : sql`${workspaceFiles.deletedAt} is null` + const invalidSizes = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(and(fileOwnerCondition(owner), activeFiles, isNull(workspaceFiles.sizeBytes))) + .limit(1) + if (invalidSizes.length) + throw new OrchestrationError('conflict', 'File size metadata is not ready') + const selectedFolders = + input.scope === 'archived' ? sql`deleted_at is not null` : sql`deleted_at is null` + const base = sql`with recursive + owned_folders as ( + select id, name, parent_id, user_id, created_at, updated_at, deleted_at + from ${folder} where ${fileFolderOwnerCondition(owner)} + ), + owned_files as ( + select id, original_name as name, folder_id as parent_id, size_bytes as size, + content_type, user_id, uploaded_at, updated_at, + case when strpos(original_name, '.') > 0 then lower(regexp_replace(original_name, '^.*[.]', '')) else '' end as extension + from ${workspaceFiles} where ${fileOwnerCondition(owner)} and ${activeFiles} + ), + descendants as ( + select id as root_id, id, 1 as depth from owned_folders + union all + select parent.root_id, child.id, parent.depth + 1 from descendants parent + join owned_folders child on child.parent_id = parent.id + where parent.depth < ${MAX_FOLDER_PATH_SEGMENTS} + ), + folder_sizes as ( + select tree.root_id, sum(files.size)::bigint as size from descendants tree + join owned_files files on files.parent_id = tree.id group by tree.root_id + ), + effective_files as ( + select *, case when lower(btrim(split_part(content_type, ';', 1))) not in ('', 'application/octet-stream', 'binary/octet-stream') + then lower(btrim(split_part(content_type, ';', 1))) else coalesce(${JSON.stringify(EFFECTIVE_EXTENSION_MIMES)}::jsonb ->> extension, 'application/octet-stream') end as mime + from owned_files + ), + items as ( + select id, 'file'::text as kind, name, parent_id, size, + coalesce(${JSON.stringify(FILE_BROWSER_MIME_LABELS)}::jsonb ->> mime, + case when mime like 'audio/%' then 'Audio' when mime like 'video/%' then 'Video' + when mime like 'image/%' then 'Image' when extension <> '' then upper(extension) else coalesce(content_type, 'File') end) as type, + user_id, trunc(extract(epoch from uploaded_at) * 1000) as created_ms, + trunc(extract(epoch from updated_at) * 1000) as updated_ms, extension, mime + from effective_files + union all + select folders.id, 'folder'::text, folders.name, folders.parent_id, coalesce(sizes.size, 0)::bigint, 'Folder'::text, + folders.user_id, trunc(extract(epoch from folders.created_at) * 1000), + trunc(extract(epoch from folders.updated_at) * 1000), ''::text, ''::text + from owned_folders folders left join folder_sizes sizes on sizes.root_id = folders.id where ${selectedFolders} + ), + authored as ( + select items.*, items.user_id as creator_id, users.name as creator_name, + users.image as creator_image, users.id is null as creator_deleted + from items left join "user" users on users.id = items.user_id + )` + const keys = [ + ...(input.sortBy === 'owner' + ? [ + numberKey(sql`case when creator_name is null then 1 else 0 end`, (row) => + row.creator_name === null ? 1 : 0 + ), + ] + : []), + BROWSER_SORTS[input.sortBy], + textKey(sql`name collate "C"`, (row) => row.name), + textKey(sql`kind collate "C"`, (row) => row.kind), + textKey(sql`id collate "C"`, (row) => row.id), + ] + const directions: ListSortOrder[] = [ + ...(input.sortBy === 'owner' ? ['asc' as const] : []), + input.sortOrder, + 'asc', + 'asc', + 'asc', + ] + let after: SQL | undefined + if (input.after) { + after = keysetAfter(keys, input.after, directions) ?? undefined + if (!after) throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } + const clauses: SQL[] = [] + if (input.folderId !== undefined) + clauses.push( + input.folderId === null ? sql`parent_id is null` : sql`parent_id = ${input.folderId}` + ) + if (input.search) clauses.push(sql`name ilike ${`%${escapeLikePattern(input.search)}%`}`) + const typeClauses = (input.types ?? []).map((type) => { + if (type === 'document') + return sql`extension in (${sql.join( + SUPPORTED_DOCUMENT_EXTENSIONS.map((extension) => sql`${extension}`), + sql`, ` + )})` + if (type === 'image') return sql`mime like 'image/%'` + const mimes = type === 'audio' ? AUDIO_MIMES : VIDEO_MIMES + return sql`lower(mime) in (${sql.join( + mimes.map((mime) => sql`${mime}`), + sql`, ` + )})` + }) + const sizeClauses = (input.sizes ?? []).map((size) => + size === 'small' + ? sql`size < ${FILE_BROWSER_SIZE_BOUNDARIES.small}` + : size === 'medium' + ? sql`size >= ${FILE_BROWSER_SIZE_BOUNDARIES.small} and size <= ${FILE_BROWSER_SIZE_BOUNDARIES.medium}` + : sql`size > ${FILE_BROWSER_SIZE_BOUNDARIES.medium}` + ) + const fileFilters: SQL[] = [] + if (typeClauses.length) fileFilters.push(sql`(${sql.join(typeClauses, sql` or `)})`) + if (sizeClauses.length) fileFilters.push(sql`(${sql.join(sizeClauses, sql` or `)})`) + if (input.creatorIds?.length) + fileFilters.push( + sql`creator_id in (${sql.join( + input.creatorIds.map((id) => sql`${id}`), + sql`, ` + )})` + ) + if (fileFilters.length) + clauses.push(sql`(kind = 'folder' or (${sql.join(fileFilters, sql` and `)}))`) + if (after) clauses.push(after) + const where = clauses.length ? sql`where ${sql.join(clauses, sql` and `)}` : sql`` + const rows = await tx.execute(sql`${base} select * from authored ${where} + order by ${sql.join(listOrderBy(keysetColumns(keys), directions), sql`, `)} limit ${input.limit + 1}`) + const creatorRows = await tx.execute(sql`${base} + select * from (select distinct creator_id, creator_name, creator_image, creator_deleted from authored where creator_id is not null) creators + order by creator_name collate "C", creator_id collate "C"`) + const page = rows.slice(0, input.limit) + const last = page.at(-1) + const items: FileBrowserItem[] = page.map((row) => ({ + id: row.id, + kind: row.kind, + name: row.name, + parentId: row.parent_id, + size: getWorkspaceFileSize({ sizeBytes: Number(row.size) }), + type: row.type, + creator: creatorFromRow(row), + createdAt: new Date(Number(row.created_ms)), + updatedAt: new Date(Number(row.updated_ms)), + })) + return { + items, + creators: creatorRows.flatMap((row) => { + const creator = creatorFromRow(row) + return creator ? [creator] : [] + }), + nextKeys: rows.length > input.limit && last ? encodeKeyset(keys, last) : null, + } +} diff --git a/apps/sim/lib/workspace-files/browser.ts b/apps/sim/lib/workspace-files/browser.ts new file mode 100644 index 00000000000..d18d7be4dd5 --- /dev/null +++ b/apps/sim/lib/workspace-files/browser.ts @@ -0,0 +1,48 @@ +export const FILE_BROWSER_SORTS = ['name', 'size', 'type', 'created', 'owner', 'updated'] as const +export type FileBrowserSort = (typeof FILE_BROWSER_SORTS)[number] +export const FILE_BROWSER_TYPES = ['document', 'image', 'audio', 'video'] as const +export const FILE_BROWSER_SIZES = ['small', 'medium', 'large'] as const +export const FILE_BROWSER_SIZE_BOUNDARIES = { small: 1_048_576, medium: 10_485_760 } as const + +export const FILE_BROWSER_MIME_LABELS: Readonly> = { + 'application/pdf': 'PDF', + 'application/zip': 'ZIP', + 'application/msword': 'Word', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word', + 'application/vnd.ms-excel': 'Excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel', + 'application/vnd.ms-powerpoint': 'PowerPoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint', + 'application/json': 'JSON', + 'application/x-yaml': 'YAML', + 'text/csv': 'CSV', + 'text/plain': 'Text', + 'text/html': 'HTML', + 'text/x-sim-page': 'Page', + 'text/markdown': 'Markdown', +} + +export interface FileBrowserCreator { + id: string + name: string + image: string | null + deleted: boolean +} + +export interface FileBrowserItem { + id: string + kind: 'file' | 'folder' + name: string + parentId: string | null + size: number + type: string + createdAt: Date + updatedAt: Date + creator: FileBrowserCreator | null +} + +export interface FileBrowserFilters { + types?: readonly (typeof FILE_BROWSER_TYPES)[number][] + sizes?: readonly (typeof FILE_BROWSER_SIZES)[number][] + creatorIds?: readonly string[] +} diff --git a/apps/sim/lib/workspace-files/owner-paths.ts b/apps/sim/lib/workspace-files/owner-paths.ts new file mode 100644 index 00000000000..27b65246241 --- /dev/null +++ b/apps/sim/lib/workspace-files/owner-paths.ts @@ -0,0 +1,15 @@ +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { FileOwner } from '@/lib/workspace-files/ownership' + +const namespaces: FileOwnerAdapters<(entityId: string, path: string) => string> = { + workspace: (_entityId, path) => path, + project: (entityId, path) => `projects/${encodeURIComponent(entityId)}/${path}`, +} + +/** Preserves the legacy workspace alias while other registered owners have explicit namespaces. */ +export function fileOwnerVfsPath(owner: FileOwner, path: string): string { + return requireFileOwnerAdapter(namespaces, owner)(owner.entityId, path) +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 6566e825859..4e9c354a3b1 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -1,9 +1,11 @@ import path from 'node:path' import { SIM_SITE_URL } from '@sim/utils/site' import type { NextConfig } from 'next' +import { PROXY_CLIENT_MAX_BODY_BYTES } from '@/lib/core/config/request-limits' import { env, isTruthy } from './lib/core/config/env' import { isDev } from './lib/core/config/env-flags' import { + FILE_DELIVERY_CSP_PATH_PATTERN, getChatEmbedCSPPolicy, getMainCSPPolicy, getWorkflowExecutionCSPPolicy, @@ -154,6 +156,7 @@ const nextConfig: NextConfig = { ], }, experimental: { + proxyClientMaxBodySize: PROXY_CLIENT_MAX_BODY_BYTES, /** * Turbopack's dev filesystem cache stays ON (this is also the Next default * since v16.1). It is what makes a dev-server restart cheap: without it every @@ -423,12 +426,13 @@ const nextConfig: NextConfig = { key: 'X-Frame-Options', value: 'SAMEORIGIN', }, - { - key: 'Content-Security-Policy', - value: getMainCSPPolicy(), - }, ], }, + { + // File routes preserve their representation CSP in withRouteHandler. + source: `/((?!workspace|chat|login|signup|${FILE_DELIVERY_CSP_PATH_PATTERN.slice(1)}$|$).*)`, + headers: [{ key: 'Content-Security-Policy', value: getMainCSPPolicy() }], + }, ] }, async redirects() { diff --git a/apps/sim/scripts/test-file-list-realtime-e2e.ts b/apps/sim/scripts/test-file-list-realtime-e2e.ts new file mode 100644 index 00000000000..9dff42c7726 --- /dev/null +++ b/apps/sim/scripts/test-file-list-realtime-e2e.ts @@ -0,0 +1,494 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { db } from '@sim/db' +import { member, permissions, workspace as workspaceTable } from '@sim/db/schema' +import { insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { ROOM_ACCESS_REVOKED_EVENT } from '@sim/realtime-protocol/events' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import { and, eq } from 'drizzle-orm' +import { io, type Socket } from 'socket.io-client' + +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required local runtime value missing') + return value +} +const base = new URL(required(process.env.FILE_LIST_REALTIME_BASE_URL)) +const relay = new URL(required(process.env.FILE_LIST_REALTIME_RELAY_URL)) +const database = new URL(required(process.env.DATABASE_URL)) +for (const value of [base, relay, database]) + assert.ok(['localhost', '127.0.0.1', '[::1]'].includes(value.hostname), 'Local runtime required') +assert.match(database.pathname, /test/i, 'Disposable database required') +const reportPath = required(process.env.FILE_LIST_REALTIME_REPORT_PATH) +const fixtureDir = required(process.env.FILE_LIST_REALTIME_FIXTURE_DIR) +const account = toRecord(JSON.parse(await readFile(join(fixtureDir, 'owner-account.json'), 'utf8'))) +const ownerCookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const sockets: Socket[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +const statuses: { method: string; path: string; status: number }[] = [] +const fixture = { workspaceId: '', projectId: '', secondEnvironmentId: '', readerId: '' } +let readerCookie = '' +const readerAccount = { + email: `list-reader-${generateId()}@example.test`, + password: `${generateId()}Aa9!`, +} +let ownerId = '' +let fileId = '' +let folderId = '' +let revision = '' +let reader: Socket +let owner: Socket +let workspaceObserver: Socket +const projectEvents: Record[] = [] +const workspaceEvents: Record[] = [] + +async function saveReport() { + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile( + reportPath, + JSON.stringify( + { + checks, + statuses, + fixture, + fixtureMethod: + 'Existing real owner session and supported HTTP workspace/file writes; second environment and reader membership use isolated database fixtures; reader session uses real email signup.', + }, + null, + 2 + ) + ) +} +async function check(name: string, action: () => Promise) { + const start = performance.now() + try { + await action() + checks.push({ name, status: 'passed', durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + throw error + } finally { + await saveReport() + } +} +async function request( + path: string, + method = 'GET', + body?: object, + cookie = ownerCookie, + extraHeaders?: Record +) { + // boundary-raw-fetch: standalone E2E exercises actual authenticated HTTP and the internal service boundary. + const response = await fetch(new URL(path, base), { + method, + headers: { + Origin: base.origin, + 'Content-Type': 'application/json', + Cookie: cookie, + ...extraHeaders, + }, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: 'manual', + signal: AbortSignal.timeout(90_000), + }) + statuses.push({ method, path: new URL(path, base).pathname, status: response.status }) + return { status: response.status, headers: response.headers, text: await response.text() } +} +function json(response: Awaited>, expected = 200) { + assert.equal(response.status, expected, 'Unexpected HTTP status; inspect private app logs') + return toRecord(JSON.parse(response.text)) +} +async function waitEvent( + socket: Socket, + event: string, + rejectEvent?: string, + timeout = 15_000, + action?: () => Promise +) { + let timer: ReturnType | undefined + let settled = false + let startDeadline: () => void = () => undefined + let accept: (value: unknown) => void + let deny: (value: unknown) => void + const cleanup = () => { + clearTimeout(timer) + socket.off(event, accept) + if (rejectEvent) socket.off(rejectEvent, deny) + } + const signal = new Promise>((resolve, reject) => { + function finish(error?: Error, value?: unknown) { + settled = true + cleanup() + if (error) reject(error) + else resolve(toRecord(value)) + } + accept = (value: unknown) => finish(undefined, value) + deny = (value: unknown) => + finish(new Error(`${event} rejected: ${String(toRecord(value).code ?? 'connection error')}`)) + socket.once(event, accept) + if (rejectEvent) socket.once(rejectEvent, deny) + /** The HTTP action has its own deadline; compilation time must not consume delivery time. */ + startDeadline = () => { + if (!settled) + timer = setTimeout(() => finish(new Error(`Timed out waiting for ${event}`)), timeout) + } + }) + /** Observe early rejection while the bounded HTTP action is still running. */ + void signal.catch(() => undefined) + try { + if (action) await action() + startDeadline() + return await signal + } finally { + cleanup() + } +} +async function connect(cookie: string) { + const token = required( + json(await request('/api/auth/socket-token', 'POST', undefined, cookie)).token + ) + const socket = io(relay.origin, { + autoConnect: false, + transports: ['websocket'], + reconnection: false, + auth: { token }, + extraHeaders: { Origin: base.origin }, + }) + sockets.push(socket) + const connected = waitEvent(socket, 'connect', 'connect_error') + socket.connect() + await connected + return socket +} +async function joinRoom(socket: Socket, type: 'project-files' | 'workspace-files', id: string) { + const result = waitEvent(socket, `join-${type}-success`, `join-${type}-error`) + socket.emit(`join-${type}`, { [type === 'project-files' ? 'projectId' : 'workspaceId']: id }) + return result +} +async function changed(action: () => Promise) { + const count = projectEvents.length + const event = await waitEvent(reader, 'project-files-changed', undefined, 15_000, action) + assert.deepEqual(Object.keys(event).sort(), ['projectId', 'timestamp']) + assert.equal(event.projectId, fixture.projectId) + assert.equal(typeof event.timestamp, 'number') + assert.equal(projectEvents.length, count + 1, 'One committed operation must emit one signal') +} +async function unchanged(action: () => Promise) { + const count = projectEvents.length + await action() + await sleep(200) + assert.equal(projectEvents.length, count, 'Rejected or staged work must not invalidate the list') +} +const prefix = () => `/api/projects/${fixture.projectId}/files` + +try { + await check('real sessions and canonical multi-environment Project fixture', async () => { + ownerId = required(toRecord(json(await request('/api/auth/get-session')).user).id) + const workspace = toRecord( + json( + await request('/api/workspaces', 'POST', { + name: `Realtime list proof ${generateId()}`, + skipDefaultWorkflow: true, + }) + ).workspace + ) + fixture.workspaceId = required(workspace.id) + const [binding] = await db + .select() + .from(workspaceTable) + .where(eq(workspaceTable.id, fixture.workspaceId)) + assert.ok(binding) + fixture.projectId = binding.projectId + const [canonicalWorkspace] = await db + .select() + .from(workspaceTable) + .where(eq(workspaceTable.id, fixture.workspaceId)) + assert.ok(canonicalWorkspace) + fixture.secondEnvironmentId = generateId() + await insertWorkspaceFixture(db, { + id: fixture.secondEnvironmentId, + name: 'Hidden realtime environment', + ownerId, + billedAccountUserId: canonicalWorkspace.billedAccountUserId, + organizationId: canonicalWorkspace.organizationId, + workspaceMode: canonicalWorkspace.workspaceMode, + forkedFromWorkspaceId: fixture.workspaceId, + }) + const signup = await request( + '/api/auth/sign-up/email', + 'POST', + { + ...readerAccount, + name: 'List proof reader', + }, + '' + ) + fixture.readerId = required(toRecord(json(signup).user).id) + readerCookie = signup.headers + .getSetCookie() + .map((cookie) => cookie.split(';')[0]) + .join('; ') + assert.ok(readerCookie) + await writeFile( + join(fixtureDir, 'file-list-realtime-fixture.json'), + JSON.stringify({ ...fixture, readerAccount, readerCookie }), + { mode: 0o600 } + ) + if (canonicalWorkspace.organizationId) + await db.insert(member).values({ + id: generateId(), + userId: fixture.readerId, + organizationId: canonicalWorkspace.organizationId, + role: 'member', + createdAt: new Date(), + }) + await db.insert(permissions).values({ + id: generateId(), + userId: fixture.readerId, + entityType: 'workspace', + entityId: fixture.workspaceId, + permissionType: 'read', + }) + const files = json(await request(prefix(), 'GET', undefined, readerCookie)) + assert.equal(toRecord(files.capabilities).canRead, true) + assert.equal(toRecord(files.capabilities).canWrite, false) + }) + await check( + 'service callback rejects an ordinary session or missing socket subject', + async () => { + const path = `/api/internal/project-file-list/${fixture.projectId}/access` + assert.equal((await request(path, 'POST')).status, 401) + assert.equal( + ( + await request(path, 'POST', undefined, '', { + 'x-api-key': required(process.env.INTERNAL_API_SECRET), + }) + ).status, + 401 + ) + } + ) + await check( + 'partial-access reader joins Project list while workspace wire remains unchanged', + async () => { + reader = await connect(readerCookie) + owner = await connect(ownerCookie) + workspaceObserver = await connect(ownerCookie) + assert.deepEqual(await joinRoom(reader, 'project-files', fixture.projectId), { + projectId: fixture.projectId, + }) + assert.deepEqual(await joinRoom(owner, 'project-files', fixture.projectId), { + projectId: fixture.projectId, + }) + assert.deepEqual(await joinRoom(workspaceObserver, 'workspace-files', fixture.workspaceId), { + workspaceId: fixture.workspaceId, + }) + reader.on('project-files-changed', (event) => projectEvents.push(toRecord(event))) + workspaceObserver.on('workspace-files-changed', (event) => + workspaceEvents.push(toRecord(event)) + ) + } + ) + await check( + 'unknown Project and malformed room IDs cannot widen or evict current membership', + async () => { + const denied = waitEvent(owner, 'join-project-files-error', 'join-project-files-success') + owner.emit('join-project-files', { projectId: generateId() }) + assert.equal((await denied).code, 'NOT_FOUND') + const malformed = waitEvent(reader, 'join-project-files-error', 'join-project-files-success') + reader.emit('join-project-files', { projectId: `${fixture.projectId}:other` }) + assert.equal((await malformed).code, 'INVALID_PAYLOAD') + const whitespace = waitEvent(reader, 'join-project-files-error', 'join-project-files-success') + reader.emit('join-project-files', { projectId: `${fixture.projectId} other` }) + assert.equal((await whitespace).code, 'INVALID_PAYLOAD') + } + ) + await check( + 'create broadcasts only after the canonical file is readable and never to the workspace room', + async () => { + const retainedMembership = await waitEvent( + owner, + 'project-files-changed', + undefined, + 15_000, + () => + changed(async () => { + const file = toRecord( + json( + await request(prefix(), 'POST', { + name: `live-${generateId()}.md`, + content: 'first version', + contentType: 'text/markdown', + encoding: 'utf-8', + }), + 201 + ).file + ) + fileId = required(file.id) + revision = required(json(await request(`${prefix()}/${fileId}/versions`)).revision) + }) + ) + assert.equal( + toRecord(json(await request(`${prefix()}/${fileId}`, 'GET', undefined, readerCookie)).file) + .id, + fileId + ) + assert.equal(retainedMembership.projectId, fixture.projectId) + assert.equal(workspaceEvents.length, 0) + } + ) + await check('read-only mutation and stale revision do not broadcast', async () => { + await changed(async () => { + json( + await request(`${prefix()}/${fileId}/content`, 'PUT', { + content: 'advance before the stale-write check', + encoding: 'utf-8', + expectedRevision: revision, + }) + ) + }) + await unchanged(async () => { + assert.equal( + (await request(`${prefix()}/${fileId}`, 'PATCH', { name: 'denied.md' }, readerCookie)) + .status, + 403 + ) + assert.equal( + ( + await request(`${prefix()}/${fileId}/content`, 'PUT', { + content: 'must not commit', + encoding: 'utf-8', + expectedRevision: revision, + }) + ).status, + 409 + ) + }) + }) + await check( + 'rename, folder creation, move, content save, archive and restore each notify current viewers', + async () => { + await changed(async () => { + json(await request(`${prefix()}/${fileId}`, 'PATCH', { name: 'renamed-live.md' })) + }) + await changed(async () => { + folderId = required( + toRecord( + json(await request(`${prefix()}/folders`, 'POST', { name: 'Live folder' })).folder + ).id + ) + }) + await changed(async () => { + json( + await request(`${prefix()}/move`, 'POST', { + fileIds: [fileId], + folderIds: [], + targetFolderId: folderId, + }) + ) + }) + revision = required(json(await request(`${prefix()}/${fileId}/versions`)).revision) + await changed(async () => { + json( + await request(`${prefix()}/${fileId}/content`, 'PUT', { + content: 'second version', + encoding: 'utf-8', + expectedRevision: revision, + }) + ) + }) + await changed(async () => { + json(await request(`${prefix()}/archive`, 'POST', { fileIds: [fileId], folderIds: [] })) + }) + await changed(async () => { + json(await request(`${prefix()}/${fileId}/restore`, 'POST', {})) + }) + } + ) + await check( + 'upload allocation stays silent and only completed registration invalidates', + async () => { + let upload: Record = {} + const bytes = new TextEncoder().encode('streamed fixture bytes') + await unchanged(async () => { + upload = json( + await request(`${prefix()}/uploads`, 'POST', { + name: 'streamed.txt', + contentType: 'text/plain', + size: bytes.length, + }), + 201 + ) + }) + const transfer = toRecord(upload.transfer) + assert.equal(transfer.method, 'put') + const target = new URL(required(transfer.url), base) + assert.equal(target.origin, base.origin, 'Local storage transfer required') + const headers = new Headers() + for (const [key, value] of Object.entries(toRecord(transfer.headers))) + headers.set(key, required(value)) + // boundary-raw-fetch: the real local provider receives binary bytes at its signed upload URL. + const uploaded = await fetch(target, { method: 'PUT', headers, body: bytes }) + assert.ok(uploaded.ok) + await changed(async () => { + const result = json( + await request( + `${prefix()}/uploads/${required(toRecord(upload.session).id)}/complete`, + 'POST', + {}, + ownerCookie, + { 'upload-token': required(upload.uploadToken) } + ) + ) + assert.equal(result.status, 'completed') + assert.equal(toRecord(result.result).name, 'streamed.txt') + }) + } + ) + await check( + 'same live socket loses Project collection access after reader membership revocation', + async () => { + const revoked = waitEvent(reader, ROOM_ACCESS_REVOKED_EVENT, undefined, 75_000) + await db + .delete(permissions) + .where( + and( + eq(permissions.userId, fixture.readerId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, fixture.workspaceId) + ) + ) + const event = await revoked + assert.deepEqual(event.room, { type: 'project-files', id: fixture.projectId }) + const denied = await request(prefix(), 'GET', undefined, readerCookie) + assert.ok([403, 404].includes(denied.status)) + await unchanged(async () => { + json(await request(`${prefix()}/${fileId}`, 'PATCH', { name: 'after-revocation.md' })) + }) + const join = waitEvent(reader, 'join-project-files-error', 'join-project-files-success') + reader.emit('join-project-files', { projectId: fixture.projectId }) + assert.ok(['NOT_FOUND', 'ACCESS_DENIED'].includes(required((await join).code))) + } + ) +} catch { + process.exitCode = 1 +} finally { + for (const socket of sockets) socket.disconnect() + await saveReport() +} + +process.stdout.write( + `${JSON.stringify({ reportPath, passed: checks.filter((check) => check.status === 'passed').length, failed: checks.filter((check) => check.status === 'failed').length })}\n` +) +process.exit(checks.length > 0 && checks.every((check) => check.status === 'passed') ? 0 : 1) diff --git a/apps/sim/scripts/test-project-file-browser-e2e.ts b/apps/sim/scripts/test-project-file-browser-e2e.ts new file mode 100644 index 00000000000..b03f29d3693 --- /dev/null +++ b/apps/sim/scripts/test-project-file-browser-e2e.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' + +const base = new URL(process.env.PROJECT_FILE_BROWSER_BASE_URL ?? 'http://127.0.0.1:3300') +if (!['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname)) + throw new Error('Browser proof requires a disposable local runtime') +function required(value: unknown): string { + if (typeof value !== 'string' || !value) throw new Error('Required fixture field missing') + return value +} +const reportPath = required(process.env.PROJECT_FILE_BROWSER_REPORT_PATH) +const fixtureDir = required(process.env.PROJECT_FILE_BROWSER_FIXTURE_DIR) +const fixture = toRecord( + JSON.parse(await readFile(join(fixtureDir, 'http-project-fixture.json'), 'utf8')) +) +const account = toRecord(JSON.parse(await readFile(join(fixtureDir, 'owner-account.json'), 'utf8'))) +const cookie = toArray(account.cookies) + .map((cookie) => required(cookie).split(';')[0]) + .join('; ') +const projectId = required(toRecord(fixture.project).id) +const prefix = `/api/projects/${projectId}/files` +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +let folderId: string | undefined +let secondProjectId: string | undefined +async function request( + method: string, + path: string, + body?: Record, + anonymous = false +) { + // boundary-raw-fetch: this repeatable proof exercises actual session-authenticated HTTP against the disposable local app. + const response = await fetch(new URL(path, base), { + method, + headers: { + Origin: base.origin, + 'Content-Type': 'application/json', + ...(anonymous ? {} : { Cookie: cookie }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(90_000), + }) + return { status: response.status, text: await response.text() } +} +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status, response.text) + return toRecord(JSON.parse(response.text)) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +function listPath(query: Record, project = projectId) { + const params = new URLSearchParams({ + folderId: required(folderId), + limit: '1', + sortBy: 'name', + sortOrder: 'asc', + }) + for (const [key, value] of Object.entries(query)) { + params.delete(key) + for (const item of Array.isArray(value) ? value : [value]) params.append(key, item) + } + return `/api/projects/${project}/files?${params}` +} +try { + const record = toRecord(json(await request('GET', `/api/projects/${projectId}`)).project) + const second = json( + await request('POST', '/api/projects', { + organizationId: record.organizationId, + name: `Browser cursor ${generateId()}`, + initialEnvironment: { name: 'Sandbox' }, + }), + 201 + ) + secondProjectId = required(toRecord(second.project).id) + folderId = required( + toRecord( + json(await request('POST', `${prefix}/folders`, { name: `Browser proof ${generateId()}` })) + .folder + ).id + ) + const created = new Map() + for (const name of ['a.txt', 'b.txt', 'c.txt', 'y.png', 'z.png']) { + const file = toRecord( + json( + await request('POST', prefix, { + name, + content: 'x', + contentType: 'application/octet-stream', + encoding: 'utf-8', + folderId, + }), + 201 + ).file + ) + created.set(name, required(file.id)) + } + await check('complete-set type filter finds later matches with a one-row page', async () => { + const first = json(await request('GET', listPath({ types: 'image' }))) + assert.deepEqual( + toArray(first.items).map((item) => toRecord(item).id), + [created.get('y.png')] + ) + const second = json( + await request('GET', listPath({ types: 'image', cursor: required(first.nextCursor) })) + ) + assert.deepEqual( + toArray(second.items).map((item) => toRecord(item).id), + [created.get('z.png')] + ) + assert.equal(second.nextCursor, null) + }) + await check('equal size keys retain ascending names across both cursor directions', async () => { + for (const sortOrder of ['asc', 'desc']) { + const ids: string[] = [] + let cursor: string | undefined + for (let page = 0; page < 6; page++) { + const result = json( + await request( + 'GET', + listPath({ sortBy: 'size', sortOrder, ...(cursor ? { cursor } : {}) }) + ) + ) + ids.push(...toArray(result.items).map((item) => required(toRecord(item).id))) + if (!result.nextCursor) break + cursor = required(result.nextCursor) + } + assert.deepEqual(ids, [...created.values()]) + } + }) + const first = json(await request('GET', listPath({}))) + const cursor = required(first.nextCursor) + await check('a cursor cannot cross a filter or folder scope', async () => { + assert.equal((await request('GET', listPath({ cursor, types: 'image' }))).status, 400) + assert.equal((await request('GET', listPath({ cursor, creatorIds: generateId() }))).status, 400) + assert.equal((await request('GET', listPath({ cursor, folderId: generateId() }))).status, 400) + }) + await check('a cursor cannot cross an accessible Project or ordering', async () => { + assert.equal( + (await request('GET', listPath({ cursor }, required(secondProjectId)))).status, + 400 + ) + assert.equal((await request('GET', listPath({ cursor, sortOrder: 'desc' }))).status, 400) + assert.equal((await request('GET', listPath({ cursor, sortBy: 'size' }))).status, 400) + }) + await check('equivalent unordered filter sets preserve cursor continuity', async () => { + const first = json(await request('GET', listPath({ types: ['image', 'video'] }))) + const second = json( + await request( + 'GET', + listPath({ types: ['video', 'image'], cursor: required(first.nextCursor) }) + ) + ) + assert.equal(toRecord(toArray(second.items)[0]).id, created.get('z.png')) + }) + await check( + 'browser list requires a session and reports observed canonical creator labels', + async () => { + assert.equal((await request('GET', listPath({}), undefined, true)).status, 401) + const page = json(await request('GET', listPath({ limit: '100' }))) + for (const item of toArray(page.items)) { + const creator = toRecord(toRecord(item).creator) + assert.equal(typeof creator.name, 'string') + assert.equal(creator.deleted, false) + assert.equal('environmentIds' in creator, false) + assert.equal('permissions' in creator, false) + } + assert.equal(toArray(page.items).length, created.size) + } + ) +} catch (error) { + checks.push({ + name: 'fixture setup', + passed: false, + durationMs: 0, + error: getErrorMessage(error), + }) +} finally { + await check('fixture resources are archived', async () => { + if (folderId) + json(await request('POST', `${prefix}/archive`, { fileIds: [], folderIds: [folderId] })) + if (secondProjectId) json(await request('DELETE', `/api/projects/${secondProjectId}`)) + }) + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +process.stdout.write( + `${JSON.stringify({ passed: checks.filter((check) => check.passed).length, total: checks.length, report: reportPath })}\n` +) +if (checks.some((check) => !check.passed)) process.exitCode = 1 diff --git a/apps/sim/scripts/test-project-file-history-e2e.ts b/apps/sim/scripts/test-project-file-history-e2e.ts new file mode 100644 index 00000000000..15fbae6dfec --- /dev/null +++ b/apps/sim/scripts/test-project-file-history-e2e.ts @@ -0,0 +1,518 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' + +const base = new URL(process.env.PROJECT_FILE_HISTORY_BASE_URL ?? 'http://127.0.0.1:3300') +if (!['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname)) + throw new Error('History proof requires a disposable local runtime') +const fixtureDir = required(process.env.PROJECT_FILE_HISTORY_FIXTURE_DIR) +const reportPath = required( + process.env.PROJECT_FILE_HISTORY_REPORT_PATH, + 'PROJECT_FILE_HISTORY_REPORT_PATH is required' +) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +function required(value: unknown, message = 'Required fixture field missing'): string { + if (typeof value !== 'string' || !value) throw new Error(message) + return value +} +async function fixture(name: string) { + return toRecord(JSON.parse(await readFile(join(fixtureDir, name), 'utf8'))) +} +const keys = await fixture('v2-fixture-keys.json') +const cookie = toArray((await fixture('owner-account.json')).cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const projectFixture = await fixture('http-project-fixture.json') +const projectId = required(toRecord(projectFixture.project).id) +const workspaceId = required(toRecord(projectFixture.initialEnvironment).id) +const prefix = `/api/v2/projects/${projectId}/files` +const internalPrefix = `/api/projects/${projectId}/files` +const ids: string[] = [] +const workspaceIds: string[] = [] +const pendingUploads: { id: string; token: string }[] = [] +async function request( + method: string, + path: string, + body?: Record, + auth: 'personal' | 'workspace' | 'session' | 'none' = 'personal', + extraHeaders?: Record +) { + const headers = new Headers({ Origin: base.origin, 'Content-Type': 'application/json' }) + if (auth === 'session') headers.set('Cookie', cookie) + if (auth === 'personal' || auth === 'workspace') headers.set('X-API-Key', required(keys[auth])) + for (const [key, value] of Object.entries(extraHeaders ?? {})) headers.set(key, value) + // boundary-raw-fetch: this standalone proof exercises real authenticated HTTP against a disposable local runtime. + const response = await fetch(new URL(path, base), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(90_000), + }) + return { status: response.status, headers: response.headers, text: await response.text() } +} +async function verifyStreamedDownload( + path: string, + auth: 'personal' | 'session', + expectedBytes: number, + expectedHash: string +) { + const headers = new Headers({ Origin: base.origin }) + if (auth === 'session') headers.set('Cookie', cookie) + else headers.set('X-API-Key', required(keys.personal)) + // boundary-raw-fetch: this proof verifies incremental delivery of large files over actual HTTP. + const response = await fetch(new URL(path, base), { + headers, + signal: AbortSignal.timeout(90_000), + }) + assert.equal(response.status, 200) + assert.equal(Number(response.headers.get('content-length')), expectedBytes) + assert.ok(response.body) + const reader = response.body.getReader() + const hash = createHash('sha256') + let received = 0 + let chunks = 0 + try { + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + assert.ok(chunk.value.length < expectedBytes) + received += chunk.value.length + chunks += 1 + hash.update(chunk.value) + } + } finally { + await reader.cancel() + reader.releaseLock() + } + assert.ok(chunks > 1) + assert.equal(received, expectedBytes) + assert.equal(hash.digest('hex'), expectedHash) +} + +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status) + return toRecord(JSON.parse(response.text)) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +try { + const create = async (content: string) => { + const file = toRecord( + json( + await request('POST', prefix, { + name: `history-${generateId()}.txt`, + content, + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).data + ) + ids.push(required(file.id)) + return file + } + const created = await create('alpha') + const other = await create('other') + const detail = `${prefix}/${required(created.id)}` + const internal = `${internalPrefix}/${required(created.id)}` + const saved = toRecord( + json( + await request('PUT', `${detail}/content`, { + content: 'beta', + encoding: 'utf-8', + expectedRevision: created.revision, + }) + ).data + ) + json( + await request( + 'PUT', + `${internal}/content`, + { content: 'gamma', encoding: 'utf-8', expectedRevision: saved.revision }, + 'session' + ) + ) + const current = toRecord(json(await request('GET', `${detail}/metadata`)).data) + let cursor: string | undefined + await check('v2 pages preserve source and authorized author attribution', async () => { + const first = json(await request('GET', `${detail}/versions?limit=1`)) + const head = toRecord(toArray(first.data)[0]) + assert.equal(head.version, 3) + assert.equal(head.source, 'user') + assert.equal(typeof toRecord(toArray(head.authors)[0]).email, 'string') + assert.equal('key' in head, false) + assert.equal('authorUserIds' in head, false) + cursor = required(first.nextCursor) + const second = json( + await request('GET', `${detail}/versions?limit=1&cursor=${encodeURIComponent(cursor)}`) + ) + assert.equal(toRecord(toArray(second.data)[0]).version, 2) + assert.equal(toRecord(toArray(second.data)[0]).source, 'api') + const third = json( + await request( + 'GET', + `${detail}/versions?limit=1&cursor=${encodeURIComponent(required(second.nextCursor))}` + ) + ) + assert.equal(toRecord(toArray(third.data)[0]).version, 1) + assert.equal(third.nextCursor, null) + }) + await check('history cursors cannot cross file or ordering', async () => { + assert.ok(cursor) + assert.equal( + ( + await request( + 'GET', + `${prefix}/${required(other.id)}/versions?cursor=${encodeURIComponent(cursor)}` + ) + ).status, + 400 + ) + assert.equal( + ( + await request( + 'GET', + `${detail}/versions?sortOrder=asc&cursor=${encodeURIComponent(cursor)}` + ) + ).status, + 400 + ) + }) + await check('internal session list and metadata share authorized history', async () => { + const list = json(await request('GET', `${internal}/versions?limit=2`, undefined, 'session')) + assert.equal(toArray(list.versions).length, 2) + assert.equal(list.revision, current.revision) + const version = toRecord( + json(await request('GET', `${internal}/versions/1`, undefined, 'session')).version + ) + assert.equal(version.version, 1) + assert.equal(version.source, 'upload') + }) + await check('both surfaces deliver historical source bytes privately', async () => { + for (const [path, auth] of [ + [`${detail}/versions/1/content`, 'personal'], + [`${internal}/versions/1/content`, 'session'], + ] as const) { + const response = await request('GET', path, undefined, auth) + assert.equal(response.status, 200) + assert.equal(response.text, 'alpha') + assert.match(response.headers.get('cache-control') ?? '', /private/) + assert.equal(response.headers.get('x-content-type-options'), 'nosniff') + } + }) + await check('workspace keys and anonymous sessions cannot use Project history', async () => { + for (const [method, path, body] of [ + ['GET', `${detail}/versions`, undefined], + ['GET', `${detail}/versions/1/content`, undefined], + ['POST', `${detail}/versions/1/revert`, {}], + ['DELETE', `${detail}/versions/1`, undefined], + ] as const) + assert.equal((await request(method, path, body, 'workspace')).status, 403) + assert.equal((await request('GET', `${internal}/versions`, undefined, 'none')).status, 401) + }) + await check('revert rejects stale content and appends a source-attributed version', async () => { + assert.equal( + (await request('POST', `${detail}/versions/1/revert`, { expectedRevision: created.revision })) + .status, + 409 + ) + const result = toRecord( + json( + await request('POST', `${detail}/versions/1/revert`, { + expectedRevision: current.revision, + expectedCurrentVersion: 3, + }) + ).data + ) + assert.equal(result.reverted, true) + assert.equal(toRecord(result.version).version, 4) + assert.equal(toRecord(result.version).source, 'revert') + assert.equal(toRecord(result.version).restoredFromVersion, 1) + assert.notEqual(result.revision, current.revision) + assert.equal((await request('GET', `${detail}/content`)).text, 'alpha') + assert.equal( + (await request('POST', `${detail}/versions/2/revert`, { expectedRevision: current.revision })) + .status, + 409 + ) + }) + await check('current history survives while a superseded version can be deleted', async () => { + assert.equal((await request('DELETE', `${detail}/versions/4`)).status, 409) + assert.equal( + json(await request('DELETE', `${internal}/versions/2`, undefined, 'session')).deleted, + true + ) + assert.equal((await request('GET', `${detail}/versions/2`)).status, 404) + assert.equal((await request('GET', `${detail}/content`)).text, 'alpha') + }) + await check('history validates pagination and conceals foreign ownership', async () => { + assert.equal((await request('GET', `${detail}/versions?limit=1.5`)).status, 400) + assert.equal((await request('GET', `${detail}/versions?workspaceId=forged`)).status, 400) + assert.equal( + ( + await request( + 'GET', + `/api/v2/projects/${generateId()}/files/${required(created.id)}/versions` + ) + ).status, + 404 + ) + }) + await check( + 'workspace session history preserves source bytes and rejects foreign ownership', + async () => { + const file = toRecord( + json( + await request('POST', '/api/v2/files', { + workspaceId, + name: `workspace-history-${generateId()}.txt`, + content: 'workspace original', + }), + 201 + ).data + ) + const fileId = required(file.id) + workspaceIds.push(fileId) + const path = `/api/workspaces/${workspaceId}/files/${fileId}/versions` + const list = json(await request('GET', path, undefined, 'session')) + assert.equal(list.revision, file.revision) + assert.equal(toRecord(toArray(list.versions)[0]).version, 1) + assert.equal('key' in toRecord(toArray(list.versions)[0]), false) + const source = await request('GET', `${path}/1/content`, undefined, 'session') + assert.equal(source.status, 200) + assert.equal(source.text, 'workspace original') + assert.match(source.headers.get('cache-control') ?? '', /private/) + assert.equal((await request('GET', path, undefined, 'none')).status, 401) + assert.equal( + ( + await request( + 'GET', + `/api/workspaces/${generateId()}/files/${fileId}/versions`, + undefined, + 'session' + ) + ).status, + 404 + ) + assert.equal( + ( + await request( + 'GET', + `/api/workspaces/${workspaceId}/files/${required(created.id)}/versions`, + undefined, + 'session' + ) + ).status, + 404 + ) + } + ) + await check( + 'workspace session revert fences the observed history revision and retains later versions', + async () => { + const fileId = required(workspaceIds[0]) + const path = `/api/workspaces/${workspaceId}/files/${fileId}/versions` + const observed = json(await request('GET', path, undefined, 'session')) + const saved = toRecord( + json( + await request('PUT', `/api/v2/files/${fileId}/content`, { + workspaceId, + content: 'workspace concurrent save', + expectedRevision: observed.revision, + }) + ).data + ) + assert.equal( + ( + await request( + 'POST', + `${path}/1/revert`, + { expectedRevision: observed.revision }, + 'session' + ) + ).status, + 409 + ) + const head = json(await request('GET', path, undefined, 'session')) + assert.equal(head.revision, saved.revision) + const reverted = json( + await request('POST', `${path}/1/revert`, { expectedRevision: head.revision }, 'session') + ) + assert.equal(reverted.reverted, true) + assert.notEqual(reverted.revision, head.revision) + assert.equal( + (await request('GET', `/api/v2/files/${fileId}?workspaceId=${workspaceId}`)).text, + 'workspace original' + ) + const retained = await request('GET', `${path}/2/content`, undefined, 'session') + assert.equal(retained.status, 200) + assert.equal(retained.text, 'workspace concurrent save') + assert.equal( + toRecord(toArray(json(await request('GET', path, undefined, 'session')).versions)[0]) + .source, + 'revert' + ) + } + ) + await check('workspace session revert accepts an omitted optional revision guard', async () => { + const fileId = required(workspaceIds[0]) + const path = `/api/workspaces/${workspaceId}/files/${fileId}/versions` + const result = json(await request('POST', `${path}/2/revert`, {}, 'session')) + assert.equal(result.reverted, true) + assert.ok(required(result.revision)) + assert.equal( + (await request('GET', `/api/v2/files/${fileId}?workspaceId=${workspaceId}`)).text, + 'workspace concurrent save' + ) + }) + await check( + 'large current and historical downloads stream while oversized reverts leave the head unchanged', + async () => { + const bytes = Buffer.alloc(100 * 1024 * 1024 + 1, 37) + const expectedHash = createHash('sha256').update(bytes).digest('hex') + const upload = json( + await request( + 'POST', + `${internalPrefix}/uploads`, + { + name: `history-size-${generateId()}.bin`, + contentType: 'application/octet-stream', + size: bytes.length, + }, + 'session' + ), + 201 + ) + const transfer = toRecord(upload.transfer) + const uploadId = required(toRecord(upload.session).id) + const uploadToken = required(upload.uploadToken) + pendingUploads.push({ id: uploadId, token: uploadToken }) + assert.equal(transfer.method, 'multipart') + const partSize = transfer.partSize + const partCount = transfer.partCount + assert.equal(typeof partSize, 'number') + assert.equal(typeof partCount, 'number') + if (typeof partSize !== 'number' || typeof partCount !== 'number') + throw new Error('Multipart transfer sizes are missing') + assert.equal(partCount, Math.ceil(bytes.length / partSize)) + assert.ok(partCount <= 100) + const partNumbers = Array.from({ length: partCount }, (_, index) => index + 1) + const parts = toArray( + json( + await request( + 'POST', + `${internalPrefix}/uploads/${uploadId}/parts`, + { partNumbers }, + 'session', + { 'upload-token': uploadToken } + ) + ).parts + ) + assert.equal(parts.length, partCount) + for (const value of parts) { + const part = toRecord(value) + const partNumber = part.partNumber + if (typeof partNumber !== 'number') throw new Error('Multipart part number is missing') + const transferHeaders = new Headers() + for (const [key, header] of Object.entries(toRecord(part.headers))) + transferHeaders.set(key, required(header)) + // boundary-raw-fetch: this proof uploads actual oversized history through signed multipart transfer URLs. + const transferred: Response = await fetch(required(part.url), { + method: 'PUT', + headers: transferHeaders, + body: bytes.subarray((partNumber - 1) * partSize, partNumber * partSize), + signal: AbortSignal.timeout(90_000), + }) + assert.equal(transferred.ok, true) + } + const completed = json( + await request('POST', `${internalPrefix}/uploads/${uploadId}/complete`, {}, 'session', { + 'upload-token': uploadToken, + }) + ) + const fileId = required(toRecord(completed.result).id) + ids.push(fileId) + const largeDetail = `${prefix}/${fileId}` + const largeInternal = `${internalPrefix}/${fileId}` + for (const [path, auth] of [ + [largeDetail, 'personal'], + [largeInternal, 'session'], + ] as const) { + await verifyStreamedDownload(`${path}/content`, auth, bytes.length, expectedHash) + assert.equal((await request('HEAD', `${path}/content`, undefined, auth)).status, 200) + } + const initial = toRecord(json(await request('GET', `${largeDetail}/metadata`)).data) + const small = toRecord( + json( + await request('PUT', `${largeDetail}/content`, { + content: 'small replacement', + encoding: 'utf-8', + expectedRevision: initial.revision, + }) + ).data + ) + const statuses: number[] = [] + for (const [path, auth] of [ + [largeDetail, 'personal'], + [largeInternal, 'session'], + ] as const) { + await verifyStreamedDownload(`${path}/versions/1/content`, auth, bytes.length, expectedHash) + assert.equal( + (await request('HEAD', `${path}/versions/1/content`, undefined, auth)).status, + 200 + ) + statuses.push( + ( + await request( + 'POST', + `${path}/versions/1/revert`, + { expectedRevision: small.revision }, + auth + ) + ).status + ) + } + assert.deepEqual(statuses, [413, 413]) + assert.equal( + toRecord(json(await request('GET', `${largeDetail}/metadata`)).data).revision, + small.revision + ) + assert.equal((await request('GET', `${largeDetail}/content`)).text, 'small replacement') + } + ) +} finally { + if (ids.length) await request('POST', `${prefix}/archive`, { fileIds: ids }) + if (workspaceIds.length) + await request( + 'POST', + `/api/workspaces/${workspaceId}/files/bulk-archive`, + { fileIds: workspaceIds, folderIds: [] }, + 'session' + ) + for (const upload of pendingUploads) + await request('DELETE', `${internalPrefix}/uploads/${upload.id}`, undefined, 'session', { + 'upload-token': upload.token, + }) +} +process.stdout.write( + `${JSON.stringify({ passed: checks.filter((check) => check.passed).length, total: checks.length, report: reportPath })}\n` +) +process.exitCode = checks.every((check) => check.passed) ? 0 : 1 diff --git a/apps/sim/scripts/test-project-files-e2e.ts b/apps/sim/scripts/test-project-files-e2e.ts new file mode 100644 index 00000000000..984fa495cf1 --- /dev/null +++ b/apps/sim/scripts/test-project-files-e2e.ts @@ -0,0 +1,459 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { closeSync, openSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId, generateShortId } from '@sim/utils/id' +import { toRecord } from '@sim/utils/object' +import { makeSignature } from 'better-auth/crypto' +import postgres from 'postgres' + +/** Owns disposable infrastructure and prerequisite credentials for the existing real HTTP suites. */ +const logger = createLogger('ProjectFilesHttpE2E') +const required = (name: string) => { + const value = process.env[name] + assert.ok(value, `${name} is required`) + return value +} +function requiredString(value: unknown): string { + assert.ok(typeof value === 'string' && value.length > 0, 'Fixture response field is missing') + return value +} +const reportPath = resolve(required('PROJECT_FILES_E2E_REPORT_PATH')) +const reportDir = dirname(reportPath) +const adminUrl = new URL(required('PROJECT_FILES_E2E_ADMIN_DATABASE_URL')) +const base = new URL(required('PROJECT_FILES_E2E_BASE_URL')) +const relay = new URL(required('PROJECT_FILES_E2E_RELAY_URL')) +const redis = new URL(required('PROJECT_FILES_E2E_REDIS_URL')) +for (const url of [adminUrl, base, relay, redis]) { + assert.ok( + ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname), + 'Loopback services required' + ) +} +for (const url of [base, relay]) { + assert.equal(url.protocol, 'http:') + assert.ok(url.port && !url.username && !url.password && url.pathname === '/') +} +assert.notEqual(base.origin, relay.origin) +assert.ok(['postgres:', 'postgresql:'].includes(adminUrl.protocol)) +assert.equal(redis.protocol, 'redis:') +const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const rootDir = resolve(appDir, '../..') +const privateDir = await mkdtemp(join(tmpdir(), 'sim-project-files-http-')) +const databaseName = `sim_project_files_http_test_${generateId().replaceAll('-', '')}` +const databaseUrl = new URL(adminUrl) +databaseUrl.pathname = `/${databaseName}` +const authSecret = generateShortId(48) +const internalSecret = generateShortId(48) +const secrets = new Set([adminUrl.toString(), databaseUrl.toString(), authSecret, internalSecret]) +if (adminUrl.password) secrets.add(adminUrl.password) +if (redis.password) secrets.add(redis.password) +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +const logs: { source: string; target: string }[] = [] +const exits: { + name: string + code: number | null + signal: NodeJS.Signals | null + requestedStop: boolean + error?: string +}[] = [] +const processes: ReturnType[] = [] +const admin = postgres(adminUrl.toString(), { max: 1 }) +let sql: ReturnType | undefined +let createdDatabase = false +let migrated = false +let interrupted = false +const environment: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: process.env.HOME, + TMPDIR: process.env.TMPDIR, + SYSTEMROOT: process.env.SYSTEMROOT, + NODE_ENV: 'development', + __NEXT_PROCESSED_ENV: 'true', + DATABASE_URL: databaseUrl.toString(), + MIGRATION_DATABASE_URL: databaseUrl.toString(), + REDIS_URL: redis.toString(), + BETTER_AUTH_SECRET: authSecret, + INTERNAL_API_SECRET: internalSecret, + ENCRYPTION_KEY: '0'.repeat(64), + NEXT_PUBLIC_APP_URL: base.origin, + BETTER_AUTH_URL: base.origin, + INTERNAL_API_BASE_URL: base.origin, + SOCKET_SERVER_URL: relay.origin, + NEXT_PUBLIC_SOCKET_URL: relay.origin, + ALLOWED_ORIGINS: base.origin, + NEXT_PUBLIC_FORCE_HOSTED: 'true', + BILLING_ENABLED: 'true', + NEXT_PUBLIC_BILLING_ENABLED: 'true', + ENTERPRISE_ENABLED: 'true', + NEXT_PUBLIC_ENTERPRISE_ENABLED: 'true', + ACCESS_CONTROL_ENABLED: 'true', + NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: 'true', + ORGANIZATIONS_ENABLED: 'true', + NEXT_PUBLIC_ORGANIZATIONS_ENABLED: 'true', + SSO_ENABLED: 'true', + NEXT_PUBLIC_SSO_ENABLED: 'true', + PROJECTS: 'true', + STORAGE_PROVIDER: 'local', + EMAIL_VERIFICATION_ENABLED: 'false', + NEXT_PUBLIC_CHAT_DISABLED: 'true', + DISABLE_TELEMETRY: 'true', + NEXT_TELEMETRY_DISABLED: '1', + DB_TX_TRIPWIRE: 'throw', +} + +function redact(text: string) { + for (const secret of secrets) text = text.replaceAll(secret, '[redacted]') + return text +} + +async function saveReport() { + await mkdir(reportDir, { recursive: true }) + await writeFile( + reportPath, + redact(JSON.stringify({ databaseName, checks, exits, reportsDirectory: reportDir }, null, 2)) + ) +} + +async function check(name: string, action: () => Promise) { + const started = performance.now() + try { + await action() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + logger.info(`PASS ${name}`) + return true + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: redact(getErrorMessage(error)), + }) + logger.error(`FAIL ${name}`) + return false + } finally { + await saveReport() + } +} + +function start(name: string, command: string[], cwd: string, env = environment) { + const source = join(privateDir, `${name}.log`) + const descriptor = openSync(source, 'w', 0o600) + const child = spawn(command[0], command.slice(1), { + cwd, + env, + detached: true, + stdio: ['ignore', descriptor, descriptor], + }) + closeSync(descriptor) + const lifecycle = { requestedStop: false } + const done = new Promise((resolveExit) => { + child.once('error', (error) => { + exits.push({ + name, + code: null, + signal: null, + requestedStop: lifecycle.requestedStop, + error: getErrorMessage(error), + }) + resolveExit(-1) + }) + child.once('exit', (code, signal) => { + exits.push({ name, code, signal, requestedStop: lifecycle.requestedStop }) + resolveExit(code ?? -1) + }) + }) + logs.push({ source, target: join(reportDir, `${name}.log`) }) + return { child, done, lifecycle } +} + +async function stop(process: ReturnType) { + const { child, done } = process + if (child.exitCode === null && child.signalCode === null && child.pid) { + process.lifecycle.requestedStop = true + try { + globalThis.process.kill(-child.pid, 'SIGTERM') + } catch {} + for ( + let attempt = 0; + attempt < 100 && child.exitCode === null && child.signalCode === null; + attempt++ + ) + await sleep(100) + if (child.exitCode === null && child.signalCode === null) { + try { + globalThis.process.kill(-child.pid, 'SIGKILL') + } catch {} + } + } + await done +} + +async function run(name: string, command: string[], cwd: string, env = environment) { + assert.equal(interrupted, false, 'Acceptance run interrupted') + const child = start(name, command, cwd, env) + processes.push(child) + const timeout = setTimeout(() => { + if (child.child.pid) { + try { + process.kill(-child.child.pid, 'SIGKILL') + } catch {} + } + }, 10 * 60_000) + try { + assert.equal(await child.done, 0, `${name} failed; inspect ${name}.log`) + } finally { + clearTimeout(timeout) + await stop(child) + } +} + +async function ready(url: URL, path: string, child: ReturnType) { + const deadline = Date.now() + 300_000 + while (Date.now() < deadline) { + assert.equal(interrupted, false, 'Acceptance run interrupted') + assert.equal(child.child.exitCode, null, 'Service exited before becoming ready') + assert.equal(child.child.signalCode, null, 'Service stopped before becoming ready') + try { + // boundary-raw-fetch: the harness waits for its own loopback app and relay processes. + const response = await fetch(new URL(path, url), { signal: AbortSignal.timeout(5_000) }) + await response.body?.cancel() + if (response.ok) return + } catch {} + await sleep(1_000) + } + throw new Error('Local service readiness deadline exceeded') +} + +async function request(cookie: string, path: string, body: object, method = 'POST') { + // boundary-raw-fetch: fixture resources enter through the running application's actual authenticated APIs. + const response = await fetch(new URL(path, base), { + method, + headers: { Cookie: cookie, Origin: base.origin, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(90_000), + }) + assert.ok(response.ok, `${method} ${path} returned ${response.status}`) + return toRecord(await response.json()) +} + +async function seed(name: string) { + assert.ok(sql) + const directory = join(privateDir, name) + await mkdir(directory, { mode: 0o700 }) + const ownerId = generateId() + const token = generateShortId(48) + const expiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString() + const cookie = `better-auth.session_token=${encodeURIComponent(`${token}.${await makeSignature(token, authSecret)}`)}` + const personalKey = `sk-sim-http-${generateShortId(32)}` + const workspaceKey = `sk-sim-http-${generateShortId(32)}` + for (const value of [token, cookie, personalKey, workspaceKey]) secrets.add(value) + await sql.begin(async (tx) => { + const email = `${ownerId}@project-files-http.test` + await tx`insert into "user" (id,name,email,normalized_email,email_verified,created_at,updated_at) + values (${ownerId},'Project files HTTP owner',${email},${email},true,now(),now())` + await tx`insert into user_stats (id,user_id) values (${generateId()},${ownerId})` + await tx`insert into subscription (id,plan,reference_id,status) values (${generateId()},'pro',${ownerId},'active')` + await tx`insert into session (id,token,user_id,expires_at,created_at,updated_at) + values (${generateId()},${token},${ownerId},${expiresAt},now(),now())` + await tx`insert into api_key (id,user_id,name,key,key_hash,type) + values (${generateId()},${ownerId},'Disposable HTTP personal key',${personalKey},${sha256Hex(personalKey)},'personal')` + }) + const project = await request(cookie, '/api/projects', { + name: `HTTP ${name}`, + organizationId: null, + initialEnvironment: { name: 'Sandbox' }, + }) + const foreign = await request(cookie, '/api/projects', { + name: `HTTP ${name} foreign`, + organizationId: null, + initialEnvironment: { name: 'Sandbox' }, + }) + const projectId = requiredString(toRecord(project.project).id) + const workspaceId = requiredString(toRecord(project.initialEnvironment).id) + const foreignProjectId = requiredString(toRecord(foreign.project).id) + await sql`insert into api_key (id,user_id,workspace_id,name,key,key_hash,type) + values (${generateId()},${ownerId},${workspaceId},'Disposable HTTP workspace key',${workspaceKey},${sha256Hex(workspaceKey)},'workspace')` + for (const [filename, data] of [ + ['owner-account.json', { userId: ownerId, cookies: [cookie] }], + ['v2-fixture-keys.json', { personal: personalKey, workspace: workspaceKey }], + ['http-project-fixture.json', project], + ] as const) + await writeFile(join(directory, filename), JSON.stringify(data), { mode: 0o600 }) + return { directory, projectId, workspaceId, foreignProjectId, cookie } +} + +function interrupt() { + interrupted = true + void Promise.all(processes.map(stop)).catch(() => undefined) +} +process.once('SIGINT', interrupt) +process.once('SIGTERM', interrupt) + +const suites = [ + ['browser', 'PROJECT_FILE_BROWSER', 'test-project-file-browser-e2e.ts'], + ['history', 'PROJECT_FILE_HISTORY', 'test-project-file-history-e2e.ts'], + ['realtime', 'FILE_LIST_REALTIME', 'test-file-list-realtime-e2e.ts'], +] as const + +try { + assert.ok( + await check( + 'Create an exclusively owned disposable database and apply normal migrations', + async () => { + await admin.unsafe(`CREATE DATABASE "${databaseName}"`) + createdDatabase = true + await run( + 'project-files-migrate', + ['bun', '--no-env-file', 'scripts/migrate.ts'], + join(rootDir, 'packages/db') + ) + migrated = true + sql = postgres(databaseUrl.toString(), { max: 2 }) + } + ) + ) + assert.ok( + await check('Start the real app and relay with local-only runtime settings', async () => { + const app = start( + 'project-files-next', + [ + 'node', + join(rootDir, 'node_modules/next/dist/bin/next'), + 'dev', + '--hostname', + base.hostname, + '--port', + base.port, + ], + appDir + ) + processes.push(app) + await ready(base, '/api/health', app) + const socket = start( + 'project-files-relay', + ['bun', '--no-env-file', 'src/index.ts'], + join(rootDir, 'apps/realtime'), + { ...environment, PORT: relay.port, SIM_DB_ROLE: 'realtime', DB_APP_NAME: 'sim-realtime' } + ) + processes.push(socket) + await ready(relay, '/health', socket) + }) + ) + for (const [name, prefix, script] of suites) { + const selectedSuites = process.env.PROJECT_FILES_E2E_SUITES?.split(',') + if (selectedSuites && !selectedSuites.includes(name)) continue + assert.equal(interrupted, false, 'Acceptance run interrupted') + await check(`Existing ${name} acceptance suite`, async () => { + const fixture = await seed(name) + await run( + `project-files-${name}`, + ['bun', '--no-env-file', join('scripts', script)], + appDir, + { + ...environment, + [`${prefix}_BASE_URL`]: base.origin, + [`${prefix}_FIXTURE_DIR`]: fixture.directory, + [`${prefix}_REPORT_PATH`]: join(reportDir, `project-files-${name}.json`), + PROJECT_FILE_RENDERED_FOREIGN_PROJECT_ID: fixture.foreignProjectId, + PROJECT_FILE_PUBLIC_WORKSPACE_ID: fixture.workspaceId, + FILE_COPY_WORKSPACE_ID: fixture.workspaceId, + FILE_LIST_REALTIME_RELAY_URL: relay.origin, + } + ) + }) + } +} catch (error) { + checks.push({ + name: 'Orchestration stopped', + status: 'failed', + durationMs: 0, + error: redact(getErrorMessage(error)), + }) +} finally { + await check('Stop all owned child processes', async () => { + for (const child of [...processes].reverse()) await stop(child) + }) + if (sql && migrated) { + const database = sql + await check('Retain token redactions before retiring fixture records', async () => { + const credentials = await database<{ value: string }[]>` + select token as value from session + union all select token as value from public_share + union all select key as value from api_key` + for (const credential of credentials) secrets.add(credential.value) + }) + await check( + 'Remove only storage prefixes belonging to the owned disposable database', + async () => { + const owners = await database< + { id: string; type: string }[] + >`select id,'project' as type from project union all select id,'workspace' as type from workspace` + for (const owner of owners) { + assert.match(owner.id, /^[A-Za-z0-9_-]+$/) + await rm(join(appDir, 'uploads', owner.type, owner.id), { force: true, recursive: true }) + if (owner.type === 'workspace') + await rm(join(appDir, 'uploads', 'copilot-doc-compiled', owner.id), { + force: true, + recursive: true, + }) + } + } + ) + await check('Close fixture database connections', () => database.end()) + } + if (createdDatabase) + await check('Drop only the database successfully created by this run', async () => { + assert.match(databaseName, /^sim_project_files_http_test_[a-f0-9]{32}$/) + await admin.unsafe(`DROP DATABASE "${databaseName}" WITH (FORCE)`) + createdDatabase = false + }) + await check('Close administrative database connection', () => admin.end()) + await check('Publish redacted logs and remove private credentials', async () => { + const collect = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) await collect(path) + else if (entry.name.endsWith('.json')) { + const scan = (value: unknown, key = '') => { + if ( + typeof value === 'string' && + /^(cookies?|readerCookie|password|token|uploadToken|personal|workspace)$/i.test( + key + ) && + value.length > 12 + ) + secrets.add(value) + else if (Array.isArray(value)) for (const child of value) scan(child, key) + else if (value && typeof value === 'object') + for (const [name, child] of Object.entries(value)) scan(child, name) + } + scan(JSON.parse(await readFile(path, 'utf8'))) + } + } + } + await collect(privateDir) + for (const log of logs) await writeFile(log.target, redact(await readFile(log.source, 'utf8'))) + for (const [name] of suites) { + const path = join(reportDir, `project-files-${name}.json`) + try { + await writeFile(path, redact(await readFile(path, 'utf8'))) + } catch (error) { + if (toRecord(error).code !== 'ENOENT') throw error + } + } + await rm(privateDir, { force: true, recursive: true }) + }) + await saveReport() +} +process.removeListener('SIGINT', interrupt) +process.removeListener('SIGTERM', interrupt) +process.exitCode = checks.some((check) => check.status === 'failed') ? 1 : 0 diff --git a/knip.jsonc b/knip.jsonc index 15437fda438..d5b95f13062 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -48,6 +48,11 @@ // Generated contracts mirror their source of truth; regenerating them must not // trip the unused-export ratchet, and hand edits would be overwritten. "lib/mothership/generated/**": ["exports", "types", "duplicates"], + // API conventions require exported named wire schemas and aliases, including before client adoption. + "lib/api/contracts/projects.ts": ["exports", "types"], + "lib/api/contracts/project-file*.ts": ["exports", "types"], + "lib/api/contracts/v2/project-file*.ts": ["exports", "types"], + "lib/api/contracts/v2/uploads.ts": ["types"], "sandboxes/index.ts": ["files"], "components/mcp/index.ts": ["files"], "triggers/quickbooks/index.ts": ["files"], diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index c5341644749..ccd1973e3a9 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -4,6 +4,7 @@ export type Principal = | OAuthAccessTokenPrincipal | WorkspaceApiKeyPrincipal | DelegatedPrincipal + | ResourceDelegatedPrincipal | OrganizationDelegatedPrincipal | SystemPrincipal | CredentialGroupEnrollmentPrincipal @@ -186,6 +187,65 @@ export type DelegatedPrincipal = SubjectDelegatedPrincipal | WorkflowExecutionDe export type ResourceEntityType = 'workspace' | 'project' | 'organization' | 'user' +/** An owner grant may be narrowed to a single file, but never widened to another owner. */ +interface ResourceEntityScope { + kind: 'entity' + entityType: ResourceEntityType + entityId: string + fileId?: string +} + +/** Observing a Project collection grants no authority to address or mutate an individual file. */ +interface ResourceFileCollectionObservationScope { + kind: 'file_collection_observation' + entityType: 'project' + entityId: string +} + +/** One compound copy grant binds the exact requested source selection and destination. */ +export interface ResourceFileCopyScope { + kind: 'file_copy' + source: { + owner: { entityType: 'workspace' | 'project'; entityId: string } + fileIds: readonly string[] + folderIds: readonly string[] + } + destination: { + owner: { entityType: 'workspace' | 'project'; entityId: string } + folderId: string | null + } +} + +export type ResourceDelegationScope = + | { kind: 'project_discovery' } + | ResourceEntityScope + | ResourceFileCopyScope + | ResourceFileCollectionObservationScope + +interface ResourceDelegatedPrincipalBase { + kind: 'resource_delegated' + subjectUserId: string + delegationId: string + audience: string + issuedAt: Date + expiresAt: Date +} + +/** Human authority for entity-owned resources; never a workflow execution principal. */ +export type ResourceDelegatedPrincipal = ResourceDelegatedPrincipalBase & + ( + | { + serviceId: 'copilot' + scope: ResourceDelegationScope + invocation: { kind: 'chat'; chatId: string } | { kind: 'workspace'; workspaceId: string } + } + | { + serviceId: 'realtime' + scope: (ResourceEntityScope & { fileId: string }) | ResourceFileCollectionObservationScope + invocation: { kind: 'realtime'; connectionId: string } + } + ) + /** Search-only authority delegated by a current organization member. */ interface OrganizationDelegatedPrincipalBase { kind: 'organization_delegated' @@ -423,6 +483,8 @@ export function serializePrincipal(principal: WorkflowExecutionPrincipal): Seria expiresAt: principal.expiresAt.toISOString(), }, } + default: + throw new Error('Principal cannot be persisted for workflow execution') } } @@ -566,6 +628,8 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { : { resourceScope: parseResourceScope(principal.resourceScope) }), } } + case 'resource_delegated': + throw new Error('Resource delegation cannot be persisted for workflow execution') case 'credential_group_enrollment': throw new Error('Credential Group enrollment principals cannot be persisted for execution') default: @@ -575,6 +639,12 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { export type PrincipalActor = | Omit + | { + kind: 'resource_delegated' + serviceId: ResourceDelegatedPrincipal['serviceId'] + subjectUserId: string + delegationId: string + } | { kind: 'organization_delegated' serviceId: OrganizationDelegatedPrincipal['serviceId'] @@ -652,6 +722,7 @@ export function resolvePrincipalSubject(principal: Principal): PrincipalSubject case 'personal_api_key': case 'oauth_access_token': return { kind: 'sim_user', userId: principal.userId } + case 'resource_delegated': case 'organization_delegated': return { kind: 'sim_user', userId: principal.subjectUserId } case 'delegated': @@ -727,6 +798,13 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { ...(principal.subjectUserId ? { subjectUserId: principal.subjectUserId } : {}), delegationId: principal.delegationId, } + case 'resource_delegated': + return { + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + delegationId: principal.delegationId, + } case 'organization_delegated': return { kind: principal.kind, @@ -784,6 +862,7 @@ export function resolvePrincipalAuditAttribution(principal: Principal): Principa case 'personal_api_key': case 'oauth_access_token': return { actor, actorId: actor.userId } + case 'resource_delegated': case 'organization_delegated': return { actor, actorId: actor.subjectUserId } case 'delegated': @@ -830,6 +909,7 @@ export function resolvePrincipalAttribution( } case 'system': throw new Error('System principals do not support user attribution') + case 'resource_delegated': case 'organization_delegated': return { actor, attributedUserId: actor.subjectUserId } case 'delegated': { diff --git a/packages/db/package.json b/packages/db/package.json index 97d0f50f215..3e03ca45314 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -32,6 +32,10 @@ "./testing/test-infrastructure": { "types": "./testing/test-infrastructure.ts", "default": "./testing/test-infrastructure.ts" + }, + "./testing/workspace-fixtures": { + "types": "./testing/workspace-fixtures.ts", + "default": "./testing/workspace-fixtures.ts" } }, "scripts": { diff --git a/packages/platform-authz/src/room-policy.ts b/packages/platform-authz/src/room-policy.ts index ba91920c852..6ac7f1146ea 100644 --- a/packages/platform-authz/src/room-policy.ts +++ b/packages/platform-authz/src/room-policy.ts @@ -13,17 +13,18 @@ import { isPermissionType, type PermissionType, permissionSatisfies } from './pr */ /** - * A file-doc room IS the collaborative editor, so occupying it requires `write`; - * every other room carries only reads/presence and requires `read`. Workflow is + * Rooms carry reads and presence; document content changes require a separate write check. + * Workflow is * included for the sweep's benefit even though it authorizes through its own path * (`authorizeWorkflowByWorkspacePermission`). */ export const ROOM_MEMBERSHIP_ACTIONS = { [ROOM_TYPES.WORKFLOW]: 'read', [ROOM_TYPES.WORKSPACE_FILES]: 'read', + [ROOM_TYPES.PROJECT_FILES]: 'read', [ROOM_TYPES.WORKSPACE_TABLES]: 'read', [ROOM_TYPES.WORKSPACE_WORKFLOWS]: 'read', - [ROOM_TYPES.WORKSPACE_FILE_DOC]: 'write', + [ROOM_TYPES.WORKSPACE_FILE_DOC]: 'read', [ROOM_TYPES.PROJECT_FILE_DOC]: 'read', [ROOM_TYPES.TABLE]: 'read', } as const satisfies Record diff --git a/packages/realtime-protocol/package.json b/packages/realtime-protocol/package.json index ce02b6f46b0..a698bb54ea1 100644 --- a/packages/realtime-protocol/package.json +++ b/packages/realtime-protocol/package.json @@ -33,6 +33,10 @@ "./table-presence": { "types": "./src/table-presence.ts", "default": "./src/table-presence.ts" + }, + "./file-doc-target": { + "types": "./src/file-doc-target.ts", + "default": "./src/file-doc-target.ts" } }, "scripts": { diff --git a/packages/realtime-protocol/src/file-doc-target.ts b/packages/realtime-protocol/src/file-doc-target.ts new file mode 100644 index 00000000000..114cc5b8574 --- /dev/null +++ b/packages/realtime-protocol/src/file-doc-target.ts @@ -0,0 +1,101 @@ +import { + projectFileDocRoom, + projectFileDocTarget, + ROOM_TYPES, + type RoomRef, +} from '@sim/realtime-protocol/rooms' + +/** An asserted owner; the relay still resolves current access through the owning application. */ +export interface FileDocOwner { + entityType: 'workspace' | 'project' + entityId: string +} + +/** A document target with explicit ownership; asserted ownership still requires authorization. */ +export interface FileDocTarget { + fileId: string + owner: FileDocOwner +} + +/** Incoming workspace messages may omit the owner until canonical authorization resolves it. */ +interface FileDocWireTarget { + fileId: string + owner?: FileDocOwner +} + +interface OwnerCodec { + room(entityId: string, fileId: string): RoomRef + admission(entityId: string, fileId: string): string +} + +const OWNER_CODECS: Record = { + workspace: { + room: (_entityId, fileId) => ({ type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: fileId }), + admission: (_entityId, fileId) => `file-doc-admission:${fileId}`, + }, + project: { + room: projectFileDocRoom, + admission: (entityId, fileId) => `file-doc-admission:${entityId}/${fileId}`, + }, +} + +/** Normalize old wire hints once; unknown owners and conflicting assertions never fall back. */ +export function parseFileDocTarget(input: { + fileId?: unknown + owner?: unknown + projectId?: unknown +}): FileDocWireTarget | null { + if (typeof input.fileId !== 'string' || !input.fileId) return null + const { fileId } = input + const candidate = + input.owner ?? + (input.projectId === undefined + ? undefined + : { entityType: 'project', entityId: input.projectId }) + if (input.owner === null) return null + if (candidate === undefined) return { fileId } + if (typeof candidate !== 'object' || candidate === null) return null + const owner = candidate as Partial + if ( + typeof owner.entityType !== 'string' || + !Object.hasOwn(OWNER_CODECS, owner.entityType) || + typeof owner.entityId !== 'string' || + !owner.entityId || + owner.entityId.length > 200 || + owner.entityId !== owner.entityId.trim() || + /[/:]/.test(owner.entityId) || + /[/:]/.test(fileId) || + (input.projectId !== undefined && + (owner.entityType !== 'project' || owner.entityId !== input.projectId)) + ) + return null + return { fileId, owner: { entityType: owner.entityType, entityId: owner.entityId } } +} + +/** Encode owner-qualified targets without renaming deployed Socket.IO or Redis addresses. */ +export function fileDocRoom(target: FileDocWireTarget): RoomRef { + return target.owner + ? OWNER_CODECS[target.owner.entityType].room(target.owner.entityId, target.fileId) + : { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: target.fileId } +} + +/** Pending admissions use the same compatibility codec as their live document. */ +export function fileDocAdmissionRoom(target: FileDocWireTarget): string { + return target.owner + ? OWNER_CODECS[target.owner.entityType].admission(target.owner.entityId, target.fileId) + : `file-doc-admission:${target.fileId}` +} + +/** Workspace ownership is resolved after authorization because legacy room names omit it. */ +export function fileDocTargetFromRoom(room: RoomRef): FileDocWireTarget | null { + if (room.type === ROOM_TYPES.WORKSPACE_FILE_DOC) return { fileId: room.id } + const project = projectFileDocTarget(room) + return project + ? { fileId: project.fileId, owner: { entityType: 'project', entityId: project.projectId } } + : null +} + +/** Include explicit ownership while retaining the Project hint understood by older relays. */ +export function fileDocOwnerWireFields(owner: FileDocOwner) { + return { owner, ...(owner.entityType === 'project' ? { projectId: owner.entityId } : {}) } +} diff --git a/packages/realtime-protocol/src/file-doc.ts b/packages/realtime-protocol/src/file-doc.ts index f5243104889..6f7e977bc27 100644 --- a/packages/realtime-protocol/src/file-doc.ts +++ b/packages/realtime-protocol/src/file-doc.ts @@ -1,3 +1,5 @@ +import type { FileDocOwner } from '@sim/realtime-protocol/file-doc-target' + /** * Wire protocol for the collaborative file-document room * ({@link ROOM_TYPES.WORKSPACE_FILE_DOC}). Live carets and text selection ride @@ -148,6 +150,7 @@ export const FILE_DOC_LIMITS = { /** Client → server join request. `fileId` is the `workspace_files.id`. */ export interface JoinFileDocPayload { + owner?: FileDocOwner projectId?: string fileId: string /** @@ -163,6 +166,7 @@ export interface JoinFileDocPayload { /** Server → client acceptance of a {@link FILE_DOC_EVENTS.JOIN}. */ export interface JoinFileDocSuccess { canWrite?: boolean + owner?: FileDocOwner projectId?: string fileId: string /** The provider whose join was accepted. Optional while older relays are still deployed. */ @@ -194,6 +198,7 @@ export interface JoinFileDocError { /** Client → server leave request. */ export interface LeaveFileDocPayload { + owner?: FileDocOwner projectId?: string fileId: string } @@ -210,6 +215,7 @@ export interface FileDocInvalidated { /** A bounded, retry-safe batch of user-authored changes. */ export interface FileDocUpdatePayload { + owner?: FileDocOwner projectId?: string fileId: string docId: string @@ -270,7 +276,8 @@ export const FILE_DOC_INTERNAL_HEADERS = { /** Current write capability for a subscribed Project document. */ export interface FileDocPermission { - projectId: string + owner?: FileDocOwner + projectId?: string fileId: string canWrite: boolean } diff --git a/packages/realtime-protocol/src/rooms.ts b/packages/realtime-protocol/src/rooms.ts index 6a00246d7a7..53b4eb663a8 100644 --- a/packages/realtime-protocol/src/rooms.ts +++ b/packages/realtime-protocol/src/rooms.ts @@ -22,6 +22,7 @@ export const ROOM_TYPES = { WORKFLOW: 'workflow', /** The workspace file browser (one room per workspace). */ WORKSPACE_FILES: 'workspace-files', + PROJECT_FILES: 'project-files', /** * A single collaborative file document — the rich-text editor for one file * (one room per file). Carries Yjs document sync + awareness (live carets and @@ -61,17 +62,27 @@ export type RoomType = (typeof ROOM_TYPES)[keyof typeof ROOM_TYPES] /** Every known room type, for exhaustive iteration/validation. */ const ALL_ROOM_TYPES = Object.values(ROOM_TYPES) as readonly RoomType[] -/** - * The presence-free, workspace-scoped live-list rooms. They share one contract derived entirely - * from the room-type token: clients join via `join-${type}`, the app server fans a mutation out via - * `POST /api/${type}-changed`, and members receive a lossy `${type}-changed` invalidation signal. - * Adding a room type here wires it into the shared socket handler and HTTP relay branch. - */ -export const WORKSPACE_LIST_ROOM_TYPES = [ - ROOM_TYPES.WORKSPACE_FILES, - ROOM_TYPES.WORKSPACE_TABLES, - ROOM_TYPES.WORKSPACE_WORKFLOWS, -] as const +/** Owner-specific wire addresses share the same presence-free invalidation lifecycle. */ +const INVALIDATION_ROOM_ID_KEYS = { + [ROOM_TYPES.WORKSPACE_FILES]: 'workspaceId', + [ROOM_TYPES.WORKSPACE_TABLES]: 'workspaceId', + [ROOM_TYPES.WORKSPACE_WORKFLOWS]: 'workspaceId', + [ROOM_TYPES.PROJECT_FILES]: 'projectId', +} as const + +export type InvalidationRoomType = keyof typeof INVALIDATION_ROOM_ID_KEYS + +export const INVALIDATION_ROOM_TYPES = Object.keys( + INVALIDATION_ROOM_ID_KEYS +) as InvalidationRoomType[] + +/** Legacy workspace callers keep their payload key; unsupported rooms never inherit it. */ +export function invalidationRoomIdKey(type: RoomType): 'workspaceId' | 'projectId' { + if (!Object.hasOwn(INVALIDATION_ROOM_ID_KEYS, type)) { + throw new Error('Room does not support list invalidation') + } + return INVALIDATION_ROOM_ID_KEYS[type as InvalidationRoomType] +} /** Universal address of a realtime room. */ export interface RoomRef { @@ -79,6 +90,26 @@ export interface RoomRef { id: string } +const ROOM_AUTHORIZATION_OWNERS = { + [ROOM_TYPES.WORKFLOW]: 'workspace', + [ROOM_TYPES.WORKSPACE_FILES]: 'workspace', + [ROOM_TYPES.WORKSPACE_TABLES]: 'workspace', + [ROOM_TYPES.WORKSPACE_WORKFLOWS]: 'workspace', + [ROOM_TYPES.WORKSPACE_FILE_DOC]: 'workspace', + [ROOM_TYPES.TABLE]: 'workspace', + [ROOM_TYPES.PROJECT_FILE_DOC]: 'project', + [ROOM_TYPES.PROJECT_FILES]: 'project', +} as const satisfies Record + +export type ProjectRoomRef = RoomRef & { + type: typeof ROOM_TYPES.PROJECT_FILE_DOC | typeof ROOM_TYPES.PROJECT_FILES +} + +/** Every room declares the authority that both admission and revalidation must consult. */ +export function isProjectRoom(room: RoomRef): room is ProjectRoomRef { + return ROOM_AUTHORIZATION_OWNERS[room.type] === 'project' +} + /** Type guard: whether an arbitrary string is a known {@link RoomType}. */ function isRoomType(value: string): value is RoomType { return (ALL_ROOM_TYPES as readonly string[]).includes(value) diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 7012a541606..72685874fcb 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -7,7 +7,14 @@ import { pipeline } from 'node:stream/promises' import type { Command } from 'commander' import { writeStdout } from '#sim-cli/output/io' import { embedStore } from '../../embed-context' -import { isRequestTimeout, RAISE_TIMEOUT_HINT, SimApiError } from '../../http/client' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { + isRequestTimeout, + RAISE_TIMEOUT_HINT, + type RequestOptions, + SimApiError, +} from '../../http/client' +import { describeOperation } from '../../runtime/build' import { apiCommand, type Connection } from '../../runtime/called-operations' import { printProtocolResult } from './result' @@ -281,17 +288,23 @@ interface DownloadOutputOptions { force?: boolean } -type DownloadOperation = 'downloadFile' | 'downloadFileVersion' +type DownloadOperation = + | 'downloadFile' + | 'downloadFileVersion' + | 'readProjectFileContent' + | 'readProjectFileVersionContent' /** * Streams a binary v2 download to stdout or atomically to `--output-file`. Shared by every * command that downloads file bytes, so each gets the same terminal guard and overwrite rules. */ async function downloadToOutput( - connectDownload: () => Connection, + connect: () => Connection, operation: Operation, - params: Record, - options: DownloadOutputOptions + pathParams: Record, + options: DownloadOutputOptions, + scope: 'workspace' | 'owner', + requestInput: Pick = {} ): Promise { const target = options.outputFile const writesToStdout = target === undefined || target === '-' @@ -299,11 +312,11 @@ async function downloadToOutput( throw new SimApiError('--force requires --output-file ', 0) } - const { client, profile } = connectDownload() - const workspaceId = client.requireWorkspace() + const { client, profile } = connect() const response = await client.requestRaw(operation, { - params, - query: { workspaceId }, + params: pathParams, + ...requestInput, + ...(scope === 'workspace' ? { query: { workspaceId: client.requireWorkspace() } } : {}), }) if (!response.body) { throw new SimApiError('File content response was empty.', response.status) @@ -328,28 +341,28 @@ async function downloadToOutput( const savedTarget = await saveToFile(response.body, target, Boolean(options.force)) printProtocolResult(profile.output, { - id: params.fileId, + ...(pathParams.fileId ? { id: pathParams.fileId } : {}), path: savedTarget, status: 'saved', }) } export function attachFileGet(files: Command): void { - const [get, connectGet] = apiCommand(files, 'get', ['downloadFile']) - get + const [command, connect] = apiCommand(files, 'get', ['downloadFile']) + command .argument('', 'File whose content to read') .allowExcessArguments(false) .description('Download a file’s content to stdout or a local file') .option('-o, --output-file ', 'Write content to a file instead of stdout') .option('--force', 'Overwrite --output-file if it already exists') .action((fileId: string, options: DownloadOutputOptions) => - downloadToOutput(connectGet, 'downloadFile', { fileId }, options) + downloadToOutput(connect, 'downloadFile', { fileId }, options, 'workspace') ) } export function attachFileVersionDownload(versions: Command): void { - const [download, connectDownload] = apiCommand(versions, 'download', ['downloadFileVersion']) - download + const [command, connect] = apiCommand(versions, 'download', ['downloadFileVersion']) + command .argument('', 'File identifier.') .argument('', 'Version number.') .allowExcessArguments(false) @@ -357,6 +370,52 @@ export function attachFileVersionDownload(versions: Command): void { .option('-o, --output-file ', 'Write content to a file instead of stdout') .option('--force', 'Overwrite --output-file if it already exists') .action((fileId: string, version: string, options: DownloadOutputOptions) => - downloadToOutput(connectDownload, 'downloadFileVersion', { fileId, version }, options) + downloadToOutput(connect, 'downloadFileVersion', { fileId, version }, options, 'workspace') + ) +} + +/** Project source downloads share transfer protections without selecting a workspace. */ +export function attachProjectFileSource(files: Command): void { + const [command, connect] = apiCommand(files, 'source', ['readProjectFileContent']) + command + .argument('', 'Project that owns the file') + .argument('', 'File whose stored source to read') + .allowExcessArguments(false) + .description( + describeOperation( + V2_OPERATIONS.readProjectFileContent, + 'Download a Project file’s stored source to stdout or a local file' + ) + ) + .option('-o, --output-file ', 'Write content to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action((projectId: string, fileId: string, options: DownloadOutputOptions) => + downloadToOutput(connect, 'readProjectFileContent', { projectId, fileId }, options, 'owner') + ) +} + +export function attachProjectFileVersionSource(versions: Command): void { + const [command, connect] = apiCommand(versions, 'source', ['readProjectFileVersionContent']) + command + .argument('', 'Project that owns the file') + .argument('', 'File identifier.') + .argument('', 'Version number.') + .allowExcessArguments(false) + .description( + describeOperation( + V2_OPERATIONS.readProjectFileVersionContent, + 'Download a Project file version’s stored source to stdout or a local file' + ) + ) + .option('-o, --output-file ', 'Write content to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action((projectId: string, fileId: string, version: string, options: DownloadOutputOptions) => + downloadToOutput( + connect, + 'readProjectFileVersionContent', + { projectId, fileId, version }, + options, + 'owner' + ) ) } diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index 3c9cf421dec..b7e41e0f413 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -1,70 +1,102 @@ import type { Command } from 'commander' -import type { CompleteFileUploadResponse, CreateFileUploadResponse } from '../../generated/v2-api' -import { apiCommand } from '../../runtime/called-operations' +import type { + CompleteFileUploadResponse, + CompleteProjectFileUploadResponse, + CreateFileUploadResponse, + CreateProjectFileUploadResponse, +} from '../../generated/v2-api' +import { apiCommand, type Connection } from '../../runtime/called-operations' import { encodeFolderPath } from '../../runtime/request' import { contentTypeFor, localFile } from '../../transfer/local-file' import { finishUploadSession } from '../../transfer/upload-session' import { printProtocolResult } from './result' +interface FileUploadOptions { + folder?: string + name?: string +} + +const WORKSPACE_UPLOAD_OPERATIONS = [ + 'createFileUpload', + 'createFileUploadPartUrls', + 'completeFileUpload', + 'abortFileUpload', +] as const +const PROJECT_UPLOAD_OPERATIONS = [ + 'createProjectFileUpload', + 'getProjectFileUploadPartUrls', + 'completeProjectFileUpload', + 'abortProjectFileUpload', +] as const + +type UploadOperation = + | (typeof WORKSPACE_UPLOAD_OPERATIONS)[number] + | (typeof PROJECT_UPLOAD_OPERATIONS)[number] + +async function uploadFile( + connect: () => Connection, + path: string, + options: FileUploadOptions, + operations: readonly [Operation, Operation, Operation, Operation], + projectId?: string +): Promise { + const { client, profile } = connect() + const workspaceId = projectId ? undefined : client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + const created = await client.request( + operations[0], + { + params: projectId ? { projectId } : undefined, + body: { + ...(workspaceId ? { workspaceId } : {}), + name, + contentType: contentTypeFor(name), + size, + ...(options.folder !== undefined ? { folderPath: encodeFolderPath(options.folder) } : {}), + }, + } + ) + const { session, uploadToken, transfer } = created.data + const completed: CompleteFileUploadResponse['data'] | CompleteProjectFileUploadResponse['data'] = + await finishUploadSession( + client, + workspaceId, + { + operations: { parts: operations[1], complete: operations[2], abort: operations[3] }, + params: { uploadId: session.id, ...(projectId ? { projectId } : {}) }, + uploadToken, + transfer, + size, + }, + path + ) + if (!completed.file) throw new Error(`File upload ${session.id} completed without a file`) + printProtocolResult(profile.output, completed.file) +} + export function attachFileUpload(files: Command): void { - const [upload, connectUpload] = apiCommand(files, 'upload', [ - 'createFileUpload', - 'createFileUploadPartUrls', - 'completeFileUpload', - 'abortFileUpload', - ]) + const [upload, connect] = apiCommand(files, 'upload', WORKSPACE_UPLOAD_OPERATIONS) upload .argument('', 'Local file to upload') .allowExcessArguments(false) .description('Upload a file to the workspace') .option('--folder ', 'Folder path as shown in the app; defaults to the root folder') .option('--name ', 'Store it under a different name') - .action(async (path: string, options: { folder?: string; name?: string }) => { - const { client, profile } = connectUpload() - const workspaceId = client.requireWorkspace() - const { name, size } = await localFile(path, options.name) - - const created = await client.request('createFileUpload', { - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - // `` above is a LOCAL file and must stay untouched; only the - // destination folder is a wire-encoded API path. - ...(options.folder !== undefined ? { folderPath: encodeFolderPath(options.folder) } : {}), - }, - }) - const { session, uploadToken, transfer } = created.data - const completed: CompleteFileUploadResponse['data'] = await finishUploadSession( - client, - workspaceId, - { - operations: { - parts: 'createFileUploadPartUrls', - complete: 'completeFileUpload', - abort: 'abortFileUpload', - }, - params: { uploadId: session.id }, - uploadToken, - transfer, - size, - }, - path - ) + .action((path: string, options: FileUploadOptions) => + uploadFile(connect, path, options, WORKSPACE_UPLOAD_OPERATIONS) + ) +} - if (!completed.file) { - throw new Error(`File upload ${session.id} completed without a file`) - } - /** - * The file record, and nothing about the session that carried it. - * - * The session is over by the time this line runs — completed on success, - * aborted on failure — so its id names nothing a caller can go on to ask - * about, and its token is a live credential that also authorizes - * aborting and completing the transfer. This command runs in CI, where - * stdout is retained and broadly readable; neither belongs in it. - */ - printProtocolResult(profile.output, completed.file) - }) +export function attachProjectFileUpload(files: Command): void { + const [upload, connect] = apiCommand(files, 'upload', PROJECT_UPLOAD_OPERATIONS) + upload + .argument('', 'Project that will own the file') + .argument('', 'Local file to upload') + .allowExcessArguments(false) + .description('Upload a shared Project file') + .option('--folder ', 'Folder path as shown in the app; defaults to the Project root') + .option('--name ', 'Store it under a different name') + .action((projectId: string, path: string, options: FileUploadOptions) => + uploadFile(connect, path, options, PROJECT_UPLOAD_OPERATIONS, projectId) + ) } diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 0b2544745db..db2a27dff8a 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -1,7 +1,12 @@ import { Command } from 'commander' import { attachChat } from './chat' -import { attachFileGet, attachFileVersionDownload } from './files-get' -import { attachFileUpload } from './files-upload' +import { + attachFileGet, + attachFileVersionDownload, + attachProjectFileSource, + attachProjectFileVersionSource, +} from './files-get' +import { attachFileUpload, attachProjectFileUpload } from './files-upload' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload' import { attachKnowledgeExport } from './knowledge-export' import { attachLogsFollow } from './logs-follow' @@ -22,6 +27,10 @@ function group(program: Command, name: string): Command { /** Attaches commands whose multi-request or binary protocols cannot be generated. */ export function attachProtocolCommands(program: Command): void { + const projectFiles = group(group(program, 'projects'), 'files') + attachProjectFileUpload(projectFiles) + attachProjectFileSource(projectFiles) + attachProjectFileVersionSource(group(projectFiles, 'versions')) const files = group(program, 'files') attachFileUpload(files) attachFileGet(files) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c55bbbd0658..1d6e415cbb3 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -20,6 +20,14 @@ const ACCESS_REQUEST_COLUMNS: ColumnSpec[] = [ { header: 'created', path: 'createdAt', format: 'timestamp' }, ] +const FILE_SHARE_FIELDS: ColumnSpec[] = [ + { header: 'shared', path: 'isActive', format: 'bool' }, + { header: 'URL', path: 'url' }, + { header: 'auth', path: 'authType' }, + { header: 'password set', path: 'hasPassword', format: 'bool' }, + { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, +] + const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = 'Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull' @@ -117,6 +125,39 @@ const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'parent', path: 'parentPath', format: 'folder-path' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ] +const FILE_VERSION_COLUMNS: ColumnSpec[] = [ + { header: 'version' }, + { header: 'current', path: 'isCurrent', format: 'bool' }, + { header: 'source' }, + { header: 'size', format: 'bytes' }, + { header: 'authors', format: 'people' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + { header: 'superseded', path: 'supersededAt', format: 'timestamp' }, +] +const FILE_VERSION_FIELDS: ColumnSpec[] = [ + { header: 'file', path: 'fileId' }, + { header: 'version' }, + { header: 'current', path: 'isCurrent', format: 'bool' }, + { header: 'source' }, + { header: 'restored from', path: 'restoredFromVersion' }, + { header: 'size', format: 'bytes' }, + { header: 'type', path: 'contentType' }, + { header: 'authors', format: 'people' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + { header: 'superseded', path: 'supersededAt', format: 'timestamp' }, +] +const FILE_REVERT_FIELDS: ColumnSpec[] = [ + { header: 'reverted', format: 'bool' }, + { header: 'file', path: 'file.id' }, + { header: 'name', path: 'file.name' }, + { header: 'version', path: 'version.version' }, + { header: 'source', path: 'version.source' }, + { header: 'restored from', path: 'version.restoredFromVersion' }, + { header: 'size', path: 'version.size', format: 'bytes' }, + { header: 'authors', path: 'version.authors', format: 'people' }, + { header: 'created', path: 'version.createdAt', format: 'timestamp' }, +] function moveResource(command: string, resource: string): CommandVariantSpec { return { @@ -754,6 +795,41 @@ export const CLI_CONTRACT: CliContract = { }, }, }, + renameProjectFile: { command: 'projects files rename' }, + listProjectFileFolders: { + command: 'projects files folders list', + columns: [{ header: 'id' }, { header: 'name' }, { header: 'parent', path: 'parentId' }], + }, + createProjectFileFolder: { command: 'projects files folders create' }, + updateProjectFileFolder: { command: 'projects files folders update' }, + restoreProjectFileFolder: { command: 'projects files folders restore' }, + moveProjectFileItems: { + command: 'projects files move', + flags: { + fileIds: { list: true }, + folderIds: { list: true }, + targetFolderPath: TARGET_FOLDER_PATH_FLAG, + }, + }, + archiveProjectFileItems: { + command: 'projects files archive', + confirm: 'This archives the selected Project files and folders, including folder contents.', + flags: { fileIds: { list: true }, folderIds: { list: true } }, + }, + restoreProjectFile: { command: 'projects files restore' }, + createProjectFile: { + command: 'projects files create', + flags: { folderPath: FOLDER_PATH_FLAG }, + }, + updateProjectFileContent: { + command: 'projects files set-content', + describe: 'Replace a shared Project file’s contents', + flags: { encoding: { choices: ['utf-8', 'base64'], describe: 'Content encoding' } }, + }, + readProjectFileContent: { + command: 'projects files source', + describe: 'Download the stored source bytes of a Project file', + }, createKnowledgeBase: { flags: { folderPath: FOLDER_PATH_FLAG } }, updateKnowledgeBase: { variants: [moveResource('knowledge mv', 'knowledge base')], @@ -897,6 +973,26 @@ export const CLI_CONTRACT: CliContract = { { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, ], }, + listProjectFiles: { + command: 'projects files list', + flags: { + folderPath: FOLDER_PATH_FLAG, + recursive: { boolean: true, negatable: true }, + }, + columns: [ + { header: 'id' }, + { header: 'name' }, + FOLDER_COLUMN, + { header: 'size', format: 'bytes' }, + { header: 'type' }, + { header: 'creator', path: 'uploadedBy' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + ], + }, + getProjectFileMetadata: { + command: 'projects files describe', + describe: 'Show Project file metadata and ownership', + }, listTableRows: { expand: 'data' }, listKnowledgeBases: { flags: { folderPath: FOLDER_PATH_FLAG }, @@ -1497,32 +1593,12 @@ export const CLI_CONTRACT: CliContract = { listFileVersions: { command: 'files versions list', describe: 'List the recorded versions of a file', - columns: [ - { header: 'version' }, - { header: 'current', path: 'isCurrent', format: 'bool' }, - { header: 'source' }, - { header: 'size', format: 'bytes' }, - { header: 'authors', format: 'people' }, - { header: 'created', path: 'createdAt', format: 'timestamp' }, - { header: 'superseded', path: 'supersededAt', format: 'timestamp' }, - ], + columns: FILE_VERSION_COLUMNS, }, getFileVersion: { command: 'files versions describe', describe: 'Show the metadata of one version of a file', - fields: [ - { header: 'file', path: 'fileId' }, - { header: 'version' }, - { header: 'current', path: 'isCurrent', format: 'bool' }, - { header: 'source' }, - { header: 'restored from', path: 'restoredFromVersion' }, - { header: 'size', format: 'bytes' }, - { header: 'type', path: 'contentType' }, - { header: 'authors', format: 'people' }, - { header: 'created', path: 'createdAt', format: 'timestamp' }, - { header: 'updated', path: 'updatedAt', format: 'timestamp' }, - { header: 'superseded', path: 'supersededAt', format: 'timestamp' }, - ], + fields: FILE_VERSION_FIELDS, }, readFileVersionText: { command: 'files versions read', @@ -1533,17 +1609,7 @@ export const CLI_CONTRACT: CliContract = { revertFileVersion: { command: 'files versions revert', describe: 'Make a previous version of a file current again', - fields: [ - { header: 'reverted', format: 'bool' }, - { header: 'file', path: 'file.id' }, - { header: 'name', path: 'file.name' }, - { header: 'version', path: 'version.version' }, - { header: 'source', path: 'version.source' }, - { header: 'restored from', path: 'version.restoredFromVersion' }, - { header: 'size', path: 'version.size', format: 'bytes' }, - { header: 'authors', path: 'version.authors', format: 'people' }, - { header: 'created', path: 'version.createdAt', format: 'timestamp' }, - ], + fields: FILE_REVERT_FIELDS, }, deleteFileVersion: { command: 'files versions delete', @@ -1551,6 +1617,30 @@ export const CLI_CONTRACT: CliContract = { confirm: 'This permanently removes the version from history and schedules deletion of its stored content; storage cleanup may retry in the background.', }, + listProjectFileVersions: { + command: 'projects files versions list', + describe: 'List the recorded versions of a shared Project file', + columns: FILE_VERSION_COLUMNS, + }, + getProjectFileVersion: { + command: 'projects files versions describe', + describe: 'Show the metadata of one version of a shared Project file', + fields: FILE_VERSION_FIELDS, + }, + readProjectFileVersionContent: { + command: 'projects files versions source', + describe: 'Download the stored source bytes of a Project file version', + }, + revertProjectFileVersion: { + command: 'projects files versions revert', + describe: 'Make a previous version of a shared Project file current again', + fields: FILE_REVERT_FIELDS, + }, + deleteProjectFileVersion: { + command: 'projects files versions delete', + describe: 'Permanently delete a previous version of a shared Project file', + confirm: 'This permanently deletes the version and its stored content.', + }, // Left to derive, the folder restore lands under `files restore` and turns // that leaf back into a group holding a lone `create` — the exact shape the // rename above exists to remove. It belongs beside the other folder verbs. @@ -1670,13 +1760,7 @@ export const CLI_CONTRACT: CliContract = { getFileShare: { command: 'files share get', describe: 'Show a file’s share settings', - fields: [ - { header: 'shared', path: 'isActive', format: 'bool' }, - { header: 'URL', path: 'url' }, - { header: 'auth', path: 'authType' }, - { header: 'password set', path: 'hasPassword', format: 'bool' }, - { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, - ], + fields: FILE_SHARE_FIELDS, }, // v2 folds share and unshare into one PATCH; `--is-active false` disables it, // so there is no separate unshare operation to expose. @@ -1686,13 +1770,7 @@ export const CLI_CONTRACT: CliContract = { flags: { allowedEmails: { list: true }, }, - fields: [ - { header: 'shared', path: 'isActive', format: 'bool' }, - { header: 'URL', path: 'url' }, - { header: 'auth', path: 'authType' }, - { header: 'password set', path: 'hasPassword', format: 'bool' }, - { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, - ], + fields: FILE_SHARE_FIELDS, }, /** @@ -2160,6 +2238,11 @@ export const CLI_CONTRACT: CliContract = { // advertise a protocol whose halfway states leak storage, so `sim files // upload` drives the whole sequence and these stay out of the surface. createFileUpload: { hidden: true }, + createProjectFileUpload: { hidden: true }, + getProjectFileUpload: { hidden: true }, + abortProjectFileUpload: { hidden: true }, + completeProjectFileUpload: { hidden: true }, + getProjectFileUploadPartUrls: { hidden: true }, createFileUploadPartUrls: { hidden: true }, completeFileUpload: { hidden: true }, abortFileUpload: { hidden: true }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 303687312e4..e59ef1d56a5 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -112,6 +112,59 @@ export type AbortKnowledgeDocumentUploadResponse = { data: AbortKnowledgeDocumentUploadResponseRef1 } +/** `DELETE /api/v2/projects/[projectId]/files/uploads/[uploadId]` */ +export type AbortProjectFileUploadParams = { + projectId: string + uploadId: string +} + +export type AbortProjectFileUploadQuery = Record + +export type AbortProjectFileUploadHeaders = { + 'upload-token': string +} + +type AbortProjectFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type AbortProjectFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: AbortProjectFileUploadResponseRef0 | null +} + +export type AbortProjectFileUploadResponse = { + data: AbortProjectFileUploadResponseRef1 +} + /** `POST /api/v2/workflows/[workflowId]/versions/[version]/activate` */ export type ActivateWorkflowVersionParams = { version: number @@ -669,6 +722,31 @@ export type ApplyWorkflowVariablesResponse = { data: ApplyWorkflowVariablesResponseRef0 } +/** `POST /api/v2/projects/[projectId]/files/archive` */ +export type ArchiveProjectFileItemsParams = { + projectId: string +} + +export type ArchiveProjectFileItemsQuery = Record + +export type ArchiveProjectFileItemsBody = { + fileIds?: Array + folderIds?: Array +} + +export type ArchiveProjectFileItemsResponse = { + data: { + deletedItems: { + files: number + folders: number + } + affectedIds: { + fileIds: Array + folderIds: Array + } + } +} + /** `POST /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk` */ export type BulkAddPermissionGroupMembersParams = { organizationId: string @@ -1625,6 +1703,59 @@ export type CompleteKnowledgeDocumentUploadResponse = { data: CompleteKnowledgeDocumentUploadResponseRef1 } +/** `POST /api/v2/projects/[projectId]/files/uploads/[uploadId]/complete` */ +export type CompleteProjectFileUploadParams = { + projectId: string + uploadId: string +} + +export type CompleteProjectFileUploadQuery = Record + +export type CompleteProjectFileUploadHeaders = { + 'upload-token': string +} + +type CompleteProjectFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type CompleteProjectFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: CompleteProjectFileUploadResponseRef0 | null +} + +export type CompleteProjectFileUploadResponse = { + data: CompleteProjectFileUploadResponseRef1 +} + /** `POST /api/v2/tables/imports/[importId]/complete` */ export type CompleteTableImportParams = { importId: string @@ -2684,6 +2815,152 @@ export type CreatePermissionGroupResponse = { data: CreatePermissionGroupResponseRef0 } +/** `POST /api/v2/projects/[projectId]/files` */ +export type CreateProjectFileParams = { + projectId: string +} + +export type CreateProjectFileQuery = Record + +type CreateProjectFileBodyRef0 = string + +export type CreateProjectFileBody = { + name: string + contentType?: string + content?: string + encoding?: 'utf-8' | 'base64' + folderPath?: CreateProjectFileBodyRef0 +} + +type CreateProjectFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type CreateProjectFileResponse = { + data: CreateProjectFileResponseRef0 +} + +/** `POST /api/v2/projects/[projectId]/files/folders` */ +export type CreateProjectFileFolderParams = { + projectId: string +} + +export type CreateProjectFileFolderQuery = Record + +export type CreateProjectFileFolderBody = { + name: string + parentId?: string | null +} + +export type CreateProjectFileFolderResponse = { + data: { + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'project' + entityId: string + } + } +} + +/** `POST /api/v2/projects/[projectId]/files/uploads` */ +export type CreateProjectFileUploadParams = { + projectId: string +} + +export type CreateProjectFileUploadQuery = Record + +type CreateProjectFileUploadBodyRef0 = string + +export type CreateProjectFileUploadBody = { + name: string + contentType: string + size: number + folderPath?: CreateProjectFileUploadBodyRef0 + folderId?: string | null +} + +type CreateProjectFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type CreateProjectFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: CreateProjectFileUploadResponseRef0 | null +} + +type CreateProjectFileUploadResponseRef2 = { + method: 'put' + url: string + headers: Record + expiresAt: string +} + +type CreateProjectFileUploadResponseRef3 = { + method: 'multipart' + partSize: number + partCount: number +} + +type CreateProjectFileUploadResponseRef4 = { + session: CreateProjectFileUploadResponseRef1 + uploadToken: string + transfer: CreateProjectFileUploadResponseRef2 | CreateProjectFileUploadResponseRef3 +} + +export type CreateProjectFileUploadResponse = { + data: CreateProjectFileUploadResponseRef4 +} + /** `POST /api/v2/sandboxes` */ export type CreateSandboxQuery = Record @@ -4020,6 +4297,23 @@ export type DeletePermissionGroupResponse = { data: DeletePermissionGroupResponseRef0 } +/** `DELETE /api/v2/projects/[projectId]/files/[fileId]/versions/[version]` */ +export type DeleteProjectFileVersionParams = { + projectId: string + fileId: string + version: number +} + +export type DeleteProjectFileVersionQuery = Record + +export type DeleteProjectFileVersionResponse = { + data: { + fileId: string + version: number + deleted: true + } +} + /** `DELETE /api/v2/sandboxes/[sandboxId]` */ export type DeleteSandboxParams = { sandboxId: string @@ -6272,6 +6566,152 @@ export type GetPermissionGroupResponse = { data: GetPermissionGroupResponseRef0 } +/** `GET /api/v2/projects/[projectId]/files/[fileId]/metadata` */ +export type GetProjectFileMetadataParams = { + projectId: string + fileId: string +} + +export type GetProjectFileMetadataQuery = Record + +type GetProjectFileMetadataResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type GetProjectFileMetadataResponse = { + data: GetProjectFileMetadataResponseRef0 +} + +/** `GET /api/v2/projects/[projectId]/files/uploads/[uploadId]` */ +export type GetProjectFileUploadParams = { + projectId: string + uploadId: string +} + +export type GetProjectFileUploadQuery = Record + +export type GetProjectFileUploadHeaders = { + 'upload-token': string +} + +type GetProjectFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type GetProjectFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: GetProjectFileUploadResponseRef0 | null +} + +export type GetProjectFileUploadResponse = { + data: GetProjectFileUploadResponseRef1 +} + +/** `POST /api/v2/projects/[projectId]/files/uploads/[uploadId]/parts` */ +export type GetProjectFileUploadPartUrlsParams = { + projectId: string + uploadId: string +} + +export type GetProjectFileUploadPartUrlsQuery = Record + +export type GetProjectFileUploadPartUrlsBody = { + partNumbers: Array +} + +export type GetProjectFileUploadPartUrlsHeaders = { + 'upload-token': string +} + +type GetProjectFileUploadPartUrlsResponseRef0 = { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +type GetProjectFileUploadPartUrlsResponseRef1 = { + parts: Array +} + +export type GetProjectFileUploadPartUrlsResponse = { + data: GetProjectFileUploadPartUrlsResponseRef1 +} + +/** `GET /api/v2/projects/[projectId]/files/[fileId]/versions/[version]` */ +export type GetProjectFileVersionParams = { + projectId: string + fileId: string + version: number +} + +export type GetProjectFileVersionQuery = Record + +type GetProjectFileVersionResponseRef0 = GetProjectFileVersionResponseRef1 + +type GetProjectFileVersionResponseRef1 = { + fileId: string + version: number + isCurrent: boolean + size: number + contentType: string + source: 'upload' | 'user' | 'api' | 'copilot' | 'workflow' | 'collab' | 'revert' | 'unknown' + authors: Array<{ + id: string + email: string | null + }> + restoredFromVersion: number | null + createdAt: string + updatedAt: string + supersededAt: string | null +} + +export type GetProjectFileVersionResponse = { + data: GetProjectFileVersionResponseRef0 +} + /** `GET /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ export type GetRowEnrichmentParams = { tableId: string @@ -9192,8 +9632,126 @@ type ListPermissionGroupsResponseRef0 = { updatedAt: string } -export type ListPermissionGroupsResponse = { - data: Array +export type ListPermissionGroupsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/projects/[projectId]/files/folders` */ +export type ListProjectFileFoldersParams = { + projectId: string +} + +export type ListProjectFileFoldersQuery = { + scope?: 'active' | 'archived' | 'all' +} + +export type ListProjectFileFoldersResponse = { + data: Array<{ + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'project' + entityId: string + } + }> + nextCursor: string | null +} + +/** `GET /api/v2/projects/[projectId]/files` */ +export type ListProjectFilesParams = { + projectId: string +} + +type ListProjectFilesQueryRef0 = string + +export type ListProjectFilesQuery = { + folderPath?: ListProjectFilesQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' + scope?: 'active' | 'archived' + search?: string + sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListProjectFilesResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type ListProjectFilesResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/projects/[projectId]/files/[fileId]/versions` */ +export type ListProjectFileVersionsParams = { + projectId: string + fileId: string +} + +export type ListProjectFileVersionsQuery = { + sortBy?: 'version' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListProjectFileVersionsResponseRef0 = ListProjectFileVersionsResponseRef1 + +type ListProjectFileVersionsResponseRef1 = { + fileId: string + version: number + isCurrent: boolean + size: number + contentType: string + source: 'upload' | 'user' | 'api' | 'copilot' | 'workflow' | 'collab' | 'revert' | 'unknown' + authors: Array<{ + id: string + email: string | null + }> + restoredFromVersion: number | null + createdAt: string + updatedAt: string + supersededAt: string | null +} + +export type ListProjectFileVersionsResponse = { + data: Array nextCursor: string | null } @@ -10112,6 +10670,30 @@ export type MoveFileItemsResponse = { data: MoveFileItemsResponseRef0 } +/** `POST /api/v2/projects/[projectId]/files/move` */ +export type MoveProjectFileItemsParams = { + projectId: string +} + +export type MoveProjectFileItemsQuery = Record + +type MoveProjectFileItemsBodyRef0 = string + +export type MoveProjectFileItemsBody = { + fileIds?: Array + folderIds?: Array + targetFolderPath?: MoveProjectFileItemsBodyRef0 +} + +export type MoveProjectFileItemsResponse = { + data: { + movedFiles: number + movedFolders: number + movedFileIds: Array + movedFolderIds: Array + } +} + /** `POST /api/v2/tables/move` */ export type MoveTablesQuery = Record @@ -11510,6 +12092,29 @@ export type ReadFileVersionTextResponse = { data: ReadFileVersionTextResponseRef0 } +/** `GET /api/v2/projects/[projectId]/files/[fileId]/content` */ +export type ReadProjectFileContentParams = { + projectId: string + fileId: string +} + +export type ReadProjectFileContentQuery = Record + +/** Non-JSON response (`binary`). */ +export type ReadProjectFileContentResponse = never + +/** `GET /api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content` */ +export type ReadProjectFileVersionContentParams = { + projectId: string + fileId: string + version: number +} + +export type ReadProjectFileVersionContentQuery = Record + +/** Non-JSON response (`binary`). */ +export type ReadProjectFileVersionContentResponse = never + /** `PATCH /api/v2/files/folders` */ export type RelocateFileFolderQuery = Record @@ -11708,6 +12313,40 @@ export type RenameFileResponse = { data: RenameFileResponseRef0 } +/** `PATCH /api/v2/projects/[projectId]/files/[fileId]` */ +export type RenameProjectFileParams = { + projectId: string + fileId: string +} + +export type RenameProjectFileQuery = Record + +export type RenameProjectFileBody = { + name: string +} + +type RenameProjectFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type RenameProjectFileResponse = { + data: RenameProjectFileResponseRef0 +} + /** `PUT /api/v2/workflows/[workflowId]/deployments/chat` */ export type ReplaceWorkflowChatDeploymentParams = { workflowId: string @@ -12202,6 +12841,72 @@ export type RestoreKnowledgeBaseResponse = { data: RestoreKnowledgeBaseResponseRef1 } +/** `POST /api/v2/projects/[projectId]/files/[fileId]/restore` */ +export type RestoreProjectFileParams = { + projectId: string + fileId: string +} + +export type RestoreProjectFileQuery = Record + +export type RestoreProjectFileBody = Record + +type RestoreProjectFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type RestoreProjectFileResponse = { + data: RestoreProjectFileResponseRef0 +} + +/** `POST /api/v2/projects/[projectId]/files/folders/[folderId]/restore` */ +export type RestoreProjectFileFolderParams = { + projectId: string + folderId: string +} + +export type RestoreProjectFileFolderQuery = Record + +export type RestoreProjectFileFolderBody = Record + +export type RestoreProjectFileFolderResponse = { + data: { + folder: { + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'project' + entityId: string + } + } + restoredItems: { + files: number + folders: number + } + } +} + /** `POST /api/v2/tables/[tableId]/restore` */ export type RestoreTableParams = { tableId: string @@ -12427,6 +13132,66 @@ export type RevertFileVersionResponse = { data: RevertFileVersionResponseRef2 } +/** `POST /api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert` */ +export type RevertProjectFileVersionParams = { + projectId: string + fileId: string + version: number +} + +export type RevertProjectFileVersionQuery = Record + +export type RevertProjectFileVersionBody = { + expectedCurrentVersion?: number + expectedRevision?: string +} + +type RevertProjectFileVersionResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type RevertProjectFileVersionResponseRef1 = RevertProjectFileVersionResponseRef2 + +type RevertProjectFileVersionResponseRef2 = { + fileId: string + version: number + isCurrent: boolean + size: number + contentType: string + source: 'upload' | 'user' | 'api' | 'copilot' | 'workflow' | 'collab' | 'revert' | 'unknown' + authors: Array<{ + id: string + email: string | null + }> + restoredFromVersion: number | null + createdAt: string + updatedAt: string + supersededAt: string | null +} + +export type RevertProjectFileVersionResponse = { + data: { + reverted: boolean + file: RevertProjectFileVersionResponseRef0 + version: RevertProjectFileVersionResponseRef1 + revision?: string + } +} + /** `POST /api/v2/workflows/[workflowId]/versions/[version]/revert` */ export type RevertWorkflowVersionParams = { version: number | 'active' @@ -13689,6 +14454,74 @@ export type UpdatePermissionGroupResponse = { data: UpdatePermissionGroupResponseRef0 } +/** `PUT /api/v2/projects/[projectId]/files/[fileId]/content` */ +export type UpdateProjectFileContentParams = { + projectId: string + fileId: string +} + +export type UpdateProjectFileContentQuery = Record + +export type UpdateProjectFileContentBody = { + content: string + encoding?: 'utf-8' | 'base64' + expectedRevision?: string +} + +type UpdateProjectFileContentResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type UpdateProjectFileContentResponse = { + data: UpdateProjectFileContentResponseRef0 +} + +/** `PATCH /api/v2/projects/[projectId]/files/folders/[folderId]` */ +export type UpdateProjectFileFolderParams = { + projectId: string + folderId: string +} + +export type UpdateProjectFileFolderQuery = Record + +export type UpdateProjectFileFolderBody = { + name?: string + parentId?: string | null + sortOrder?: number +} + +export type UpdateProjectFileFolderResponse = { + data: { + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'project' + entityId: string + } + } +} + /** `PATCH /api/v2/tables/[tableId]/rows` */ export type UpdateRowsByFilterParams = { tableId: string @@ -14697,6 +15530,25 @@ export const V2_OPERATIONS = { }, }, }, + abortProjectFileUpload: { + method: 'DELETE', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', + pathParams: ['projectId', 'uploadId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + uploadId: 'Upload session identifier within the Project.', + }, + responseMode: 'json', + summary: 'Abort Project file upload', + workspaceKeyUnsupported: true, + headers: { + 'upload-token': { + kind: 'string', + required: true, + describe: 'Signed upload control token returned when the upload session was created.', + }, + }, + }, activateWorkflowVersion: { method: 'POST', path: '/api/v2/workflows/[workflowId]/versions/[version]/activate', @@ -14856,6 +15708,24 @@ export const V2_OPERATIONS = { }, }, }, + archiveProjectFileItems: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/archive', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Archive Project File Items', + workspaceKeyUnsupported: true, + body: { + fileIds: { kind: 'array', default: [], describe: 'Identifiers of the files to archive.' }, + folderIds: { + kind: 'array', + default: [], + describe: + 'Identifiers of folders to archive recursively, including their files and descendants.', + }, + }, + }, bulkAddPermissionGroupMembers: { method: 'POST', path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk', @@ -15237,6 +16107,25 @@ export const V2_OPERATIONS = { }, }, }, + completeProjectFileUpload: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete', + pathParams: ['projectId', 'uploadId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + uploadId: 'Upload session identifier within the Project.', + }, + responseMode: 'json', + summary: 'Complete Project file upload', + workspaceKeyUnsupported: true, + headers: { + 'upload-token': { + kind: 'string', + required: true, + describe: 'Signed upload control token returned when the upload session was created.', + }, + }, + }, completeTableImport: { method: 'POST', path: '/api/v2/tables/imports/[importId]/complete', @@ -15758,6 +16647,82 @@ export const V2_OPERATIONS = { }, }, }, + createProjectFile: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Create Project File', + workspaceKeyUnsupported: true, + body: { + name: { + kind: 'string', + required: true, + describe: + 'File name, including its extension. Path separators and dot segments are rejected.', + }, + contentType: { + kind: 'string', + describe: 'MIME type. When omitted, it is inferred from the file extension.', + }, + content: { + kind: 'string', + default: '', + describe: + 'Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 70 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The decoded-file limit is 50 MiB. Use an upload session for larger payloads.', + }, + encoding: { + kind: 'enum', + values: ['utf-8', 'base64'] as const, + default: 'utf-8', + describe: 'Encoding of the content field.', + }, + folderPath: { + kind: 'string', + describe: 'Canonical containing-folder path. Omit for the Project root.', + }, + }, + }, + createProjectFileFolder: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/folders', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Create Project file folder', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', required: true, describe: 'Name for the new folder.' }, + parentId: { + kind: 'string', + describe: 'Parent folder identifier; omit or use null for the root.', + }, + }, + }, + createProjectFileUpload: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Create Project file upload', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', required: true, describe: 'File name, including its extension.' }, + contentType: { kind: 'string', required: true, describe: 'MIME type of the uploaded file.' }, + size: { kind: 'integer', required: true, describe: 'Exact file size in bytes.' }, + folderPath: { + kind: 'string', + describe: + 'Canonical destination folder path. Specify either folderId or folderPath, not both.', + }, + folderId: { + kind: 'string', + describe: 'Destination folder identifier; omit or use null for the Project root.', + }, + }, + }, createSandbox: { method: 'POST', path: '/api/v2/sandboxes', @@ -16396,6 +17361,19 @@ export const V2_OPERATIONS = { summary: 'Delete permission group', workspaceKeyUnsupported: true, }, + deleteProjectFileVersion: { + method: 'DELETE', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]', + pathParams: ['projectId', 'fileId', 'version'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + version: 'Version number.', + }, + responseMode: 'json', + summary: 'Delete Project File Version', + workspaceKeyUnsupported: true, + }, deleteSandbox: { method: 'DELETE', path: '/api/v2/sandboxes/[sandboxId]', @@ -17510,6 +18488,76 @@ export const V2_OPERATIONS = { summary: 'Get permission group', workspaceKeyUnsupported: true, }, + getProjectFileMetadata: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/metadata', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Get Project File Metadata', + workspaceKeyUnsupported: true, + }, + getProjectFileUpload: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', + pathParams: ['projectId', 'uploadId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + uploadId: 'Upload session identifier within the Project.', + }, + responseMode: 'json', + summary: 'Get Project file upload', + workspaceKeyUnsupported: true, + headers: { + 'upload-token': { + kind: 'string', + required: true, + describe: 'Signed upload control token returned when the upload session was created.', + }, + }, + }, + getProjectFileUploadPartUrls: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts', + pathParams: ['projectId', 'uploadId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + uploadId: 'Upload session identifier within the Project.', + }, + responseMode: 'json', + summary: 'Get Project file upload part URLs', + workspaceKeyUnsupported: true, + body: { + partNumbers: { + kind: 'array', + required: true, + describe: 'Multipart part numbers for which signed URLs should be created.', + }, + }, + headers: { + 'upload-token': { + kind: 'string', + required: true, + describe: 'Signed upload control token returned when the upload session was created.', + }, + }, + }, + getProjectFileVersion: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]', + pathParams: ['projectId', 'fileId', 'version'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + version: 'Version number.', + }, + responseMode: 'json', + summary: 'Get Project File Version', + workspaceKeyUnsupported: true, + }, getRowEnrichment: { method: 'GET', path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', @@ -19651,6 +20699,129 @@ export const V2_OPERATIONS = { }, }, }, + listProjectFileFolders: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/folders', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'List Project file folders', + workspaceKeyUnsupported: true, + query: { + scope: { + kind: 'enum', + values: ['active', 'archived', 'all'] as const, + default: 'active', + describe: 'Folder lifecycle scope.', + }, + }, + }, + listProjectFiles: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'List Project Files', + workspaceKeyUnsupported: true, + query: { + folderPath: { + kind: 'string', + describe: + 'Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.', + }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + describe: + 'Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.', + }, + scope: { + kind: 'enum', + values: ['active', 'archived'] as const, + default: 'active', + describe: + 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', + }, + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the file name.', + }, + sortBy: { + kind: 'enum', + values: ['name', 'size', 'uploadedAt', 'updatedAt'] as const, + default: 'uploadedAt', + describe: + 'Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 100, + describe: 'Maximum files per page. Must be a whole number from 1 to 1000. Defaults to 100.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + }, + }, + listProjectFileVersions: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'List Project File Versions', + workspaceKeyUnsupported: true, + query: { + sortBy: { + kind: 'enum', + values: ['version'] as const, + default: 'version', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'desc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + }, + }, listSandboxes: { method: 'GET', path: '/api/v2/sandboxes', @@ -20585,6 +21756,28 @@ export const V2_OPERATIONS = { }, }, }, + moveProjectFileItems: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/move', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Move Project File Items', + workspaceKeyUnsupported: true, + body: { + fileIds: { kind: 'array', default: [], describe: 'Identifiers of the files to move.' }, + folderIds: { + kind: 'array', + default: [], + describe: 'Identifiers of folders to move with their contents.', + }, + targetFolderPath: { + kind: 'string', + describe: + 'Existing destination folder path within the Project. Omit to move items to the Project root.', + }, + }, + }, moveTables: { method: 'POST', path: '/api/v2/tables/move', @@ -20991,6 +22184,31 @@ export const V2_OPERATIONS = { }, }, }, + readProjectFileContent: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/content', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'binary', + summary: 'Read Project File Source', + workspaceKeyUnsupported: true, + }, + readProjectFileVersionContent: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content', + pathParams: ['projectId', 'fileId', 'version'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + version: 'Version number.', + }, + responseMode: 'binary', + summary: 'Read Project File Version Content', + workspaceKeyUnsupported: true, + }, relocateFileFolder: { method: 'PATCH', path: '/api/v2/files/folders', @@ -21128,6 +22346,21 @@ export const V2_OPERATIONS = { name: { kind: 'string', required: true, describe: 'New file name, including its extension.' }, }, }, + renameProjectFile: { + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Rename Project File', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', required: true, describe: 'New file name, including its extension.' }, + }, + }, replaceWorkflowChatDeployment: { method: 'PUT', path: '/api/v2/workflows/[workflowId]/deployments/chat', @@ -21350,6 +22583,32 @@ export const V2_OPERATIONS = { }, }, }, + restoreProjectFile: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/restore', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Restore Project File', + workspaceKeyUnsupported: true, + body: {}, + }, + restoreProjectFileFolder: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/folders/[folderId]/restore', + pathParams: ['projectId', 'folderId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + folderId: 'Folder identifier within the Project.', + }, + responseMode: 'json', + summary: 'Restore Project file folder', + workspaceKeyUnsupported: true, + body: {}, + }, restoreTable: { method: 'POST', path: '/api/v2/tables/[tableId]/restore', @@ -21428,6 +22687,31 @@ export const V2_OPERATIONS = { }, }, }, + revertProjectFileVersion: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert', + pathParams: ['projectId', 'fileId', 'version'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + version: 'Version number.', + }, + responseMode: 'json', + summary: 'Revert Project File Version', + workspaceKeyUnsupported: true, + body: { + expectedCurrentVersion: { + kind: 'integer', + describe: + 'Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content.', + }, + expectedRevision: { + kind: 'string', + describe: + 'Revert only while the file still holds the content this revision names, as returned by Get file metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version.', + }, + }, + }, revertWorkflowVersion: { method: 'POST', path: '/api/v2/workflows/[workflowId]/versions/[version]/revert', @@ -22502,6 +23786,58 @@ export const V2_OPERATIONS = { }, }, }, + updateProjectFileContent: { + method: 'PUT', + path: '/api/v2/projects/[projectId]/files/[fileId]/content', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Replace Project File Content', + workspaceKeyUnsupported: true, + body: { + content: { + kind: 'string', + required: true, + describe: + 'Complete replacement content for the file. The complete JSON request is capped at 70 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The decoded-file limit is 50 MiB.', + }, + encoding: { + kind: 'enum', + values: ['utf-8', 'base64'] as const, + default: 'utf-8', + describe: 'Encoding of the content field.', + }, + expectedRevision: { + kind: 'string', + describe: + 'Revision from Get file metadata or an earlier write; the request is refused with `409` when the content moved on.', + }, + }, + }, + updateProjectFileFolder: { + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/folders/[folderId]', + pathParams: ['projectId', 'folderId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + folderId: 'Folder identifier within the Project.', + }, + responseMode: 'json', + summary: 'Update Project file folder', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', describe: 'New folder name; omit to leave unchanged.' }, + parentId: { + kind: 'string', + describe: + 'New parent folder identifier; null moves to the root, omission leaves the parent unchanged.', + }, + sortOrder: { kind: 'integer', describe: 'New manual position; omit to leave unchanged.' }, + }, + }, updateRowsByFilter: { method: 'PATCH', path: '/api/v2/tables/[tableId]/rows', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 4cf4279cc4f..9a56cba9541 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -402,6 +402,7 @@ describe('destructive operations are gated', () => { 'activateWorkflowVersion', 'applyWorkflowOperations', 'applyWorkflowVariables', + 'archiveProjectFileItems', 'bulkDeleteFiles', 'bulkDeleteTables', 'bulkUpdateKnowledgeChunks', @@ -417,6 +418,18 @@ describe('destructive operations are gated', () => { * decision on anything new. */ const NON_DESTRUCTIVE = new Set([ + 'completeProjectFileUpload', + 'createProjectFile', + 'createProjectFileFolder', + 'createProjectFileUpload', + 'getProjectFileUploadPartUrls', + 'moveProjectFileItems', + 'renameProjectFile', + 'restoreProjectFile', + 'restoreProjectFileFolder', + 'revertProjectFileVersion', + 'updateProjectFileContent', + 'updateProjectFileFolder', /** These amend access or request history without discarding a resource. */ 'cancelOrganizationAccessRequest', 'cancelWorkspaceAccessRequest', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index f76860b1d4d..01e8a36f167 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -456,6 +456,21 @@ describe('single-resource rendering', () => { }) describe('pagination slot', () => { + it('fetches the complete Project file inventory by default', async () => { + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'first' }], nextCursor: 'next' }) + .mockResolvedValueOnce({ data: [{ id: 'last' }], nextCursor: null }) + const printed: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => printed.push(line)) + await program().parseAsync(['node', 'sim', 'projects', 'files', 'list', 'project-1']) + expect(mockRequest).toHaveBeenCalledTimes(2) + expect(JSON.parse(printed.join('\n'))).toEqual({ + data: [{ id: 'first' }, { id: 'last' }], + nextCursor: null, + }) + }) + it.each([ { argv: ['files', 'list'], cursors: ['c1', 'c1'] }, { argv: ['files', 'list'], cursors: ['c1', 'c2', 'c1'] }, diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 4397175fcdb..7cddeaab597 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -131,6 +131,13 @@ export const BULK_OUTCOME_CHECKS: Readonly { + const items = payload.deletedItems as { files?: unknown; folders?: unknown } | undefined + if (countOf(items?.files) + countOf(items?.folders) > 0) return null + const requested = lengthOf(body?.fileIds) + lengthOf(body?.folderIds) + if (requested === 0) return null + return `Archived nothing: none of the ${requested} requested ${requested === 1 ? 'item was' : 'items were'} archived.` + }, /** * `added` empty with `failed` populated is a call that indexed nothing. An * empty request — no file resolved to a reference at all — is not a failure, diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index fe6d5b7ba07..13b1d443a78 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -28,6 +28,7 @@ const COMPLETE_LIST_OPERATIONS: ReadonlySet = new Set([ 'listOrganizationMembers', 'listOrganizationWorkspaces', 'listFiles', + 'listProjectFiles', 'listKnowledgeBases', 'listKnowledgeConnectors', 'listMcpServers', diff --git a/packages/sim-cli/src/transfer/upload-session.ts b/packages/sim-cli/src/transfer/upload-session.ts index 2f477cbfe23..cd1cda95ee7 100644 --- a/packages/sim-cli/src/transfer/upload-session.ts +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -67,7 +67,7 @@ async function uploadBytes( async function uploadParts( client: OperationClient, - workspaceId: string, + workspaceId: string | undefined, session: UploadSession, transfer: Extract, file: Blob | StreamingUpload @@ -95,7 +95,7 @@ async function uploadParts( session.operations.parts, { params: session.params, - query: { workspaceId }, + query: workspaceId ? { workspaceId } : undefined, headers: { 'upload-token': session.uploadToken }, body: { partNumbers }, } @@ -126,7 +126,7 @@ async function uploadParts( /** Uploads and completes a signed transfer, aborting its session if the transfer fails. */ export async function finishUploadSession( client: OperationClient, - workspaceId: string, + workspaceId: string | undefined, session: UploadSession, path: string ): Promise { @@ -171,7 +171,7 @@ export async function finishUploadSession( const completed = await client.request<{ data: T }>(session.operations.complete, { params: session.params, - query: { workspaceId }, + query: workspaceId ? { workspaceId } : undefined, headers: { 'upload-token': session.uploadToken }, }) return completed.data @@ -185,7 +185,7 @@ export async function finishUploadSession( await cleanupClient .request(session.operations.abort, { params: session.params, - query: { workspaceId }, + query: workspaceId ? { workspaceId } : undefined, headers: { 'upload-token': session.uploadToken }, }) .catch(() => undefined) diff --git a/packages/testing/src/mocks/workspace-authorization.mock.ts b/packages/testing/src/mocks/workspace-authorization.mock.ts index 5b067eb371d..c94f99e1f67 100644 --- a/packages/testing/src/mocks/workspace-authorization.mock.ts +++ b/packages/testing/src/mocks/workspace-authorization.mock.ts @@ -120,6 +120,7 @@ export const workspaceAuthorizationMockFns = { case 'personal_api_key': case 'oauth_access_token': return principal.userId ?? null + case 'resource_delegated': case 'organization_delegated': return principal.subjectUserId ?? null case 'delegated': diff --git a/packages/utils/src/client-info.ts b/packages/utils/src/client-info.ts index 8e747a6ac4a..f3154f197c1 100644 --- a/packages/utils/src/client-info.ts +++ b/packages/utils/src/client-info.ts @@ -254,6 +254,7 @@ export function resolveClientInfo( const INTERNAL_AUTH_KINDS: ReadonlySet = new Set([ 'internal_jwt', 'delegated', + 'resource_delegated', 'organization_delegated', 'system', ]) diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 1be6c06f6d4..250973e12fc 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -43,6 +43,7 @@ import path from 'node:path' import Ajv2020 from 'ajv/dist/2020' import { z } from 'zod' import { OPENAPI_SPEC_FILES } from '../apps/docs/lib/openapi-specs' +import { unpublishedProjectFileDocumentation } from '../apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files' const ROOT = path.resolve(import.meta.dir, '..') const DOCS_DIR = path.join(ROOT, 'apps/docs') @@ -66,6 +67,12 @@ const SPEC_FILES = OPENAPI_SPEC_FILES * transfer is published on `transfer.url` in `contracts/v2/uploads.ts`. */ const UNDOCUMENTED_V2_ROUTES: Readonly> = { + ...Object.fromEntries( + unpublishedProjectFileDocumentation.routes.map(({ contract }) => [ + `${contract.method} ${contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}`, + unpublishedProjectFileDocumentation.reason, + ]) + ), 'POST /api/v2/chat': 'Conversational surface behind `sim chat`. The contract describes the JSON answer, but the CLI consumes the same endpoint as an NDJSON progress stream negotiated by Accept header — a protocol `checkV2Conventions` cannot describe as a published operation. Deliberately unpublished while the surface is CLI-only; document it when the JSON shape is committed to as public API.', 'PUT /api/v2/uploads/{uploadId}': diff --git a/scripts/check-source-conventions.baseline.json b/scripts/check-source-conventions.baseline.json index ab5c946bd16..ca3bcffbbc6 100644 --- a/scripts/check-source-conventions.baseline.json +++ b/scripts/check-source-conventions.baseline.json @@ -283,7 +283,6 @@ "apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts": 1, "apps/sim/lib/uploads/contexts/workspace/file-manager-errors.test.ts": 1, "apps/sim/lib/uploads/contexts/workspace/file-manager.test.ts": 1, - "apps/sim/lib/uploads/contexts/workspace/file-manager.ts": 1, "apps/sim/lib/uploads/core/storage-client.ts": 1, "apps/sim/lib/webhooks/providers/table.ts": 1, "apps/sim/lib/webhooks/providers/whatsapp.test.ts": 1, diff --git a/scripts/check-test-types.baseline.json b/scripts/check-test-types.baseline.json index 8a52a4a2912..e656a8d0318 100644 --- a/scripts/check-test-types.baseline.json +++ b/scripts/check-test-types.baseline.json @@ -319,9 +319,6 @@ "TS2345": 2, "TS2554": 1 }, - "apps/sim/app/api/users/me/settings/route.test.ts": { - "TS2554": 2 - }, "apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts": { "TS2554": 1 }, @@ -493,9 +490,6 @@ "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts": { "TS2578": 2 }, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx": { - "TS2741": 1 - }, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-lifecycle.test.tsx": { "TS2550": 1 }, @@ -981,16 +975,9 @@ "apps/sim/lib/api/server/routes/definition.test.ts": { "TS2741": 1 }, - "apps/sim/lib/api/server/routes/internal-json-route.test.ts": { - "TS2741": 16 - }, "apps/sim/lib/api/server/routes/scim-route.test.ts": { "TS2322": 2 }, - "apps/sim/lib/api/server/routes/v2-binary-route.test.ts": { - "TS2322": 1, - "TS2741": 3 - }, "apps/sim/lib/api/server/routes/v2-body-lifecycle-route.test.ts": { "TS18046": 3, "TS2322": 2, diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index d0bac8d0d6f..bc2959655b4 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -6,68 +6,68 @@ }, "entries": { "app/api/v2/blocks/[blockId]/route.ts": { - "modules": 1800, + "modules": 1838, "gateways": { - "apps/sim/lib/api/server/routes/index.ts": 557, - "apps/sim/triggers/index.ts": 539, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 501, - "apps/sim/lib/auth/index.ts": 488, - "apps/sim/blocks/registry.ts": 373, - "apps/sim/lib/webhooks/providers/index.ts": 132, - "apps/sim/lib/webhooks/providers/registry.ts": 130 + "apps/sim/triggers/index.ts": 543, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/blocks/registry.ts": 374, + "apps/sim/lib/api/server/routes/index.ts": 346, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 293, + "apps/sim/lib/auth/index.ts": 283, + "apps/sim/lib/webhooks/providers/index.ts": 89, + "apps/sim/lib/webhooks/providers/registry.ts": 87 } }, "app/api/v2/blocks/route.ts": { - "modules": 1797, + "modules": 1835, "gateways": { - "apps/sim/blocks/registry.ts": 916, - "apps/sim/lib/api/server/routes/index.ts": 550, - "apps/sim/triggers/index.ts": 539, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 502, - "apps/sim/lib/auth/index.ts": 489, - "apps/sim/lib/webhooks/providers/index.ts": 133, - "apps/sim/lib/webhooks/providers/registry.ts": 130 + "apps/sim/blocks/registry.ts": 921, + "apps/sim/triggers/index.ts": 543, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/lib/api/server/routes/index.ts": 338, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 294, + "apps/sim/lib/auth/index.ts": 284, + "apps/sim/lib/webhooks/providers/index.ts": 90, + "apps/sim/lib/webhooks/providers/registry.ts": 87 } }, "app/api/v2/connector-types/route.ts": { - "modules": 1866, + "modules": 1904, "gateways": { - "apps/sim/blocks/registry.ts": 917, - "apps/sim/lib/api/server/routes/index.ts": 548, - "apps/sim/triggers/index.ts": 539, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 500, - "apps/sim/lib/auth/index.ts": 487, - "apps/sim/lib/webhooks/providers/index.ts": 133, - "apps/sim/lib/webhooks/providers/registry.ts": 130 + "apps/sim/blocks/registry.ts": 922, + "apps/sim/triggers/index.ts": 543, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/lib/api/server/routes/index.ts": 338, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 294, + "apps/sim/lib/auth/index.ts": 284, + "apps/sim/lib/webhooks/providers/index.ts": 90, + "apps/sim/lib/webhooks/providers/registry.ts": 87 } }, "app/api/v2/tools/[toolId]/route.ts": { - "modules": 1795, + "modules": 1833, "gateways": { - "apps/sim/blocks/registry.ts": 916, - "apps/sim/lib/api/server/routes/index.ts": 558, - "apps/sim/triggers/index.ts": 539, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 502, - "apps/sim/lib/auth/index.ts": 489, - "apps/sim/lib/webhooks/providers/index.ts": 133, - "apps/sim/lib/webhooks/providers/registry.ts": 130 + "apps/sim/blocks/registry.ts": 921, + "apps/sim/triggers/index.ts": 543, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/lib/api/server/routes/index.ts": 347, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 294, + "apps/sim/lib/auth/index.ts": 284, + "apps/sim/lib/webhooks/providers/index.ts": 90, + "apps/sim/lib/webhooks/providers/registry.ts": 87 } }, "app/api/v2/tools/route.ts": { - "modules": 1796, + "modules": 1834, "gateways": { - "apps/sim/blocks/registry.ts": 916, - "apps/sim/lib/api/server/routes/index.ts": 550, - "apps/sim/triggers/index.ts": 539, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 502, - "apps/sim/lib/auth/index.ts": 489, - "apps/sim/lib/webhooks/providers/index.ts": 133, - "apps/sim/lib/webhooks/providers/registry.ts": 130 + "apps/sim/blocks/registry.ts": 921, + "apps/sim/triggers/index.ts": 543, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/lib/api/server/routes/index.ts": 338, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 294, + "apps/sim/lib/auth/index.ts": 284, + "apps/sim/lib/webhooks/providers/index.ts": 90, + "apps/sim/lib/webhooks/providers/registry.ts": 87 } }, "app/workspace/[workspaceId]/access-requests/loading.tsx": { @@ -79,33 +79,33 @@ "gateways": {} }, "app/workspace/[workspaceId]/changelog/layout.tsx": { - "modules": 1594, + "modules": 1607, "gateways": { - "apps/sim/app/workspace/[workspaceId]/changelog/components/index.ts": 1593, - "apps/sim/app/workspace/[workspaceId]/changelog/components/release-entry/release-entry.tsx": 1475, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 1443, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 1406, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 1329, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 380, - "apps/sim/blocks/registry-maps.ts": 378 + "apps/sim/app/workspace/[workspaceId]/changelog/components/index.ts": 1606, + "apps/sim/app/workspace/[workspaceId]/changelog/components/release-entry/release-entry.tsx": 1486, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 1453, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 1416, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 1339, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 381, + "apps/sim/blocks/registry-maps.ts": 379 } }, "app/workspace/[workspaceId]/changelog/loading.tsx": { - "modules": 16, + "modules": 17, "gateways": {} }, "app/workspace/[workspaceId]/changelog/page.tsx": { - "modules": 1630, + "modules": 1650, "gateways": { - "apps/sim/app/workspace/[workspaceId]/changelog/changelog.tsx": 1613, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 380, - "apps/sim/blocks/registry-maps.ts": 378, - "apps/sim/app/workspace/[workspaceId]/changelog/components/index.ts": 367, - "apps/sim/app/workspace/[workspaceId]/changelog/components/release-entry/release-entry.tsx": 363, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 335, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 298 + "apps/sim/app/workspace/[workspaceId]/changelog/changelog.tsx": 1632, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 381, + "apps/sim/blocks/registry-maps.ts": 379, + "apps/sim/app/workspace/[workspaceId]/changelog/components/index.ts": 369, + "apps/sim/app/workspace/[workspaceId]/changelog/components/release-entry/release-entry.tsx": 365, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 336, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 299 } }, "app/workspace/[workspaceId]/chat/[chatId]/error.tsx": { @@ -117,16 +117,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3500, + "modules": 3554, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1736, - "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1075, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1071, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 735, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 732, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/blocks/registry.ts": 342, - "apps/sim/lib/auth/index.ts": 311 + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1751, + "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1080, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1076, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 739, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 736, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/lib/auth/index.ts": 315 } }, "app/workspace/[workspaceId]/dashboards/layout.tsx": { @@ -134,20 +134,20 @@ "gateways": {} }, "app/workspace/[workspaceId]/dashboards/loading.tsx": { - "modules": 15, + "modules": 16, "gateways": {} }, "app/workspace/[workspaceId]/dashboards/page.tsx": { - "modules": 1295, - "gateways": { - "apps/sim/components/dashboards/dashboard-resource.tsx": 1279, - "apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts": 1123, - "apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts": 1091, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 382, - "apps/sim/blocks/registry-maps.ts": 379, + "modules": 1309, + "gateways": { + "apps/sim/components/dashboards/dashboard-resource.tsx": 1292, + "apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts": 1132, + "apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts": 1099, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 383, + "apps/sim/blocks/registry-maps.ts": 380, "apps/sim/triggers/plane/index.ts": 46, - "apps/sim/lib/api/contracts/index.ts": 45 + "apps/sim/lib/api/contracts/index.ts": 44 } }, "app/workspace/[workspaceId]/error.tsx": { @@ -155,27 +155,27 @@ "gateways": {} }, "app/workspace/[workspaceId]/files/[fileId]/loading.tsx": { - "modules": 18, + "modules": 19, "gateways": {} }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 2424, + "modules": 2468, "gateways": { - "apps/sim/triggers/registry.ts": 537, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 520, - "apps/sim/blocks/registry.ts": 366, - "apps/sim/lib/auth/index.ts": 313, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 302, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 271, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 199, - "apps/sim/lib/webhooks/providers/index.ts": 132 + "apps/sim/triggers/registry.ts": 541, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 525, + "apps/sim/blocks/registry.ts": 367, + "apps/sim/lib/auth/index.ts": 318, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 303, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 272, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 200, + "apps/sim/lib/webhooks/providers/index.ts": 89 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { - "modules": 71, + "modules": 75, "gateways": { - "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 70, - "apps/sim/hooks/queries/workspace-files.ts": 66 + "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 74, + "apps/sim/hooks/queries/workspace-files.ts": 63 } }, "app/workspace/[workspaceId]/files/error.tsx": { @@ -183,20 +183,20 @@ "gateways": {} }, "app/workspace/[workspaceId]/files/loading.tsx": { - "modules": 16, + "modules": 17, "gateways": {} }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 2423, + "modules": 2467, "gateways": { - "apps/sim/triggers/registry.ts": 537, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 521, - "apps/sim/blocks/registry.ts": 366, - "apps/sim/lib/auth/index.ts": 313, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 302, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 271, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 199, - "apps/sim/lib/webhooks/providers/index.ts": 132 + "apps/sim/triggers/registry.ts": 541, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 526, + "apps/sim/blocks/registry.ts": 367, + "apps/sim/lib/auth/index.ts": 318, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 303, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 272, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 200, + "apps/sim/lib/webhooks/providers/index.ts": 89 } }, "app/workspace/[workspaceId]/home/error.tsx": { @@ -208,41 +208,41 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3500, + "modules": 3554, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1736, - "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1075, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1071, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 735, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 732, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/blocks/registry.ts": 342, - "apps/sim/lib/auth/index.ts": 311 + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1751, + "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1080, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1076, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 739, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 736, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/lib/auth/index.ts": 315 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1234, + "modules": 1242, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1155, - "apps/sim/triggers/index.ts": 580, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 393, - "apps/sim/lib/api/contracts/index.ts": 54, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1160, + "apps/sim/triggers/index.ts": 584, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 394, + "apps/sim/lib/api/contracts/index.ts": 53, "apps/sim/triggers/plane/index.ts": 46, "apps/sim/triggers/clickup/index.ts": 32 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1308, + "modules": 1318, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1262, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 385, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 101, - "apps/sim/components/permissions/index.ts": 87, - "apps/sim/components/permissions/add-people-modal.tsx": 82, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 80, - "apps/sim/lib/api/contracts/index.ts": 58 + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1270, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 386, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 103, + "apps/sim/components/permissions/index.ts": 89, + "apps/sim/components/permissions/add-people-modal.tsx": 84, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 82, + "apps/sim/lib/api/contracts/index.ts": 57 } }, "app/workspace/[workspaceId]/integrations/error.tsx": { @@ -250,32 +250,32 @@ "gateways": {} }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1207, - "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1203, - "apps/sim/blocks/registry.ts": 978, - "apps/sim/triggers/index.ts": 580, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/hooks/queries/credentials.ts": 73, - "apps/sim/lib/api/contracts/index.ts": 54, + "modules": 1215, + "gateways": { + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1211, + "apps/sim/blocks/registry.ts": 984, + "apps/sim/triggers/index.ts": 584, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/hooks/queries/credentials.ts": 72, + "apps/sim/lib/api/contracts/index.ts": 53, "apps/sim/triggers/plane/index.ts": 46, "apps/sim/triggers/clickup/index.ts": 32 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx": { - "modules": 18, + "modules": 19, "gateways": {} }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1452, + "modules": 1464, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1388, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 376, - "apps/sim/blocks/registry-maps.ts": 373, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 88, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1397, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 377, + "apps/sim/blocks/registry-maps.ts": 374, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 90, "apps/sim/connectors/registry.ts": 75, - "apps/sim/lib/api/contracts/index.ts": 55, + "apps/sim/lib/api/contracts/index.ts": 54, "apps/sim/triggers/plane/index.ts": 46 } }, @@ -284,20 +284,20 @@ "gateways": {} }, "app/workspace/[workspaceId]/knowledge/[id]/loading.tsx": { - "modules": 19, + "modules": 20, "gateways": {} }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1619, + "modules": 1634, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1554, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 378, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 154, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1566, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 379, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 155, "apps/sim/connectors/registry.ts": 70, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.ts": 52, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.ts": 53, "apps/sim/triggers/plane/index.ts": 46, - "apps/sim/app/o/[organizationId]/providers/organization-provider.tsx": 42 + "apps/sim/app/o/[organizationId]/providers/organization-provider.tsx": 43 } }, "app/workspace/[workspaceId]/knowledge/error.tsx": { @@ -305,33 +305,33 @@ "gateways": {} }, "app/workspace/[workspaceId]/knowledge/loading.tsx": { - "modules": 18, + "modules": 19, "gateways": {} }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2587, + "modules": 2638, "gateways": { - "apps/sim/triggers/registry.ts": 537, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 444, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 379, - "apps/sim/blocks/registry.ts": 367, - "apps/sim/lib/auth/index.ts": 254, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 233, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 452, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 386, + "apps/sim/blocks/registry.ts": 368, + "apps/sim/lib/auth/index.ts": 256, + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 237, "apps/sim/lib/knowledge/orchestration/index.ts": 231, "apps/sim/lib/knowledge/orchestration/connectors.ts": 227 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2449, + "modules": 2495, "gateways": { - "apps/sim/triggers/registry.ts": 537, - "apps/sim/blocks/registry.ts": 361, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 286, - "apps/sim/lib/auth/index.ts": 279, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/blocks/registry.ts": 362, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 285, + "apps/sim/lib/auth/index.ts": 282, "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 154, - "apps/sim/lib/webhooks/providers/index.ts": 131, - "apps/sim/lib/webhooks/providers/registry.ts": 129, - "apps/sim/app/workspace/[workspaceId]/prefetch-access.ts": 91 + "apps/sim/app/workspace/[workspaceId]/prefetch-access.ts": 91, + "apps/sim/lib/webhooks/providers/index.ts": 88, + "apps/sim/ee/access-requests/lib/application/requests.ts": 86 } }, "app/workspace/[workspaceId]/logs/error.tsx": { @@ -339,20 +339,20 @@ "gateways": {} }, "app/workspace/[workspaceId]/logs/loading.tsx": { - "modules": 16, + "modules": 17, "gateways": {} }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1902, - "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1885, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 566, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 517, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 511, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 442, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 432, - "apps/sim/blocks/registry.ts": 360 + "modules": 1919, + "gateways": { + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1901, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 569, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 518, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 512, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 443, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 433, + "apps/sim/blocks/registry.ts": 361 } }, "app/workspace/[workspaceId]/not-found.tsx": { @@ -372,16 +372,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2976, - "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 1164, - "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 968, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/lib/auth/index.ts": 451, - "apps/sim/ee/workspace-forking/components/forks.tsx": 410, - "apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx": 402, - "apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx": 394, - "apps/sim/ee/workspace-forking/components/fork-sync/fork-comparison-modal.tsx": 388 + "modules": 3032, + "gateways": { + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 1178, + "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 984, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/ee/workspace-forking/components/forks.tsx": 409, + "apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx": 401, + "apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx": 393, + "apps/sim/ee/workspace-forking/components/fork-sync/fork-comparison-modal.tsx": 387, + "apps/sim/blocks/registry.ts": 360 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -393,15 +393,15 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1750, - "gateways": { - "apps/sim/lib/auth/index.ts": 1593, - "apps/sim/blocks/registry.ts": 920, - "apps/sim/blocks/registry-maps.ts": 917, - "apps/sim/triggers/index.ts": 539, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/lib/webhooks/providers/index.ts": 135, - "apps/sim/lib/webhooks/providers/registry.ts": 132, + "modules": 1789, + "gateways": { + "apps/sim/lib/auth/index.ts": 1629, + "apps/sim/blocks/registry.ts": 925, + "apps/sim/blocks/registry-maps.ts": 922, + "apps/sim/triggers/index.ts": 543, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/lib/webhooks/providers/index.ts": 90, + "apps/sim/lib/webhooks/providers/registry.ts": 87, "apps/sim/lib/workflows/lifecycle.ts": 48 } }, @@ -410,15 +410,15 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/layout.tsx": { - "modules": 2293, + "modules": 2318, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 2258, - "apps/sim/triggers/registry.ts": 578, + "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 2281, + "apps/sim/triggers/registry.ts": 582, "apps/sim/ee/workspace-forking/components/forks.tsx": 414, "apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx": 406, "apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx": 397, "apps/sim/ee/workspace-forking/components/fork-sync/fork-comparison-modal.tsx": 391, - "apps/sim/blocks/registry.ts": 360, + "apps/sim/blocks/registry.ts": 361, "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 359 } }, @@ -427,29 +427,29 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/loading.tsx": { - "modules": 1241, + "modules": 1251, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1238, - "apps/sim/components/permissions/index.ts": 1075, - "apps/sim/components/permissions/add-people-modal.tsx": 1070, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 1068, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 387, - "apps/sim/blocks/registry-maps.ts": 384, - "apps/sim/lib/api/contracts/index.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1248, + "apps/sim/components/permissions/index.ts": 1083, + "apps/sim/components/permissions/add-people-modal.tsx": 1078, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 1076, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 388, + "apps/sim/blocks/registry-maps.ts": 385, + "apps/sim/lib/api/contracts/index.ts": 59 } }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1263, + "modules": 1273, "gateways": { - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 387, - "apps/sim/blocks/registry-maps.ts": 384, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 99, - "apps/sim/components/permissions/index.ts": 87, - "apps/sim/components/permissions/add-people-modal.tsx": 82, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 80, - "apps/sim/lib/api/contracts/index.ts": 60 + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 388, + "apps/sim/blocks/registry-maps.ts": 385, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 101, + "apps/sim/components/permissions/index.ts": 89, + "apps/sim/components/permissions/add-people-modal.tsx": 84, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 82, + "apps/sim/lib/api/contracts/index.ts": 59 } }, "app/workspace/[workspaceId]/settings/usage/events/layout.tsx": { @@ -461,29 +461,29 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/usage/events/page.tsx": { - "modules": 1757, - "gateways": { - "apps/sim/lib/auth/index.ts": 1593, - "apps/sim/blocks/registry.ts": 920, - "apps/sim/blocks/registry-maps.ts": 917, - "apps/sim/triggers/index.ts": 539, - "apps/sim/triggers/registry.ts": 537, - "apps/sim/lib/webhooks/providers/index.ts": 135, - "apps/sim/lib/webhooks/providers/registry.ts": 132, + "modules": 1796, + "gateways": { + "apps/sim/lib/auth/index.ts": 1629, + "apps/sim/blocks/registry.ts": 925, + "apps/sim/blocks/registry-maps.ts": 922, + "apps/sim/triggers/index.ts": 543, + "apps/sim/triggers/registry.ts": 541, + "apps/sim/lib/webhooks/providers/index.ts": 90, + "apps/sim/lib/webhooks/providers/registry.ts": 87, "apps/sim/lib/workflows/lifecycle.ts": 48 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1529, + "modules": 1542, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1528, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 380, - "apps/sim/blocks/registry-maps.ts": 378, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 278, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 275, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 124, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts": 69 + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1541, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 381, + "apps/sim/blocks/registry-maps.ts": 379, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 281, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 278, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 126, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts": 71 } }, "app/workspace/[workspaceId]/skills/error.tsx": { @@ -491,29 +491,29 @@ "gateways": {} }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1527, + "modules": 1540, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1526, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 380, - "apps/sim/blocks/registry-maps.ts": 378, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 278, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 275, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 124, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts": 69 + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1539, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 381, + "apps/sim/blocks/registry-maps.ts": 379, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 281, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 278, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 126, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts": 71 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1143, + "modules": 1151, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1139, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1009, - "apps/sim/blocks/registry.ts": 998, - "apps/sim/blocks/registry-maps.ts": 996, - "apps/sim/triggers/index.ts": 580, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/hooks/queries/skills.ts": 74, - "apps/sim/lib/api/contracts/index.ts": 67 + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1147, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1015, + "apps/sim/blocks/registry.ts": 1004, + "apps/sim/blocks/registry-maps.ts": 1002, + "apps/sim/triggers/index.ts": 584, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/hooks/queries/skills.ts": 75, + "apps/sim/lib/api/contracts/index.ts": 66 } }, "app/workspace/[workspaceId]/tables/[tableId]/error.tsx": { @@ -521,20 +521,20 @@ "gateways": {} }, "app/workspace/[workspaceId]/tables/[tableId]/loading.tsx": { - "modules": 16, + "modules": 17, "gateways": {} }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2030, + "modules": 2045, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1968, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 462, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 415, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 410, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 353, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 344, - "apps/sim/blocks/registry.ts": 343 + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1980, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 465, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 416, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 411, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 354, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 345, + "apps/sim/blocks/registry.ts": 344 } }, "app/workspace/[workspaceId]/tables/error.tsx": { @@ -542,73 +542,73 @@ "gateways": {} }, "app/workspace/[workspaceId]/tables/loading.tsx": { - "modules": 16, + "modules": 17, "gateways": {} }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 2123, + "modules": 2168, "gateways": { - "apps/sim/triggers/registry.ts": 537, - "apps/sim/lib/auth/index.ts": 499, - "apps/sim/blocks/registry.ts": 368, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 210, - "apps/sim/lib/webhooks/providers/index.ts": 132, - "apps/sim/lib/webhooks/providers/registry.ts": 130, - "apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts": 101, - "apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts": 53 + "apps/sim/triggers/registry.ts": 541, + "apps/sim/lib/auth/index.ts": 521, + "apps/sim/blocks/registry.ts": 369, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 215, + "apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts": 102, + "apps/sim/lib/webhooks/providers/index.ts": 89, + "apps/sim/lib/webhooks/providers/registry.ts": 87, + "apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts": 54 } }, "app/workspace/[workspaceId]/tests/[name]/loading.tsx": { - "modules": 16, + "modules": 17, "gateways": {} }, "app/workspace/[workspaceId]/tests/[name]/page.tsx": { - "modules": 1948, + "modules": 1963, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tests/components/index.ts": 1947, - "apps/sim/app/workspace/[workspaceId]/tests/components/test-detail.tsx": 1944, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 360, - "apps/sim/app/workspace/[workspaceId]/tests/components/test-dashboard.tsx": 349, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 340, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 294, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 285 + "apps/sim/app/workspace/[workspaceId]/tests/components/index.ts": 1962, + "apps/sim/app/workspace/[workspaceId]/tests/components/test-detail.tsx": 1959, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 361, + "apps/sim/app/workspace/[workspaceId]/tests/components/test-dashboard.tsx": 351, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 342, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 280 } }, "app/workspace/[workspaceId]/tests/layout.tsx": { - "modules": 1948, + "modules": 1963, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tests/components/index.ts": 1947, - "apps/sim/app/workspace/[workspaceId]/tests/components/test-detail.tsx": 1944, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 360, - "apps/sim/app/workspace/[workspaceId]/tests/components/test-dashboard.tsx": 349, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 340, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 294, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 285 + "apps/sim/app/workspace/[workspaceId]/tests/components/index.ts": 1962, + "apps/sim/app/workspace/[workspaceId]/tests/components/test-detail.tsx": 1959, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 361, + "apps/sim/app/workspace/[workspaceId]/tests/components/test-dashboard.tsx": 351, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 342, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 280 } }, "app/workspace/[workspaceId]/tests/loading.tsx": { - "modules": 16, + "modules": 17, "gateways": {} }, "app/workspace/[workspaceId]/tests/page.tsx": { - "modules": 1259, - "gateways": { - "apps/sim/app/workspace/[workspaceId]/tests/tests.tsx": 1242, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 1099, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 387, - "apps/sim/blocks/registry-maps.ts": 384, - "apps/sim/lib/api/contracts/index.ts": 60, + "modules": 1272, + "gateways": { + "apps/sim/app/workspace/[workspaceId]/tests/tests.tsx": 1254, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 1107, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 388, + "apps/sim/blocks/registry-maps.ts": 385, + "apps/sim/lib/api/contracts/index.ts": 59, "apps/sim/triggers/plane/index.ts": 46, "apps/sim/stores/workflows/registry/store.ts": 39 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 171, + "modules": 173, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 164, + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 166, "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 110, "apps/sim/lib/billing/client/upgrade.ts": 102, "apps/sim/hooks/queries/organization.ts": 96, @@ -621,75 +621,75 @@ "gateways": {} }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2431, + "modules": 2455, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2430, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 673, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 624, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 360, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 290, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 185, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 181 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2454, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 682, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 633, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 361, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 291, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 188, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 184 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2413, - "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1131, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 847, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 439, - "apps/sim/blocks/registry.ts": 360, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 190, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 186, + "modules": 2441, + "gateways": { + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1135, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 854, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 441, + "apps/sim/blocks/registry.ts": 361, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 193, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 189, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 177 } }, "app/workspace/layout.tsx": { - "modules": 1209, + "modules": 1219, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1199, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 387, - "apps/sim/blocks/registry-maps.ts": 384, - "apps/sim/stores/workflows/registry/store.ts": 108, - "apps/sim/hooks/queries/deployments.ts": 105, - "apps/sim/lib/workflows/comparison/describe.ts": 90, - "apps/sim/lib/api/contracts/index.ts": 60 + "apps/sim/app/workspace/providers/socket-provider.tsx": 1209, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 388, + "apps/sim/blocks/registry-maps.ts": 385, + "apps/sim/stores/workflows/registry/store.ts": 107, + "apps/sim/hooks/queries/deployments.ts": 104, + "apps/sim/lib/workflows/comparison/describe.ts": 89, + "apps/sim/lib/api/contracts/index.ts": 59 } }, "app/workspace/page.tsx": { - "modules": 1212, - "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 1091, - "apps/sim/stores/reset-all-stores.ts": 1087, - "apps/sim/triggers/index.ts": 580, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/blocks/registry.ts": 388, - "apps/sim/blocks/registry-maps.ts": 385, - "apps/sim/lib/api/contracts/index.ts": 55, + "modules": 1233, + "gateways": { + "apps/sim/lib/auth/stale-session-recovery.ts": 1097, + "apps/sim/stores/reset-all-stores.ts": 1093, + "apps/sim/triggers/index.ts": 584, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/blocks/registry.ts": 389, + "apps/sim/blocks/registry-maps.ts": 386, + "apps/sim/lib/api/contracts/index.ts": 54, "apps/sim/triggers/plane/index.ts": 46 } }, "lib/catalog/projection/block-detail.ts": { - "modules": 1045, + "modules": 1052, "gateways": { - "apps/sim/triggers/index.ts": 580, - "apps/sim/triggers/registry.ts": 578, - "apps/sim/lib/catalog/projection/block-summary.ts": 423, - "apps/sim/blocks/registry-maps.ts": 419, + "apps/sim/triggers/index.ts": 584, + "apps/sim/triggers/registry.ts": 582, + "apps/sim/lib/catalog/projection/block-summary.ts": 424, + "apps/sim/blocks/registry-maps.ts": 420, "apps/sim/triggers/plane/index.ts": 46, "apps/sim/triggers/clickup/index.ts": 32 } }, "lib/catalog/projection/block-summary.ts": { - "modules": 1040, + "modules": 1047, "gateways": { - "apps/sim/blocks/registry.ts": 1030, - "apps/sim/blocks/registry-maps.ts": 1027, - "apps/sim/triggers/index.ts": 580, - "apps/sim/triggers/registry.ts": 578, + "apps/sim/blocks/registry.ts": 1037, + "apps/sim/blocks/registry-maps.ts": 1034, + "apps/sim/triggers/index.ts": 584, + "apps/sim/triggers/registry.ts": 582, "apps/sim/triggers/plane/index.ts": 46, "apps/sim/triggers/clickup/index.ts": 32 } diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index 6426aac971c..6d0757ae64a 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -1151,10 +1151,8 @@ "apps/sim/hooks/queries/workflow-search-replace.ts#workflowSearchReplaceKeys", "apps/sim/hooks/queries/workflows.ts#WORKFLOW_DEPLOYMENT_VERSION_STATE_STALE_TIME", "apps/sim/hooks/queries/workspace-file-table.ts#WORKSPACE_CSV_PREVIEW_STALE_TIME", - "apps/sim/hooks/queries/workspace-file-table.ts#workspaceFileTableKeys", "apps/sim/hooks/queries/workspace-files.ts#CLOUD_STORAGE_CONFIGURED_STALE_TIME", "apps/sim/hooks/queries/workspace-files.ts#DocNotReadyError", - "apps/sim/hooks/queries/workspace-files.ts#WORKSPACE_FILES_LIST_STALE_TIME", "apps/sim/hooks/queries/workspace-files.ts#WORKSPACE_FILE_BINARY_STALE_TIME", "apps/sim/hooks/queries/workspace-files.ts#WORKSPACE_FILE_CONTENT_STALE_TIME", "apps/sim/hooks/queries/workspace-usage.ts#WORKSPACE_CREDIT_AVAILABILITY_STALE_TIME", @@ -1478,7 +1476,6 @@ "apps/sim/lib/api/contracts/file-doc.ts#mergeFileDocBodySchema", "apps/sim/lib/api/contracts/file-doc.ts#mergeFileDocResponseSchema", "apps/sim/lib/api/contracts/file-doc.ts#persistFileDocBodySchema", - "apps/sim/lib/api/contracts/file-doc.ts#persistFileDocResponseSchema", "apps/sim/lib/api/contracts/folders.ts#createFolderBodySchema", "apps/sim/lib/api/contracts/folders.ts#duplicateFolderBodySchema", "apps/sim/lib/api/contracts/folders.ts#folderCascadeCountsSchema", @@ -3208,15 +3205,11 @@ "apps/sim/lib/api/contracts/v2/file-versions.ts#V2FileVersionRevertResult", "apps/sim/lib/api/contracts/v2/file-versions.ts#V2ListFileVersionsQuery", "apps/sim/lib/api/contracts/v2/file-versions.ts#V2RevertFileVersionBody", - "apps/sim/lib/api/contracts/v2/file-versions.ts#v2DeleteFileVersionResultSchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionAuthorSchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionParamsSchema", - "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionSchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionSortFields", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionSourceSchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionTextSchema", - "apps/sim/lib/api/contracts/v2/file-versions.ts#v2ListFileVersionsQuerySchema", - "apps/sim/lib/api/contracts/v2/file-versions.ts#v2RevertFileVersionBodySchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2RevertFileVersionResultSchema", "apps/sim/lib/api/contracts/v2/files.ts#V2BulkDeleteFilesBody", "apps/sim/lib/api/contracts/v2/files.ts#V2BulkDeleteFilesResult", @@ -3253,9 +3246,6 @@ "apps/sim/lib/api/contracts/v2/files.ts#splitFolderPathList", "apps/sim/lib/api/contracts/v2/files.ts#v2BulkDeleteFilesResultSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2BulkDownloadFilesQuerySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2CreateFileBodySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2CreateFileUploadBodySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2CreateFileUploadDataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2CreatedFileSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2DeleteFileFolderDataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2DeleteFileResultSchema", @@ -3266,23 +3256,17 @@ "apps/sim/lib/api/contracts/v2/files.ts#v2FileScopeSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileSearchResultsSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileShareSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2FileSortFields", "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadParamsSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadWorkspaceQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2GetFileMetadataQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2ListFileFoldersQuerySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2ListFilesQuerySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2MoveFileItemsBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2MoveFileItemsResultSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2NullableFileShareSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2RenameFileBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileFolderBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileFolderDataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2UnzipFileBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2UnzipFileDataSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2UpdateFileContentBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2WrittenFileSchema", "apps/sim/lib/api/contracts/v2/knowledge.ts#MAX_V2_BULK_KNOWLEDGE_DOCUMENTS", "apps/sim/lib/api/contracts/v2/knowledge.ts#MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS", @@ -3944,33 +3928,19 @@ "apps/sim/lib/api/contracts/workflows.ts#workflowVariableReadSchema", "apps/sim/lib/api/contracts/workflows.ts#workflowVariableWriteSchema", "apps/sim/lib/api/contracts/workflows.ts#workflowVariablesBodySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#bulkArchiveWorkspaceFileItemsBodySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#createWorkspaceFileFolderBodySchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#downloadWorkspaceFileItemsQuerySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#listWorkspaceFileFoldersQuerySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#moveWorkspaceFileItemsBodySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#updateWorkspaceFileFolderBodySchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFolderParamsSchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFolderSchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFolderScopeSchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFoldersParamsSchema", "apps/sim/lib/api/contracts/workspace-file-table.ts#WorkspaceCsvPreviewParams", "apps/sim/lib/api/contracts/workspace-file-table.ts#WorkspaceCsvPreviewQuery", "apps/sim/lib/api/contracts/workspace-file-table.ts#workspaceCsvPreviewParamsSchema", - "apps/sim/lib/api/contracts/workspace-file-table.ts#workspaceCsvPreviewQuerySchema", - "apps/sim/lib/api/contracts/workspace-file-table.ts#workspaceCsvPreviewResponseSchema", "apps/sim/lib/api/contracts/workspace-files.ts#ExportWorkspaceFileSnapshotBody", - "apps/sim/lib/api/contracts/workspace-files.ts#ExtractWorkspaceFileResponse", "apps/sim/lib/api/contracts/workspace-files.ts#UpdateWorkspaceFileDimensionsBody", "apps/sim/lib/api/contracts/workspace-files.ts#extractWorkspaceFileResponseSchema", "apps/sim/lib/api/contracts/workspace-files.ts#listWorkspaceFilesQuerySchema", - "apps/sim/lib/api/contracts/workspace-files.ts#renameWorkspaceFileBodySchema", "apps/sim/lib/api/contracts/workspace-files.ts#renameWorkspaceFileErrorSchema", - "apps/sim/lib/api/contracts/workspace-files.ts#updateWorkspaceFileContentBodySchema", "apps/sim/lib/api/contracts/workspace-files.ts#updateWorkspaceFileDimensionsBodySchema", - "apps/sim/lib/api/contracts/workspace-files.ts#workspaceFileParamsSchema", - "apps/sim/lib/api/contracts/workspace-files.ts#workspaceFileRecordSchema", - "apps/sim/lib/api/contracts/workspace-files.ts#workspaceFileScopeSchema", "apps/sim/lib/api/contracts/workspace-files.ts#workspaceFilesParamsSchema", "apps/sim/lib/api/contracts/workspace-fork.ts#ForkCopyableFile", "apps/sim/lib/api/contracts/workspace-fork.ts#ForkCopyableResource", diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 627175aa1a9..5f8114dc086 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -1293,7 +1293,8 @@ function build(): Map { assertGuidesExist() const program = buildProgram({ version: false }) - const top = subcommands(program) + /** Projects is executable but unpublished until launch; do not change CLI help or admission. */ + const top = subcommands(program).filter((command) => command.name() !== 'projects') const groups = top.filter((command) => subcommands(command).length > 0) const globals = top .filter((command) => subcommands(command).length === 0) diff --git a/scripts/generate-v2-cli-api.test.ts b/scripts/generate-v2-cli-api.test.ts index b8bc571750d..380374f8a79 100644 --- a/scripts/generate-v2-cli-api.test.ts +++ b/scripts/generate-v2-cli-api.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { z } from 'zod' import { CLI_MANAGED_HEADERS, @@ -11,6 +11,12 @@ import { } from './generate-v2-cli-api' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +let operationDocs: Awaited> + +/** Load and transform the canonical contract graph once, outside individual assertion deadlines. */ +beforeAll(async () => { + operationDocs = await loadSummaries(workspaceKeyDenialMarkers()) +}, 30_000) describe('discriminated object request bodies', () => { const schema = z.discriminatedUnion('action', [ @@ -71,9 +77,8 @@ describe('request headers reaching the CLI as flags', () => { /** * Reads the denial sentences out of `openapi/shared.ts` as source text. * - * The generator itself imports that module, but it resolves through the `@/` - * alias, which the root vitest run has no resolver for. Parsing the literal - * keeps the test bound to the same single source of truth: reword the sentence + * Parsing the literal keeps the expectation independent of the generator's + * module loader while bound to the same single source of truth: reword the sentence * and this recomputes the expected set, so a generator holding a stale copy of * it goes red instead of silently unmarking a family. */ @@ -107,12 +112,29 @@ describe('operations that refuse a workspace API key', () => { * pinned pair stayed green. */ it('emits the marker for every operation the specs say refuses one', () => { - const marked = [...loadSummaries(workspaceKeyDenialMarkers()).values()].filter( - (doc) => doc.workspaceKeyUnsupported - ) + const marked = [...operationDocs.values()].filter((doc) => doc.workspaceKeyUnsupported) expect(marked.length).toBeGreaterThan(0) expect(generatedSource().match(/workspaceKeyUnsupported: true/g)?.length ?? 0).toBe( marked.length ) }) }) + +describe('unpublished Project documentation', () => { + it('retains runtime help and key restrictions without publishing Project paths', () => { + const projectList = operationDocs.get('GET /api/v2/projects/{projectId}/files') + expect(projectList?.summary).toBeTruthy() + expect(projectList?.description).toContain('OAuth scope:') + expect(projectList?.workspaceKeyUnsupported).toBe(true) + const publicSpec = JSON.parse( + readFileSync(path.join(ROOT, 'apps/docs/openapi-v2-files-audit.json'), 'utf8') + ) + expect(publicSpec.info.description).not.toContain('Project') + expect( + publicSpec.tags.some((tag: { description: string }) => tag.description.includes('Project')) + ).toBe(false) + expect(Object.keys(publicSpec.paths).some((key) => key.startsWith('/api/v2/projects/'))).toBe( + false + ) + }) +}) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index fc6cce672f0..4b9af6376a0 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -115,11 +115,25 @@ export function docPathKey(method: string, contractPath: string): string { * `description` is read for the same reason — it is where the workspace-key * denial is already stated. */ -export function loadSummaries( +export async function loadSummaries( workspaceKeyDenialMarkers: readonly string[] -): Map { +): Promise> { const docs = new Map() - + const [ + { generateOpenApiDocument }, + { filesAuditOpenApiDocument }, + { unpublishedProjectFileDocumentation }, + ] = await Promise.all([ + import(path.join(ROOT, 'scripts/openapi/generator.ts')), + import(path.join(ROOT, 'apps/sim/lib/api/contracts/v2/openapi/files-audit.ts')), + import(path.join(ROOT, 'apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts')), + ]) + /** Runtime help retains canonical metadata even when its public reference is unpublished. */ + const unpublished = generateOpenApiDocument({ + ...filesAuditOpenApiDocument, + routes: unpublishedProjectFileDocumentation.routes, + }) + const specs = [unpublished] for (const file of specFiles()) { let spec: Record try { @@ -130,6 +144,10 @@ export function loadSummaries( continue } + specs.push(spec) + } + + for (const spec of specs) { for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { for (const [method, operation] of Object.entries(methods as Record)) { const doc: OperationDoc = {} @@ -717,6 +735,7 @@ export function render(operations: Operation[], docs: Map) const discriminator = renderBodyDiscriminator(op.contract.body, ' ') if (discriminator) out.push(` bodyDiscriminator: ${discriminator},`) else if (isUnionSlot(op.contract.body)) out.push(` opaqueBody: true,`) + else if (!map && op.contract.body instanceof z.ZodObject) out.push(` body: {},`) } } // Contract headers are request input like any other slot: `upload-token` @@ -768,7 +787,9 @@ async function main() { const args = new Set(process.argv.slice(2)) const operations = await collectOperations() - const generated = format(render(operations, loadSummaries(await loadWorkspaceKeyDenialMarkers()))) + const generated = format( + render(operations, await loadSummaries(await loadWorkspaceKeyDenialMarkers())) + ) if (args.has('--check')) { let current = '' diff --git a/scripts/generate-v2-mcp-operations.ts b/scripts/generate-v2-mcp-operations.ts index f921ebd65e6..e6a7ffca87b 100644 --- a/scripts/generate-v2-mcp-operations.ts +++ b/scripts/generate-v2-mcp-operations.ts @@ -161,7 +161,7 @@ function format(source: string): string { async function main() { const check = process.argv.includes('--check') - const docs = loadSummaries(await loadWorkspaceKeyDenialMarkers()) + const docs = await loadSummaries(await loadWorkspaceKeyDenialMarkers()) const readRoute = (relativePath: string) => { const file = path.join(APP_ROOT, relativePath) return existsSync(file) ? readFileSync(file, 'utf8') : null